Skip to content

Commit f0d1100

Browse files
authored
feat: <Request /> autoRefresh (emberjs#9363)
* feat: <Request /> autoRefresh * feat: more tests * more impl and tests * more imple * more nice things
1 parent 3fe14c5 commit f0d1100

File tree

30 files changed

+795
-26
lines changed

30 files changed

+795
-26
lines changed

packages/ember/README.md

+56-5
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,9 @@ import { Await } from '@warp-drive/ember';
218218
</template>
219219
```
220220

221+
When using the Await component, if no error block is provided and the promise rejects,
222+
the error will be thrown.
223+
221224
### RequestState
222225

223226
RequestState extends PromiseState to provide a reactive wrapper for a request `Future` which
@@ -320,6 +323,10 @@ import { Request } from '@warp-drive/ember';
320323
</template>
321324
```
322325

326+
When using the Await component, if no error block is provided and the request rejects,
327+
the error will be thrown. Cancellation errors are not rethrown if no error block or
328+
cancellation block is present.
329+
323330
- Streaming Data
324331

325332
The loading state exposes the download `ReadableStream` instance for consumption
@@ -365,7 +372,36 @@ import { Request } from '@warp-drive/ember';
365372
If a request is aborted but no cancelled block is present, the error will be given
366373
to the error block to handle.
367374

368-
If no error block is present, the error will be rethrown.
375+
If no error block is present, the cancellation error will be swallowed.
376+
377+
- retry
378+
379+
Cancelled and error'd requests may be retried,
380+
retry will reuse the error, cancelled and loading
381+
blocks as appropriate.
382+
383+
```gjs
384+
import { Request } from '@warp-drive/ember';
385+
import { on } from '@ember/modifier';
386+
387+
<template>
388+
<Request @request={{@request}}>
389+
<:cancelled as |error state|>
390+
<h2>The Request Cancelled</h2>
391+
<button {{on "click" state.retry}}>Retry</button>
392+
</:cancelled>
393+
394+
<:error as |error state|>
395+
<ErrorForm @error={{error}} />
396+
<button {{on "click" state.retry}}>Retry</button>
397+
</:error>
398+
399+
<:content as |result|>
400+
<h1>{{result.title}}</h1>
401+
</:content>
402+
</Request>
403+
</template>
404+
```
369405

370406
- Reloading states
371407

@@ -434,29 +470,44 @@ import { Request } from '@warp-drive/ember';
434470
</template>
435471
```
436472

437-
- AutoRefresh behavior
473+
- Autorefresh behavior
438474

439475
Requests can be made to automatically refresh when a browser window or tab comes back to the
440-
foreground after being backgrounded.
476+
foreground after being backgrounded or when the network reports as being online after having
477+
been offline.
441478

442479
```gjs
443480
import { Request } from '@warp-drive/ember';
444481
445482
<template>
446-
<Request @request={{@request}} @autoRefresh={{true}}>
483+
<Request @request={{@request}} @autorefresh={{true}}>
447484
<!-- ... -->
448485
</Request>
449486
</template>
450487
```
451488

489+
By default, an autorefresh will only occur if the browser was backgrounded or offline for more than
490+
30s before coming back available. This amount of time can be tweaked by setting the number of milliseconds
491+
via `@autorefreshThreshold`.
492+
493+
The behavior of the fetch initiated by the autorefresh can also be adjusted by `@autorefreshBehavior`
494+
495+
Options are:
496+
497+
- `refresh` update while continuing to show the current state.
498+
- `reload` update and show the loading state until update completes)
499+
- `delegate` (**default**) trigger the request, but let the cache handler decide whether the update should occur or if the cache is still valid.
500+
501+
---
502+
452503
Similarly, refresh could be set up on a timer or on a websocket subscription by using the yielded
453504
refresh function and passing it to another component.
454505

455506
```gjs
456507
import { Request } from '@warp-drive/ember';
457508
458509
<template>
459-
<Request @request={{@request}} @autoRefresh={{true}}>
510+
<Request @request={{@request}}>
460511
<:content as |result state|>
461512
<h1>{{result.title}}</h1>
462513

packages/ember/src/-private/await.gts

-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ export function notNull<T>(x: T | null) {
1010
return x;
1111
}
1212
export const and = (x: unknown, y: unknown) => Boolean(x && y);
13-
1413
interface ThrowSignature<E = Error | string | object> {
1514
Args: {
1615
error: E;

packages/ember/src/-private/request.gts

+187-11
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { assert } from '@ember/debug';
22
import { service } from '@ember/service';
33
import Component from '@glimmer/component';
44
import { cached } from '@glimmer/tracking';
5+
import { EnableHydration, type RequestInfo } from '@warp-drive/core-types/request';
56
import type { Future, StructuredErrorDocument } from '@ember-data/request';
6-
7+
import type { RequestState } from './request-state.ts';
78
import { importSync, macroCondition, moduleExists } from '@embroider/macros';
89

910
import type { StoreRequestInput } from '@ember-data/store';
@@ -12,24 +13,49 @@ import type Store from '@ember-data/store';
1213
import { getRequestState } from './request-state.ts';
1314
import type { RequestLoadingState } from './request-state.ts';
1415
import { and, notNull, Throw } from './await.gts';
16+
import { tracked } from '@glimmer/tracking';
17+
18+
const not = (x: unknown) => !x;
19+
// default to 30 seconds unavailable before we refresh
20+
const DEFAULT_DEADLINE = 30_000;
1521

1622
let provide = service;
1723
if (macroCondition(moduleExists('ember-provide-consume-context'))) {
1824
const { consume } = importSync('ember-provide-consume-context') as { consume: typeof service };
1925
provide = consume;
2026
}
2127

28+
type ContentFeatures<T> = {
29+
isOnline: boolean;
30+
isHidden: boolean;
31+
isRefreshing: boolean;
32+
refresh: () => Promise<void>;
33+
reload: () => Promise<void>;
34+
abort?: () => void;
35+
latestRequest?: Future<T>;
36+
};
37+
2238
interface RequestSignature<T> {
2339
Args: {
2440
request?: Future<T>;
2541
query?: StoreRequestInput<T>;
2642
store?: Store;
43+
autorefresh?: boolean;
44+
autorefreshThreshold?: number;
45+
autorefreshBehavior?: 'refresh' | 'reload' | 'policy';
2746
};
2847
Blocks: {
2948
loading: [state: RequestLoadingState];
30-
cancelled: [error: StructuredErrorDocument];
31-
error: [error: StructuredErrorDocument];
32-
content: [value: T];
49+
cancelled: [
50+
error: StructuredErrorDocument,
51+
features: { isOnline: boolean; isHidden: boolean; retry: () => Promise<void> },
52+
];
53+
error: [
54+
error: StructuredErrorDocument,
55+
features: { isOnline: boolean; isHidden: boolean; retry: () => Promise<void> },
56+
];
57+
content: [value: T, features: ContentFeatures<T>];
58+
always: [state: RequestState<T>];
3359
};
3460
}
3561

@@ -38,15 +64,164 @@ export class Request<T> extends Component<RequestSignature<T>> {
3864
* @internal
3965
*/
4066
@provide('store') declare _store: Store;
67+
@tracked isOnline: boolean = true;
68+
@tracked isHidden: boolean = true;
69+
@tracked isRefreshing: boolean = false;
70+
@tracked _localRequest: Future<T> | undefined;
71+
@tracked _latestRequest: Future<T> | undefined;
72+
declare unavailableStart: number | null;
73+
declare onlineChanged: (event: Event) => void;
74+
declare backgroundChanged: (event: Event) => void;
75+
declare _originalRequest: Future<T> | undefined;
76+
declare _originalQuery: StoreRequestInput | undefined;
77+
78+
constructor(owner: unknown, args: RequestSignature<T>['Args']) {
79+
super(owner, args);
80+
this.installListeners();
81+
}
82+
83+
installListeners() {
84+
if (typeof window === 'undefined') {
85+
return;
86+
}
87+
88+
this.isOnline = window.navigator.onLine;
89+
this.unavailableStart = this.isOnline ? null : Date.now();
90+
this.isHidden = document.visibilityState === 'hidden';
91+
92+
this.onlineChanged = (event: Event) => {
93+
this.isOnline = event.type === 'online';
94+
if (event.type === 'offline') {
95+
this.unavailableStart = Date.now();
96+
}
97+
this.maybeUpdate();
98+
};
99+
this.backgroundChanged = () => {
100+
this.isHidden = document.visibilityState === 'hidden';
101+
this.maybeUpdate();
102+
};
103+
104+
window.addEventListener('online', this.onlineChanged, { passive: true, capture: true });
105+
window.addEventListener('offline', this.onlineChanged, { passive: true, capture: true });
106+
document.addEventListener('visibilitychange', this.backgroundChanged, { passive: true, capture: true });
107+
}
41108

42-
retry = () => {};
43-
reload = () => {};
44-
refresh = () => {};
109+
maybeUpdate(mode?: 'reload' | 'refresh' | 'policy'): void {
110+
if (this.isOnline && !this.isHidden && (mode || this.args.autorefresh)) {
111+
const deadline =
112+
typeof this.args.autorefreshThreshold === 'number' ? this.args.autorefreshThreshold : DEFAULT_DEADLINE;
113+
const shouldAttempt = mode || (this.unavailableStart && Date.now() - this.unavailableStart > deadline);
114+
this.unavailableStart = null;
115+
116+
if (shouldAttempt) {
117+
const request = Object.assign({}, this.reqState.request as unknown as RequestInfo);
118+
const val = mode ?? this.args.autorefreshBehavior ?? 'policy';
119+
switch (val) {
120+
case 'reload':
121+
request.cacheOptions = Object.assign({}, request.cacheOptions, { reload: true });
122+
break;
123+
case 'refresh':
124+
request.cacheOptions = Object.assign({}, request.cacheOptions, { backgroundReload: true });
125+
break;
126+
case 'policy':
127+
break;
128+
default:
129+
throw new Error(`Invalid ${mode ? 'update mode' : '@autorefreshBehavior'} for <Request />: ${val}`);
130+
}
131+
132+
const wasStoreRequest = (request as { [EnableHydration]: boolean })[EnableHydration] === true;
133+
assert(
134+
`Cannot supply a different store via context than was used to create the request`,
135+
!request.store || request.store === this.store
136+
);
137+
138+
this._latestRequest = wasStoreRequest
139+
? this.store.request<T>(request)
140+
: this.store.requestManager.request<T>(request);
141+
142+
if (val !== 'refresh') {
143+
this._localRequest = this._latestRequest;
144+
}
145+
}
146+
}
147+
148+
if (mode) {
149+
throw new Error(`Reload not available: the network is not online or the tab is hidden`);
150+
}
151+
}
152+
153+
retry = async () => {
154+
this.maybeUpdate('reload');
155+
await this._localRequest;
156+
};
157+
158+
refresh = async () => {
159+
this.isRefreshing = true;
160+
this.maybeUpdate('refresh');
161+
try {
162+
await this._latestRequest;
163+
} finally {
164+
this.isRefreshing = false;
165+
}
166+
};
167+
168+
@cached
169+
get errorFeatures() {
170+
return {
171+
isHidden: this.isHidden,
172+
isOnline: this.isOnline,
173+
retry: this.retry,
174+
};
175+
}
176+
177+
@cached
178+
get contentFeatures() {
179+
const feat: ContentFeatures<T> = {
180+
isHidden: this.isHidden,
181+
isOnline: this.isOnline,
182+
reload: this.retry,
183+
refresh: this.refresh,
184+
isRefreshing: this.isRefreshing,
185+
latestRequest: this._latestRequest,
186+
};
187+
188+
if (feat.isRefreshing) {
189+
feat.abort = () => {
190+
this._latestRequest?.abort();
191+
};
192+
}
193+
194+
return feat;
195+
}
196+
197+
willDestroy() {
198+
if (typeof window === 'undefined') {
199+
return;
200+
}
201+
202+
window.removeEventListener('online', this.onlineChanged, { passive: true, capture: true } as unknown as boolean);
203+
window.removeEventListener('offline', this.onlineChanged, { passive: true, capture: true } as unknown as boolean);
204+
document.removeEventListener('visibilitychange', this.backgroundChanged, {
205+
passive: true,
206+
capture: true,
207+
} as unknown as boolean);
208+
}
45209

46210
@cached
47211
get request() {
48212
const { request, query } = this.args;
49213
assert(`Cannot use both @request and @query args with the <Request> component`, !request || !query);
214+
const { _localRequest, _originalRequest, _originalQuery } = this;
215+
const isOriginalRequest = request === _originalRequest && query === _originalQuery;
216+
217+
if (_localRequest && isOriginalRequest) {
218+
return _localRequest;
219+
}
220+
221+
// update state checks for the next time
222+
this._originalQuery = query;
223+
this._originalRequest = request;
224+
50225
if (request) {
51226
return request;
52227
}
@@ -73,13 +248,14 @@ export class Request<T> extends Component<RequestSignature<T>> {
73248
{{#if this.reqState.isLoading}}
74249
{{yield this.reqState.loadingState to="loading"}}
75250
{{else if (and this.reqState.isCancelled (has-block "cancelled"))}}
76-
{{yield (notNull this.reqState.error) to="cancelled"}}
251+
{{yield (notNull this.reqState.error) this.errorFeatures to="cancelled"}}
77252
{{else if (and this.reqState.isError (has-block "error"))}}
78-
{{yield (notNull this.reqState.error) to="error"}}
253+
{{yield (notNull this.reqState.error) this.errorFeatures to="error"}}
79254
{{else if this.reqState.isSuccess}}
80-
{{yield (notNull this.reqState.result) to="content"}}
81-
{{else}}
255+
{{yield (notNull this.reqState.result) this.contentFeatures to="content"}}
256+
{{else if (not this.reqState.isCancelled)}}
82257
<Throw @error={{(notNull this.reqState.error)}} />
83258
{{/if}}
259+
{{yield this.reqState to="always"}}
84260
</template>
85261
}
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"url": "users/1",
3+
"status": 200,
4+
"statusText": "OK",
5+
"headers": {
6+
"Content-Type": "application/vnd.api+json",
7+
"Content-Encoding": "br",
8+
"Cache-Control": "no-store"
9+
},
10+
"method": "GET",
11+
"requestBody": null
12+
}
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"url": "users/2",
3+
"status": 404,
4+
"statusText": "Not Found",
5+
"headers": {
6+
"Content-Type": "application/vnd.api+json",
7+
"Content-Encoding": "br",
8+
"Cache-Control": "no-store"
9+
},
10+
"method": "GET",
11+
"requestBody": null
12+
}
Binary file not shown.

0 commit comments

Comments
 (0)