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'; + + +``` - 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'; } 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + 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( + + ); + 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'); + }); });