diff --git a/packages/ember/README.md b/packages/ember/README.md
index 24b6fe37a17..0ee536c9c9d 100644
--- a/packages/ember/README.md
+++ b/packages/ember/README.md
@@ -218,6 +218,9 @@ import { Await } from '@warp-drive/ember';
```
+When using the Await component, if no error block is provided and the promise rejects,
+the error will be thrown.
+
### RequestState
RequestState extends PromiseState to provide a reactive wrapper for a request `Future` which
@@ -320,6 +323,10 @@ import { Request } from '@warp-drive/ember';
```
+When using the Await component, if no error block is provided and the request rejects,
+the error will be thrown. Cancellation errors are not rethrown if no error block or
+cancellation block is present.
+
- Streaming Data
The loading state exposes the download `ReadableStream` instance for consumption
@@ -365,7 +372,36 @@ import { Request } from '@warp-drive/ember';
If a request is aborted but no cancelled block is present, the error will be given
to the error block to handle.
-If no error block is present, the error will be rethrown.
+If no error block is present, the cancellation error will be swallowed.
+
+- retry
+
+Cancelled and error'd requests may be retried,
+retry will reuse the error, cancelled and loading
+blocks as appropriate.
+
+```gjs
+import { Request } from '@warp-drive/ember';
+import { on } from '@ember/modifier';
+
+
+
+ <:cancelled as |error state|>
+
The Request Cancelled
+
+
+
+ <:error as |error state|>
+
+
+
+
+ <:content as |result|>
+
{{result.title}}
+
+
+
+```
- Reloading states
@@ -434,21 +470,36 @@ import { Request } from '@warp-drive/ember';
```
-- AutoRefresh behavior
+- Autorefresh behavior
Requests can be made to automatically refresh when a browser window or tab comes back to the
-foreground after being backgrounded.
+foreground after being backgrounded or when the network reports as being online after having
+been offline.
```gjs
import { Request } from '@warp-drive/ember';
-
+
```
+By default, an autorefresh will only occur if the browser was backgrounded or offline for more than
+30s before coming back available. This amount of time can be tweaked by setting the number of milliseconds
+via `@autorefreshThreshold`.
+
+The behavior of the fetch initiated by the autorefresh can also be adjusted by `@autorefreshBehavior`
+
+Options are:
+
+- `refresh` update while continuing to show the current state.
+- `reload` update and show the loading state until update completes)
+- `delegate` (**default**) trigger the request, but let the cache handler decide whether the update should occur or if the cache is still valid.
+
+---
+
Similarly, refresh could be set up on a timer or on a websocket subscription by using the yielded
refresh function and passing it to another component.
@@ -456,7 +507,7 @@ refresh function and passing it to another component.
import { Request } from '@warp-drive/ember';
-
+
<:content as |result state|>
{{result.title}}
diff --git a/packages/ember/src/-private/await.gts b/packages/ember/src/-private/await.gts
index 44cfec44592..61d5189f2a0 100644
--- a/packages/ember/src/-private/await.gts
+++ b/packages/ember/src/-private/await.gts
@@ -10,7 +10,6 @@ export function notNull(x: T | null) {
return x;
}
export const and = (x: unknown, y: unknown) => Boolean(x && y);
-
interface ThrowSignature {
Args: {
error: E;
diff --git a/packages/ember/src/-private/request.gts b/packages/ember/src/-private/request.gts
index ff9d8af52af..5868978d2bb 100644
--- a/packages/ember/src/-private/request.gts
+++ b/packages/ember/src/-private/request.gts
@@ -2,8 +2,9 @@ import { assert } from '@ember/debug';
import { service } from '@ember/service';
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
+import { EnableHydration, type RequestInfo } from '@warp-drive/core-types/request';
import type { Future, StructuredErrorDocument } from '@ember-data/request';
-
+import type { RequestState } from './request-state.ts';
import { importSync, macroCondition, moduleExists } from '@embroider/macros';
import type { StoreRequestInput } from '@ember-data/store';
@@ -12,6 +13,11 @@ import type Store from '@ember-data/store';
import { getRequestState } from './request-state.ts';
import type { RequestLoadingState } from './request-state.ts';
import { and, notNull, Throw } from './await.gts';
+import { tracked } from '@glimmer/tracking';
+
+const not = (x: unknown) => !x;
+// default to 30 seconds unavailable before we refresh
+const DEFAULT_DEADLINE = 30_000;
let provide = service;
if (macroCondition(moduleExists('ember-provide-consume-context'))) {
@@ -19,17 +25,37 @@ if (macroCondition(moduleExists('ember-provide-consume-context'))) {
provide = consume;
}
+type ContentFeatures = {
+ isOnline: boolean;
+ isHidden: boolean;
+ isRefreshing: boolean;
+ refresh: () => Promise;
+ reload: () => Promise;
+ abort?: () => void;
+ latestRequest?: Future;
+};
+
interface RequestSignature {
Args: {
request?: Future;
query?: StoreRequestInput;
store?: Store;
+ autorefresh?: boolean;
+ autorefreshThreshold?: number;
+ autorefreshBehavior?: 'refresh' | 'reload' | 'policy';
};
Blocks: {
loading: [state: RequestLoadingState];
- cancelled: [error: StructuredErrorDocument];
- error: [error: StructuredErrorDocument];
- content: [value: T];
+ cancelled: [
+ error: StructuredErrorDocument,
+ features: { isOnline: boolean; isHidden: boolean; retry: () => Promise },
+ ];
+ error: [
+ error: StructuredErrorDocument,
+ features: { isOnline: boolean; isHidden: boolean; retry: () => Promise },
+ ];
+ content: [value: T, features: ContentFeatures];
+ always: [state: RequestState];
};
}
@@ -38,15 +64,164 @@ export class Request extends Component> {
* @internal
*/
@provide('store') declare _store: Store;
+ @tracked isOnline: boolean = true;
+ @tracked isHidden: boolean = true;
+ @tracked isRefreshing: boolean = false;
+ @tracked _localRequest: Future | undefined;
+ @tracked _latestRequest: Future | undefined;
+ declare unavailableStart: number | null;
+ declare onlineChanged: (event: Event) => void;
+ declare backgroundChanged: (event: Event) => void;
+ declare _originalRequest: Future | undefined;
+ declare _originalQuery: StoreRequestInput | undefined;
+
+ constructor(owner: unknown, args: RequestSignature['Args']) {
+ super(owner, args);
+ this.installListeners();
+ }
+
+ installListeners() {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ this.isOnline = window.navigator.onLine;
+ this.unavailableStart = this.isOnline ? null : Date.now();
+ this.isHidden = document.visibilityState === 'hidden';
+
+ this.onlineChanged = (event: Event) => {
+ this.isOnline = event.type === 'online';
+ if (event.type === 'offline') {
+ this.unavailableStart = Date.now();
+ }
+ this.maybeUpdate();
+ };
+ this.backgroundChanged = () => {
+ this.isHidden = document.visibilityState === 'hidden';
+ this.maybeUpdate();
+ };
+
+ window.addEventListener('online', this.onlineChanged, { passive: true, capture: true });
+ window.addEventListener('offline', this.onlineChanged, { passive: true, capture: true });
+ document.addEventListener('visibilitychange', this.backgroundChanged, { passive: true, capture: true });
+ }
- retry = () => {};
- reload = () => {};
- refresh = () => {};
+ maybeUpdate(mode?: 'reload' | 'refresh' | 'policy'): void {
+ if (this.isOnline && !this.isHidden && (mode || this.args.autorefresh)) {
+ const deadline =
+ typeof this.args.autorefreshThreshold === 'number' ? this.args.autorefreshThreshold : DEFAULT_DEADLINE;
+ const shouldAttempt = mode || (this.unavailableStart && Date.now() - this.unavailableStart > deadline);
+ this.unavailableStart = null;
+
+ if (shouldAttempt) {
+ const request = Object.assign({}, this.reqState.request as unknown as RequestInfo);
+ const val = mode ?? this.args.autorefreshBehavior ?? 'policy';
+ switch (val) {
+ case 'reload':
+ request.cacheOptions = Object.assign({}, request.cacheOptions, { reload: true });
+ break;
+ case 'refresh':
+ request.cacheOptions = Object.assign({}, request.cacheOptions, { backgroundReload: true });
+ break;
+ case 'policy':
+ break;
+ default:
+ throw new Error(`Invalid ${mode ? 'update mode' : '@autorefreshBehavior'} for : ${val}`);
+ }
+
+ const wasStoreRequest = (request as { [EnableHydration]: boolean })[EnableHydration] === true;
+ assert(
+ `Cannot supply a different store via context than was used to create the request`,
+ !request.store || request.store === this.store
+ );
+
+ this._latestRequest = wasStoreRequest
+ ? this.store.request(request)
+ : this.store.requestManager.request(request);
+
+ if (val !== 'refresh') {
+ this._localRequest = this._latestRequest;
+ }
+ }
+ }
+
+ if (mode) {
+ throw new Error(`Reload not available: the network is not online or the tab is hidden`);
+ }
+ }
+
+ retry = async () => {
+ this.maybeUpdate('reload');
+ await this._localRequest;
+ };
+
+ refresh = async () => {
+ this.isRefreshing = true;
+ this.maybeUpdate('refresh');
+ try {
+ await this._latestRequest;
+ } finally {
+ this.isRefreshing = false;
+ }
+ };
+
+ @cached
+ get errorFeatures() {
+ return {
+ isHidden: this.isHidden,
+ isOnline: this.isOnline,
+ retry: this.retry,
+ };
+ }
+
+ @cached
+ get contentFeatures() {
+ const feat: ContentFeatures = {
+ isHidden: this.isHidden,
+ isOnline: this.isOnline,
+ reload: this.retry,
+ refresh: this.refresh,
+ isRefreshing: this.isRefreshing,
+ latestRequest: this._latestRequest,
+ };
+
+ if (feat.isRefreshing) {
+ feat.abort = () => {
+ this._latestRequest?.abort();
+ };
+ }
+
+ return feat;
+ }
+
+ willDestroy() {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ window.removeEventListener('online', this.onlineChanged, { passive: true, capture: true } as unknown as boolean);
+ window.removeEventListener('offline', this.onlineChanged, { passive: true, capture: true } as unknown as boolean);
+ document.removeEventListener('visibilitychange', this.backgroundChanged, {
+ passive: true,
+ capture: true,
+ } as unknown as boolean);
+ }
@cached
get request() {
const { request, query } = this.args;
assert(`Cannot use both @request and @query args with the component`, !request || !query);
+ const { _localRequest, _originalRequest, _originalQuery } = this;
+ const isOriginalRequest = request === _originalRequest && query === _originalQuery;
+
+ if (_localRequest && isOriginalRequest) {
+ return _localRequest;
+ }
+
+ // update state checks for the next time
+ this._originalQuery = query;
+ this._originalRequest = request;
+
if (request) {
return request;
}
@@ -73,13 +248,14 @@ export class Request extends Component> {
{{#if this.reqState.isLoading}}
{{yield this.reqState.loadingState to="loading"}}
{{else if (and this.reqState.isCancelled (has-block "cancelled"))}}
- {{yield (notNull this.reqState.error) to="cancelled"}}
+ {{yield (notNull this.reqState.error) this.errorFeatures to="cancelled"}}
{{else if (and this.reqState.isError (has-block "error"))}}
- {{yield (notNull this.reqState.error) to="error"}}
+ {{yield (notNull this.reqState.error) this.errorFeatures to="error"}}
{{else if this.reqState.isSuccess}}
- {{yield (notNull this.reqState.result) to="content"}}
- {{else}}
+ {{yield (notNull this.reqState.result) this.contentFeatures to="content"}}
+ {{else if (not this.reqState.isCancelled)}}
{{/if}}
+ {{yield this.reqState to="always"}}
}
diff --git a/tests/warp-drive__ember/.mock-cache/733572b0/GET-0-users/1/res.body.br b/tests/warp-drive__ember/.mock-cache/733572b0/GET-0-users/1/res.body.br
new file mode 100644
index 00000000000..95a61de6c7d
Binary files /dev/null and b/tests/warp-drive__ember/.mock-cache/733572b0/GET-0-users/1/res.body.br differ
diff --git a/tests/warp-drive__ember/.mock-cache/733572b0/GET-0-users/1/res.meta.json b/tests/warp-drive__ember/.mock-cache/733572b0/GET-0-users/1/res.meta.json
new file mode 100644
index 00000000000..82a5960c1e6
--- /dev/null
+++ b/tests/warp-drive__ember/.mock-cache/733572b0/GET-0-users/1/res.meta.json
@@ -0,0 +1,12 @@
+{
+ "url": "users/1",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "Content-Type": "application/vnd.api+json",
+ "Content-Encoding": "br",
+ "Cache-Control": "no-store"
+ },
+ "method": "GET",
+ "requestBody": null
+}
\ No newline at end of file
diff --git a/tests/warp-drive__ember/.mock-cache/73414455/GET-0-users/2/res.body.br b/tests/warp-drive__ember/.mock-cache/73414455/GET-0-users/2/res.body.br
new file mode 100644
index 00000000000..8332a7f4e94
Binary files /dev/null and b/tests/warp-drive__ember/.mock-cache/73414455/GET-0-users/2/res.body.br differ
diff --git a/tests/warp-drive__ember/.mock-cache/73414455/GET-0-users/2/res.meta.json b/tests/warp-drive__ember/.mock-cache/73414455/GET-0-users/2/res.meta.json
new file mode 100644
index 00000000000..5ad0191413a
--- /dev/null
+++ b/tests/warp-drive__ember/.mock-cache/73414455/GET-0-users/2/res.meta.json
@@ -0,0 +1,12 @@
+{
+ "url": "users/2",
+ "status": 404,
+ "statusText": "Not Found",
+ "headers": {
+ "Content-Type": "application/vnd.api+json",
+ "Content-Encoding": "br",
+ "Cache-Control": "no-store"
+ },
+ "method": "GET",
+ "requestBody": null
+}
\ No newline at end of file
diff --git a/tests/warp-drive__ember/.mock-cache/8d3e369b/GET-0-users/1/res.body.br b/tests/warp-drive__ember/.mock-cache/8d3e369b/GET-0-users/1/res.body.br
new file mode 100644
index 00000000000..95a61de6c7d
Binary files /dev/null and b/tests/warp-drive__ember/.mock-cache/8d3e369b/GET-0-users/1/res.body.br differ
diff --git a/tests/warp-drive__ember/.mock-cache/8d3e369b/GET-0-users/1/res.meta.json b/tests/warp-drive__ember/.mock-cache/8d3e369b/GET-0-users/1/res.meta.json
new file mode 100644
index 00000000000..82a5960c1e6
--- /dev/null
+++ b/tests/warp-drive__ember/.mock-cache/8d3e369b/GET-0-users/1/res.meta.json
@@ -0,0 +1,12 @@
+{
+ "url": "users/1",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "Content-Type": "application/vnd.api+json",
+ "Content-Encoding": "br",
+ "Cache-Control": "no-store"
+ },
+ "method": "GET",
+ "requestBody": null
+}
\ No newline at end of file
diff --git a/tests/warp-drive__ember/.mock-cache/b5fd3ed0/GET-0-users/2/res.body.br b/tests/warp-drive__ember/.mock-cache/b5fd3ed0/GET-0-users/2/res.body.br
new file mode 100644
index 00000000000..8332a7f4e94
Binary files /dev/null and b/tests/warp-drive__ember/.mock-cache/b5fd3ed0/GET-0-users/2/res.body.br differ
diff --git a/tests/warp-drive__ember/.mock-cache/b5fd3ed0/GET-0-users/2/res.meta.json b/tests/warp-drive__ember/.mock-cache/b5fd3ed0/GET-0-users/2/res.meta.json
new file mode 100644
index 00000000000..5ad0191413a
--- /dev/null
+++ b/tests/warp-drive__ember/.mock-cache/b5fd3ed0/GET-0-users/2/res.meta.json
@@ -0,0 +1,12 @@
+{
+ "url": "users/2",
+ "status": 404,
+ "statusText": "Not Found",
+ "headers": {
+ "Content-Type": "application/vnd.api+json",
+ "Content-Encoding": "br",
+ "Cache-Control": "no-store"
+ },
+ "method": "GET",
+ "requestBody": null
+}
\ No newline at end of file
diff --git a/tests/warp-drive__ember/.mock-cache/b5fd3ed0/GET-1-users/2/res.body.br b/tests/warp-drive__ember/.mock-cache/b5fd3ed0/GET-1-users/2/res.body.br
new file mode 100644
index 00000000000..7524b09c463
Binary files /dev/null and b/tests/warp-drive__ember/.mock-cache/b5fd3ed0/GET-1-users/2/res.body.br differ
diff --git a/tests/warp-drive__ember/.mock-cache/b5fd3ed0/GET-1-users/2/res.meta.json b/tests/warp-drive__ember/.mock-cache/b5fd3ed0/GET-1-users/2/res.meta.json
new file mode 100644
index 00000000000..61a2d9f6259
--- /dev/null
+++ b/tests/warp-drive__ember/.mock-cache/b5fd3ed0/GET-1-users/2/res.meta.json
@@ -0,0 +1,12 @@
+{
+ "url": "users/2",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "Content-Type": "application/vnd.api+json",
+ "Content-Encoding": "br",
+ "Cache-Control": "no-store"
+ },
+ "method": "GET",
+ "requestBody": null
+}
\ No newline at end of file
diff --git a/tests/warp-drive__ember/.mock-cache/ca4889d8/GET-0-users/1/res.body.br b/tests/warp-drive__ember/.mock-cache/ca4889d8/GET-0-users/1/res.body.br
new file mode 100644
index 00000000000..95a61de6c7d
Binary files /dev/null and b/tests/warp-drive__ember/.mock-cache/ca4889d8/GET-0-users/1/res.body.br differ
diff --git a/tests/warp-drive__ember/.mock-cache/ca4889d8/GET-0-users/1/res.meta.json b/tests/warp-drive__ember/.mock-cache/ca4889d8/GET-0-users/1/res.meta.json
new file mode 100644
index 00000000000..82a5960c1e6
--- /dev/null
+++ b/tests/warp-drive__ember/.mock-cache/ca4889d8/GET-0-users/1/res.meta.json
@@ -0,0 +1,12 @@
+{
+ "url": "users/1",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "Content-Type": "application/vnd.api+json",
+ "Content-Encoding": "br",
+ "Cache-Control": "no-store"
+ },
+ "method": "GET",
+ "requestBody": null
+}
\ No newline at end of file
diff --git a/tests/warp-drive__ember/.mock-cache/ca4889d8/GET-1-users/1/res.body.br b/tests/warp-drive__ember/.mock-cache/ca4889d8/GET-1-users/1/res.body.br
new file mode 100644
index 00000000000..b2266cf6156
Binary files /dev/null and b/tests/warp-drive__ember/.mock-cache/ca4889d8/GET-1-users/1/res.body.br differ
diff --git a/tests/warp-drive__ember/.mock-cache/ca4889d8/GET-1-users/1/res.meta.json b/tests/warp-drive__ember/.mock-cache/ca4889d8/GET-1-users/1/res.meta.json
new file mode 100644
index 00000000000..82a5960c1e6
--- /dev/null
+++ b/tests/warp-drive__ember/.mock-cache/ca4889d8/GET-1-users/1/res.meta.json
@@ -0,0 +1,12 @@
+{
+ "url": "users/1",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "Content-Type": "application/vnd.api+json",
+ "Content-Encoding": "br",
+ "Cache-Control": "no-store"
+ },
+ "method": "GET",
+ "requestBody": null
+}
\ No newline at end of file
diff --git a/tests/warp-drive__ember/.mock-cache/ccc35947/GET-0-users/2/res.body.br b/tests/warp-drive__ember/.mock-cache/ccc35947/GET-0-users/2/res.body.br
new file mode 100644
index 00000000000..8332a7f4e94
Binary files /dev/null and b/tests/warp-drive__ember/.mock-cache/ccc35947/GET-0-users/2/res.body.br differ
diff --git a/tests/warp-drive__ember/.mock-cache/ccc35947/GET-0-users/2/res.meta.json b/tests/warp-drive__ember/.mock-cache/ccc35947/GET-0-users/2/res.meta.json
new file mode 100644
index 00000000000..5ad0191413a
--- /dev/null
+++ b/tests/warp-drive__ember/.mock-cache/ccc35947/GET-0-users/2/res.meta.json
@@ -0,0 +1,12 @@
+{
+ "url": "users/2",
+ "status": 404,
+ "statusText": "Not Found",
+ "headers": {
+ "Content-Type": "application/vnd.api+json",
+ "Content-Encoding": "br",
+ "Cache-Control": "no-store"
+ },
+ "method": "GET",
+ "requestBody": null
+}
\ No newline at end of file
diff --git a/tests/warp-drive__ember/.mock-cache/ccc35947/GET-1-users/2/res.body.br b/tests/warp-drive__ember/.mock-cache/ccc35947/GET-1-users/2/res.body.br
new file mode 100644
index 00000000000..7524b09c463
Binary files /dev/null and b/tests/warp-drive__ember/.mock-cache/ccc35947/GET-1-users/2/res.body.br differ
diff --git a/tests/warp-drive__ember/.mock-cache/ccc35947/GET-1-users/2/res.meta.json b/tests/warp-drive__ember/.mock-cache/ccc35947/GET-1-users/2/res.meta.json
new file mode 100644
index 00000000000..61a2d9f6259
--- /dev/null
+++ b/tests/warp-drive__ember/.mock-cache/ccc35947/GET-1-users/2/res.meta.json
@@ -0,0 +1,12 @@
+{
+ "url": "users/2",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "Content-Type": "application/vnd.api+json",
+ "Content-Encoding": "br",
+ "Cache-Control": "no-store"
+ },
+ "method": "GET",
+ "requestBody": null
+}
\ No newline at end of file
diff --git a/tests/warp-drive__ember/.mock-cache/db0753d2/GET-0-users/2/res.body.br b/tests/warp-drive__ember/.mock-cache/db0753d2/GET-0-users/2/res.body.br
new file mode 100644
index 00000000000..8332a7f4e94
Binary files /dev/null and b/tests/warp-drive__ember/.mock-cache/db0753d2/GET-0-users/2/res.body.br differ
diff --git a/tests/warp-drive__ember/.mock-cache/db0753d2/GET-0-users/2/res.meta.json b/tests/warp-drive__ember/.mock-cache/db0753d2/GET-0-users/2/res.meta.json
new file mode 100644
index 00000000000..5ad0191413a
--- /dev/null
+++ b/tests/warp-drive__ember/.mock-cache/db0753d2/GET-0-users/2/res.meta.json
@@ -0,0 +1,12 @@
+{
+ "url": "users/2",
+ "status": 404,
+ "statusText": "Not Found",
+ "headers": {
+ "Content-Type": "application/vnd.api+json",
+ "Content-Encoding": "br",
+ "Cache-Control": "no-store"
+ },
+ "method": "GET",
+ "requestBody": null
+}
\ No newline at end of file
diff --git a/tests/warp-drive__ember/.mock-cache/eea0d109/GET-0-users/1/res.body.br b/tests/warp-drive__ember/.mock-cache/eea0d109/GET-0-users/1/res.body.br
new file mode 100644
index 00000000000..95a61de6c7d
Binary files /dev/null and b/tests/warp-drive__ember/.mock-cache/eea0d109/GET-0-users/1/res.body.br differ
diff --git a/tests/warp-drive__ember/.mock-cache/eea0d109/GET-0-users/1/res.meta.json b/tests/warp-drive__ember/.mock-cache/eea0d109/GET-0-users/1/res.meta.json
new file mode 100644
index 00000000000..82a5960c1e6
--- /dev/null
+++ b/tests/warp-drive__ember/.mock-cache/eea0d109/GET-0-users/1/res.meta.json
@@ -0,0 +1,12 @@
+{
+ "url": "users/1",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "Content-Type": "application/vnd.api+json",
+ "Content-Encoding": "br",
+ "Cache-Control": "no-store"
+ },
+ "method": "GET",
+ "requestBody": null
+}
\ No newline at end of file
diff --git a/tests/warp-drive__ember/.mock-cache/f878a0f8/GET-0-users/2/res.body.br b/tests/warp-drive__ember/.mock-cache/f878a0f8/GET-0-users/2/res.body.br
new file mode 100644
index 00000000000..8332a7f4e94
Binary files /dev/null and b/tests/warp-drive__ember/.mock-cache/f878a0f8/GET-0-users/2/res.body.br differ
diff --git a/tests/warp-drive__ember/.mock-cache/f878a0f8/GET-0-users/2/res.meta.json b/tests/warp-drive__ember/.mock-cache/f878a0f8/GET-0-users/2/res.meta.json
new file mode 100644
index 00000000000..5ad0191413a
--- /dev/null
+++ b/tests/warp-drive__ember/.mock-cache/f878a0f8/GET-0-users/2/res.meta.json
@@ -0,0 +1,12 @@
+{
+ "url": "users/2",
+ "status": 404,
+ "statusText": "Not Found",
+ "headers": {
+ "Content-Type": "application/vnd.api+json",
+ "Content-Encoding": "br",
+ "Cache-Control": "no-store"
+ },
+ "method": "GET",
+ "requestBody": null
+}
\ No newline at end of file
diff --git a/tests/warp-drive__ember/.mock-cache/fbd61105/GET-0-users/2/res.body.br b/tests/warp-drive__ember/.mock-cache/fbd61105/GET-0-users/2/res.body.br
new file mode 100644
index 00000000000..8332a7f4e94
Binary files /dev/null and b/tests/warp-drive__ember/.mock-cache/fbd61105/GET-0-users/2/res.body.br differ
diff --git a/tests/warp-drive__ember/.mock-cache/fbd61105/GET-0-users/2/res.meta.json b/tests/warp-drive__ember/.mock-cache/fbd61105/GET-0-users/2/res.meta.json
new file mode 100644
index 00000000000..5ad0191413a
--- /dev/null
+++ b/tests/warp-drive__ember/.mock-cache/fbd61105/GET-0-users/2/res.meta.json
@@ -0,0 +1,12 @@
+{
+ "url": "users/2",
+ "status": 404,
+ "statusText": "Not Found",
+ "headers": {
+ "Content-Type": "application/vnd.api+json",
+ "Content-Encoding": "br",
+ "Cache-Control": "no-store"
+ },
+ "method": "GET",
+ "requestBody": null
+}
\ No newline at end of file
diff --git a/tests/warp-drive__ember/tests/integration/request-component-test.gts b/tests/warp-drive__ember/tests/integration/request-component-test.gts
index e3c8a96fd8c..14da774279d 100644
--- a/tests/warp-drive__ember/tests/integration/request-component-test.gts
+++ b/tests/warp-drive__ember/tests/integration/request-component-test.gts
@@ -1,5 +1,6 @@
-import { rerender, settled } from '@ember/test-helpers';
-
+import { click, rerender, settled } from '@ember/test-helpers';
+import { on } from '@ember/modifier';
+import { fn } from '@ember/helper';
import type { CacheHandler, Future, NextFn, RequestContext, StructuredDataDocument } from '@ember-data/request';
import RequestManager from '@ember-data/request';
import Fetch from '@ember-data/request/fetch';
@@ -8,6 +9,7 @@ import { module, setupRenderingTest, test as _test } from '@warp-drive/diagnosti
import { getRequestState, Request } from '@warp-drive/ember';
import { mock, MockServerHandler } from '@warp-drive/holodeck';
import { GET } from '@warp-drive/holodeck/mock';
+import type Store from '@ember-data/store';
// our tests use a rendering test context and add manager to it
interface LocalTestContext extends RenderingTestContext {
@@ -18,7 +20,35 @@ function test(name: string, callback: DiagnosticTest): void {
return _test(name, callback);
}
-const RECORD = false;
+function setupOnError(cb: (message: Error | string) => void) {
+ const originalLog = console.error;
+ let cleanup!: () => void;
+ const handler = function (e: ErrorEvent | (Event & { reason: Error | string })) {
+ if (e instanceof ErrorEvent || e instanceof Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ cb('error' in e ? e.error : e.reason);
+ } else {
+ cb(e);
+ }
+ cleanup();
+ return false;
+ };
+ cleanup = () => {
+ window.removeEventListener('unhandledrejection', handler, { capture: true });
+ window.removeEventListener('error', handler, { capture: true });
+ console.error = originalLog;
+ };
+ console.error = handler;
+
+ window.addEventListener('unhandledrejection', handler, { capture: true });
+ window.addEventListener('error', handler, { capture: true });
+
+ return cleanup;
+}
+
+const RECORD = true;
type UserResource = {
data: {
@@ -36,8 +66,8 @@ class SimpleCacheHandler implements CacheHandler {
context: RequestContext,
next: NextFn
): T | Promise> | Future {
- const { url, method } = context.request;
- if (url && method === 'GET' && this._cache.has(url)) {
+ const { url, method, cacheOptions } = context.request;
+ if (url && method === 'GET' && this._cache.has(url) && cacheOptions?.reload !== true) {
return this._cache.get(url) as T;
}
@@ -61,7 +91,7 @@ class SimpleCacheHandler implements CacheHandler {
}
}
-async function mockGETSuccess(context: LocalTestContext): Promise {
+async function mockGETSuccess(context: LocalTestContext, attributes?: { name: string }): Promise {
await GET(
context,
'users/1',
@@ -69,9 +99,12 @@ async function mockGETSuccess(context: LocalTestContext): Promise {
data: {
id: '1',
type: 'user',
- attributes: {
- name: 'Chris Thoburn',
- },
+ attributes: Object.assign(
+ {
+ name: 'Chris Thoburn',
+ },
+ attributes
+ ),
},
}),
{ RECORD: RECORD }
@@ -103,6 +136,23 @@ async function mockGETFailure(context: LocalTestContext): Promise {
return 'https://localhost:1135/users/2';
}
+async function mockRetrySuccess(context: LocalTestContext): Promise {
+ await GET(
+ context,
+ 'users/2',
+ () => ({
+ data: {
+ id: '2',
+ type: 'user',
+ attributes: {
+ name: 'Chris Thoburn',
+ },
+ },
+ }),
+ { RECORD: RECORD }
+ );
+ return 'https://localhost:1135/users/2';
+}
module('Integration | ', function (hooks) {
setupRenderingTest(hooks);
@@ -248,6 +298,118 @@ module('Integration | ', function (hooks) {
);
});
+ test('we can retry from error state', async function (assert) {
+ const store = this.owner.lookup('service:store') as Store;
+ store.requestManager = this.manager;
+
+ const url = await mockGETFailure(this);
+ await mockRetrySuccess(this);
+ const request = this.manager.request({ url, method: 'GET' });
+ const state = getRequestState(request);
+
+ let counter = 0;
+ function countFor(_result: unknown) {
+ return ++counter;
+ }
+ function retry(state: { retry: () => void }) {
+ assert.step('retry');
+ return state.retry();
+ }
+
+ await this.render(
+
+
+ <:loading>Pending Count: {{countFor request}}
+ <:error as |error state|>{{error.message}} Count:
+ {{~countFor error~}}
+
+
+ <:content as |result|>{{result.data.attributes.name}} Count: {{countFor result}}
+
+
+ );
+
+ assert.equal(state!, getRequestState(request), 'state is a stable reference');
+ assert.equal(state!.result, null, 'result is null');
+ assert.equal(state!.error, null, 'error is null');
+ assert.equal(counter, 1, 'counter is 1');
+ assert.equal(this.element.textContent?.trim(), 'PendingCount: 1');
+ try {
+ await request;
+ } catch {
+ // ignore the error
+ }
+ await rerender();
+ assert.equal(state!.result, null, 'after rerender result is still null');
+ assert.true(state!.error instanceof Error, 'error is an instance of Error');
+ assert.equal(
+ (state!.error as Error | undefined)?.message,
+ '[404 Not Found] GET (cors) - https://localhost:1135/users/2',
+ 'error message is correct'
+ );
+ assert.equal(counter, 2, 'counter is 2');
+ assert.equal(
+ this.element.textContent?.trim(),
+ '[404 Not Found] GET (cors) - https://localhost:1135/users/2Count:2Retry'
+ );
+
+ await click('[test-id="retry-button"]');
+
+ assert.verifySteps(['retry']);
+ assert.equal(counter, 4, 'counter is 4');
+ assert.equal(this.element.textContent?.trim(), 'Chris ThoburnCount: 4');
+ });
+
+ test('it rethrows if error block is not present', async function (assert) {
+ const url = await mockGETFailure(this);
+ const request = this.manager.request({ url, method: 'GET' });
+ const state = getRequestState(request);
+
+ let counter = 0;
+ function countFor(_result: unknown) {
+ return ++counter;
+ }
+
+ await this.render(
+
+
+ <:loading>Pending Count: {{countFor request}}
+ <:content as |result|>{{result.data.attributes.name}} Count: {{countFor result}}
+
+
+ );
+
+ assert.equal(state!, getRequestState(request), 'state is a stable reference');
+ assert.equal(state!.result, null, 'result is null');
+ assert.equal(state!.error, null, 'error is null');
+ assert.equal(counter, 1, 'counter is 1');
+ assert.equal(this.element.textContent?.trim(), 'PendingCount: 1');
+ const cleanup = setupOnError((message) => {
+ assert.step('render-error');
+ assert.true(
+ typeof message === 'string' && message.startsWith('\n\nError occurred:\n\n- While rendering:'),
+ 'error message is correct'
+ );
+ });
+ try {
+ await request;
+ } catch {
+ // ignore the error
+ }
+ await rerender();
+ cleanup();
+ assert.verifySteps(['render-error']);
+ assert.equal(state!.result, null, 'after rerender result is still null');
+ assert.true(state!.error instanceof Error, 'error is an instance of Error');
+ assert.equal(
+ (state!.error as Error | undefined)?.message,
+ '[404 Not Found] GET (cors) - https://localhost:1135/users/2',
+ 'error message is correct'
+ );
+ assert.equal(counter, 1, 'counter is still 1');
+ assert.equal(this.element.textContent?.trim(), '');
+ });
+
test('it transitions to cancelled state correctly', async function (assert) {
const url = await mockGETFailure(this);
const request = this.manager.request({ url, method: 'GET' });
@@ -294,6 +456,164 @@ module('Integration | ', function (hooks) {
assert.equal(this.element.textContent?.trim(), 'Cancelled The user aborted a request.Count: 2');
});
+ test('we can retry from cancelled state', async function (assert) {
+ const store = this.owner.lookup('service:store') as Store;
+ store.requestManager = this.manager;
+
+ const url = await mockGETFailure(this);
+ await mockRetrySuccess(this);
+ const request = this.manager.request({ url, method: 'GET' });
+ const state = getRequestState(request);
+
+ let counter = 0;
+ function countFor(_result: unknown) {
+ return ++counter;
+ }
+ function retry(state: { retry: () => void }) {
+ assert.step('retry');
+ return state.retry();
+ }
+
+ await this.render(
+
+
+ <:loading>Pending Count: {{countFor request}}
+ <:cancelled as |error state|>Cancelled:
+ {{~error.message~}} Count:
+ {{~countFor error~}}
+
+
+ <:error as |error|>{{error.message}} Count: {{countFor error}}
+ <:content as |result|>{{result.data.attributes.name}} Count: {{countFor result}}
+
+
+ );
+
+ assert.equal(state!, getRequestState(request), 'state is a stable reference');
+ assert.equal(state!.result, null, 'result is null');
+ assert.equal(state!.error, null, 'error is null');
+ assert.equal(counter, 1, 'counter is 1');
+ assert.equal(this.element.textContent?.trim(), 'PendingCount: 1');
+
+ request.abort();
+
+ try {
+ await request;
+ } catch {
+ // ignore the error
+ }
+ await rerender();
+ assert.equal(state!.result, null, 'after rerender result is still null');
+ assert.true(state!.error instanceof Error, 'error is an instance of Error');
+ assert.equal(
+ (state!.error as Error | undefined)?.message,
+ 'The user aborted a request.',
+ 'error message is correct'
+ );
+ assert.equal(counter, 2, 'counter is 2');
+ assert.equal(this.element.textContent?.trim(), 'Cancelled:The user aborted a request.Count:2Retry');
+
+ await click('[test-id="retry-button"]');
+
+ assert.verifySteps(['retry']);
+ assert.equal(counter, 4, 'counter is 4');
+ assert.equal(this.element.textContent?.trim(), 'Chris ThoburnCount: 4');
+ });
+
+ test('it transitions to error state if cancelled block is not present', async function (assert) {
+ const url = await mockGETFailure(this);
+ const request = this.manager.request({ url, method: 'GET' });
+ const state = getRequestState(request);
+
+ let counter = 0;
+ function countFor(_result: unknown) {
+ return ++counter;
+ }
+
+ await this.render(
+
+
+ <:loading>Pending Count: {{countFor request}}
+ <:error as |error|>{{error.message}} Count: {{countFor error}}
+ <:content as |result|>{{result.data.attributes.name}} Count: {{countFor result}}
+
+
+ );
+
+ assert.equal(state!, getRequestState(request), 'state is a stable reference');
+ assert.equal(state!.result, null, 'result is null');
+ assert.equal(state!.error, null, 'error is null');
+ assert.equal(counter, 1, 'counter is 1');
+ assert.equal(this.element.textContent?.trim(), 'PendingCount: 1');
+
+ request.abort();
+
+ try {
+ await request;
+ } catch {
+ // ignore the error
+ }
+ await rerender();
+ assert.equal(state!.result, null, 'after rerender result is still null');
+ assert.true(state!.error instanceof Error, 'error is an instance of Error');
+ assert.equal(
+ (state!.error as Error | undefined)?.message,
+ 'The user aborted a request.',
+ 'error message is correct'
+ );
+ assert.equal(counter, 2, 'counter is 2');
+ assert.equal(this.element.textContent?.trim(), 'The user aborted a request.Count: 2');
+ });
+
+ test('it does not rethrow for cancelled', async function (assert) {
+ const url = await mockGETFailure(this);
+ const request = this.manager.request({ url, method: 'GET' });
+ const state = getRequestState(request);
+
+ let counter = 0;
+ function countFor(_result: unknown) {
+ return ++counter;
+ }
+
+ await this.render(
+
+
+ <:loading>Pending Count: {{countFor request}}
+ <:content as |result|>{{result.data.attributes.name}} Count: {{countFor result}}
+
+
+ );
+
+ assert.equal(state!, getRequestState(request), 'state is a stable reference');
+ assert.equal(state!.result, null, 'result is null');
+ assert.equal(state!.error, null, 'error is null');
+ assert.equal(counter, 1, 'counter is 1');
+ assert.equal(this.element.textContent?.trim(), 'PendingCount: 1');
+
+ const cleanup = setupOnError((message) => {
+ assert.step('render-error');
+ });
+
+ request.abort();
+ try {
+ await request;
+ } catch {
+ // ignore the error
+ }
+ await rerender();
+ cleanup();
+ assert.equal(state!.result, null, 'after rerender result is still null');
+ assert.true(state!.error instanceof Error, 'error is an instance of Error');
+ assert.equal(
+ (state!.error as Error | undefined)?.message,
+ 'The user aborted a request.',
+ 'error message is correct'
+ );
+ assert.equal(counter, 1, 'counter is 1');
+ assert.equal(this.element.textContent?.trim(), '');
+ assert.verifySteps([], 'no error should be thrown');
+ });
+
test('it renders only once when the promise error state is already cached', async function (assert) {
const url = await mockGETFailure(this);
const request = this.manager.request({ url, method: 'GET' });
@@ -346,4 +666,71 @@ module('Integration | ', function (hooks) {
'[404 Not Found] GET (cors) - https://localhost:1135/users/2Count: 1'
);
});
+
+ test('isOnline updates when expected', async function (assert) {
+ const url = await mockGETSuccess(this);
+ const request = this.manager.request({ url, method: 'GET' });
+
+ await this.render(
+
+
+ <:content as |result state|>Online: {{state.isOnline}}
+
+
+ );
+ await request;
+ await rerender();
+
+ assert.equal(this.element.textContent?.trim(), 'Online: true');
+ window.dispatchEvent(new Event('offline'));
+
+ await rerender();
+
+ assert.equal(this.element.textContent?.trim(), 'Online: false');
+ window.dispatchEvent(new Event('online'));
+
+ await rerender();
+
+ assert.equal(this.element.textContent?.trim(), 'Online: true');
+ });
+
+ test('@autorefreshBehavior="reload" works as expected', async function (assert) {
+ const store = this.owner.lookup('service:store') as Store;
+ store.requestManager = this.manager;
+
+ const url = await mockGETSuccess(this);
+ await mockGETSuccess(this, { name: 'James Thoburn' });
+ const request = this.manager.request({ url, method: 'GET' });
+
+ await this.render(
+
+
+ <:content as |result state|>{{result.data.attributes.name}} | Online: {{state.isOnline}}
+
+
+ );
+ await request;
+ await rerender();
+
+ assert.equal(this.element.textContent?.trim(), 'Chris Thoburn | Online: true');
+ window.dispatchEvent(new Event('offline'));
+
+ await rerender();
+
+ // enable the auto-refresh threshold to trigger
+ await new Promise((resolve) => setTimeout(resolve, 1));
+
+ assert.equal(this.element.textContent?.trim(), 'Chris Thoburn | Online: false');
+ window.dispatchEvent(new Event('online'));
+
+ // let the event dispatch complete
+ await new Promise((resolve) => setTimeout(resolve, 1));
+ await settled();
+ assert.equal(this.element.textContent?.trim(), 'James Thoburn | Online: true');
+ });
});