252 lines
7.4 KiB
JavaScript
252 lines
7.4 KiB
JavaScript
import { Signal } from 'signal-polyfill';
|
|
|
|
/** A very cheap representation of the of a promise. */
|
|
|
|
// We only need a single instance of the pending state in our system, since it
|
|
// is otherwise unparameterized (unlike the resolved and rejected states).
|
|
const PENDING = ["PENDING"];
|
|
|
|
// NOTE: this class is the implementation behind the types; the public types
|
|
// layer on additional safety. See below! Additionally, the docs for the class
|
|
// itself are applied to the export, not to the class, so that they will appear
|
|
// when users refer to *that*.
|
|
class SignalAsyncData {
|
|
/**
|
|
The internal state management for the promise.
|
|
- `readonly` so it cannot be mutated by the class itself after instantiation
|
|
- uses true native privacy so it cannot even be read (and therefore *cannot*
|
|
be depended upon) by consumers.
|
|
*/
|
|
#state = new Signal.State(PENDING);
|
|
#promise;
|
|
|
|
/**
|
|
@param promise The promise to inspect.
|
|
*/
|
|
constructor(data) {
|
|
// SAFETY: do not let TS type-narrow this condition,
|
|
// else, `this` is of type `never`
|
|
if (this.constructor !== SignalAsyncData) {
|
|
throw new Error("tracked-async-data cannot be subclassed");
|
|
}
|
|
if (!isPromiseLike(data)) {
|
|
this.#state.set(["RESOLVED", data]);
|
|
this.#promise = Promise.resolve(data);
|
|
return;
|
|
}
|
|
this.#promise = data;
|
|
|
|
// Otherwise, we know that haven't yet handled that promise anywhere in the
|
|
// system, so we continue creating a new instance.
|
|
this.#promise.then(value => {
|
|
this.#state.set(["RESOLVED", value]);
|
|
}, error => {
|
|
this.#state.set(["REJECTED", error]);
|
|
});
|
|
}
|
|
then = (onResolved, onRejected) => {
|
|
if (isPromiseLike(this.#promise)) {
|
|
return this.#promise.then(onResolved).catch(onRejected);
|
|
}
|
|
if (this.state === "RESOLVED") {
|
|
return onResolved(this.value);
|
|
}
|
|
if (this.state === "REJECTED" && onRejected) {
|
|
return onRejected(this.error);
|
|
}
|
|
throw new Error(`Value was not resolveable`);
|
|
};
|
|
|
|
/**
|
|
* The resolution state of the promise.
|
|
*/
|
|
get state() {
|
|
return this.#state.get()[0];
|
|
}
|
|
|
|
/**
|
|
The value of the resolved promise.
|
|
@note It is only valid to access `error` when `.isError` is true, that is,
|
|
when `TrackedAsyncData.state` is `"ERROR"`.
|
|
@warning You should not rely on this returning `T | null`! In a future
|
|
breaking change which drops support for pre-Octane idioms, it will *only*
|
|
return `T` and will *throw* if you access it when the state is wrong.
|
|
*/
|
|
get value() {
|
|
let data = this.#state.get();
|
|
return data[0] === "RESOLVED" ? data[1] : null;
|
|
}
|
|
|
|
/**
|
|
The error of the rejected promise.
|
|
@note It is only valid to access `error` when `.isError` is true, that is,
|
|
when `TrackedAsyncData.state` is `"ERROR"`.
|
|
@warning You should not rely on this returning `null` when the state is not
|
|
`"ERROR"`! In a future breaking change which drops support for pre-Octane
|
|
idioms, it will *only* return `E` and will *throw* if you access it when
|
|
the state is wrong.
|
|
*/
|
|
get error() {
|
|
let data = this.#state.get();
|
|
return data[0] === "REJECTED" ? data[1] : null;
|
|
}
|
|
|
|
/**
|
|
Is the state `"PENDING"`.
|
|
*/
|
|
get isPending() {
|
|
return this.state === "PENDING";
|
|
}
|
|
|
|
/** Is the state `"RESOLVED"`? */
|
|
get isResolved() {
|
|
return this.state === "RESOLVED";
|
|
}
|
|
|
|
/** Is the state `"REJECTED"`? */
|
|
get isRejected() {
|
|
return this.state === "REJECTED";
|
|
}
|
|
|
|
// SAFETY: casts are safe because we uphold these invariants elsewhere in the
|
|
// class. It would be great if we could guarantee them statically, but getters
|
|
// do not return information about the state of the class well.
|
|
toJSON() {
|
|
const {
|
|
isPending,
|
|
isResolved,
|
|
isRejected
|
|
} = this;
|
|
if (isPending) {
|
|
return {
|
|
isPending,
|
|
isResolved,
|
|
isRejected
|
|
};
|
|
} else if (isResolved) {
|
|
return {
|
|
isPending,
|
|
isResolved,
|
|
value: this.value,
|
|
isRejected
|
|
};
|
|
} else {
|
|
return {
|
|
isPending,
|
|
isResolved,
|
|
isRejected,
|
|
error: this.error
|
|
};
|
|
}
|
|
}
|
|
toString() {
|
|
return JSON.stringify(this.toJSON(), null, 2);
|
|
}
|
|
}
|
|
|
|
/**
|
|
The JSON representation of a `TrackedAsyncData`, useful for e.g. logging.
|
|
|
|
Note that you cannot reconstruct a `TrackedAsyncData` *from* this, because it
|
|
is impossible to get the original promise when in a pending state!
|
|
*/
|
|
|
|
// The exported type is the intersection of three narrowed interfaces. Doing it
|
|
// this way has two nice benefits:
|
|
//
|
|
// 1. It allows narrowing to work. For example:
|
|
//
|
|
// ```ts
|
|
// let data = new TrackedAsyncData(Promise.resolve("hello"));
|
|
// if (data.isPending) {
|
|
// data.value; // null
|
|
// data.error; // null
|
|
// } else if (data.isPending) {
|
|
// data.value; // null
|
|
// data.error; // null
|
|
// } else if (data.isRejected) {
|
|
// data.value; // null
|
|
// data.error; // unknown, can now be narrowed
|
|
// }
|
|
// ```
|
|
//
|
|
// This dramatically improves the usability of the type in type-aware
|
|
// contexts (including with templates when using Glint!)
|
|
//
|
|
// 2. Using `interface extends` means that (a) it is guaranteed to be a subtype
|
|
// of the `_TrackedAsyncData` type, (b) that the docstrings applied to the
|
|
// base type still work, and (c) that the types which are *common* to the
|
|
// shared implementations (i.e. `.toJSON()` and `.toString()`) are shared
|
|
// automatically.
|
|
|
|
/** Utility type to check whether the string `key` is a property on an object */
|
|
function has(key, t) {
|
|
return key in t;
|
|
}
|
|
function isPromiseLike(data) {
|
|
return typeof data === "object" && data !== null && has("then", data) && typeof data.then === "function";
|
|
}
|
|
|
|
/**
|
|
Given a `Promise`, return a `TrackedAsyncData` object which exposes the state
|
|
of the promise, as well as the resolved value or thrown error once the promise
|
|
resolves or fails.
|
|
|
|
The function and helper accept any data, so you may use it freely in contexts
|
|
where you are receiving data which may or may not be a `Promise`.
|
|
|
|
## Example
|
|
|
|
Given a backing class like this:
|
|
|
|
```js
|
|
import Component from '@glimmer/component';
|
|
import { signal } from 'signal-utils';
|
|
import { load } from 'ember-tracked-data/helpers/load';
|
|
|
|
export default class ExtraInfo extends Component {
|
|
@signal
|
|
get someData() {return load(fetch('some-url', this.args.someArg));
|
|
}
|
|
}
|
|
```
|
|
|
|
You can use the result in your template like this:
|
|
|
|
```hbs
|
|
{{#if this.someData.isLoading}}
|
|
loading...
|
|
{{else if this.someData.isLoaded}}
|
|
{{this.someData.value}}
|
|
{{else if this.someData.isError}}
|
|
Whoops! Something went wrong: {{this.someData.error}}
|
|
{{/if}}
|
|
```
|
|
|
|
You can also use the helper directly in your template:
|
|
|
|
```hbs
|
|
{{#let (load @somePromise) as |data|}}
|
|
{{#if data.isLoading}}
|
|
<LoadingSpinner />
|
|
{{else if data.isLoaded}}
|
|
<SomeComponent @data={{data.value}} />
|
|
{{else if data.isError}}
|
|
<Error @cause={{data.error}} />
|
|
{{/if}}
|
|
{{/let}}
|
|
```
|
|
|
|
@param data The (async) data we want to operate on: a value or a `Promise` of
|
|
a value.
|
|
@returns An object containing the state(, value, and error).
|
|
@note Prefer to use `TrackedAsyncData` directly! This function is provided
|
|
simply for symmetry with the helper and backwards compatibility.
|
|
*/
|
|
function load(data) {
|
|
return new SignalAsyncData(data);
|
|
}
|
|
|
|
export { SignalAsyncData, load };
|
|
//# sourceMappingURL=async-data.ts.js.map
|