Skip to content

Commit

Permalink
feat: <Request /> autoRefresh (#9363)
Browse files Browse the repository at this point in the history
* feat: <Request /> autoRefresh

* feat: more tests

* more impl and tests

* more imple

* more nice things
  • Loading branch information
runspired authored Apr 18, 2024
1 parent 3fe14c5 commit f0d1100
Show file tree
Hide file tree
Showing 30 changed files with 795 additions and 26 deletions.
61 changes: 56 additions & 5 deletions packages/ember/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ import { Await } from '@warp-drive/ember';
</template>
```

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
Expand Down Expand Up @@ -320,6 +323,10 @@ import { Request } from '@warp-drive/ember';
</template>
```

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
Expand Down Expand Up @@ -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';
<template>
<Request @request={{@request}}>
<:cancelled as |error state|>
<h2>The Request Cancelled</h2>
<button {{on "click" state.retry}}>Retry</button>
</:cancelled>
<:error as |error state|>
<ErrorForm @error={{error}} />
<button {{on "click" state.retry}}>Retry</button>
</:error>
<:content as |result|>
<h1>{{result.title}}</h1>
</:content>
</Request>
</template>
```

- Reloading states

Expand Down Expand Up @@ -434,29 +470,44 @@ import { Request } from '@warp-drive/ember';
</template>
```

- 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';
<template>
<Request @request={{@request}} @autoRefresh={{true}}>
<Request @request={{@request}} @autorefresh={{true}}>
<!-- ... -->
</Request>
</template>
```

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.

```gjs
import { Request } from '@warp-drive/ember';
<template>
<Request @request={{@request}} @autoRefresh={{true}}>
<Request @request={{@request}}>
<:content as |result state|>
<h1>{{result.title}}</h1>
Expand Down
1 change: 0 additions & 1 deletion packages/ember/src/-private/await.gts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ export function notNull<T>(x: T | null) {
return x;
}
export const and = (x: unknown, y: unknown) => Boolean(x && y);

interface ThrowSignature<E = Error | string | object> {
Args: {
error: E;
Expand Down
198 changes: 187 additions & 11 deletions packages/ember/src/-private/request.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -12,24 +13,49 @@ 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'))) {
const { consume } = importSync('ember-provide-consume-context') as { consume: typeof service };
provide = consume;
}

type ContentFeatures<T> = {
isOnline: boolean;
isHidden: boolean;
isRefreshing: boolean;
refresh: () => Promise<void>;
reload: () => Promise<void>;
abort?: () => void;
latestRequest?: Future<T>;
};

interface RequestSignature<T> {
Args: {
request?: Future<T>;
query?: StoreRequestInput<T>;
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<void> },
];
error: [
error: StructuredErrorDocument,
features: { isOnline: boolean; isHidden: boolean; retry: () => Promise<void> },
];
content: [value: T, features: ContentFeatures<T>];
always: [state: RequestState<T>];
};
}

Expand All @@ -38,15 +64,164 @@ export class Request<T> extends Component<RequestSignature<T>> {
* @internal
*/
@provide('store') declare _store: Store;
@tracked isOnline: boolean = true;
@tracked isHidden: boolean = true;
@tracked isRefreshing: boolean = false;
@tracked _localRequest: Future<T> | undefined;
@tracked _latestRequest: Future<T> | undefined;
declare unavailableStart: number | null;
declare onlineChanged: (event: Event) => void;
declare backgroundChanged: (event: Event) => void;
declare _originalRequest: Future<T> | undefined;
declare _originalQuery: StoreRequestInput | undefined;

constructor(owner: unknown, args: RequestSignature<T>['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 <Request />: ${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<T>(request)
: this.store.requestManager.request<T>(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<T> = {
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 <Request> 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;
}
Expand All @@ -73,13 +248,14 @@ export class Request<T> extends Component<RequestSignature<T>> {
{{#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)}}
<Throw @error={{(notNull this.reqState.error)}} />
{{/if}}
{{yield this.reqState to="always"}}
</template>
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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
}
Binary file not shown.
Loading

0 comments on commit f0d1100

Please sign in to comment.