Skip to content

Instantly share code, notes, and snippets.

@chriskrycho
Last active October 10, 2023 10:47
Show Gist options
  • Save chriskrycho/306a82990dd82203073272e055df5cd1 to your computer and use it in GitHub Desktop.
Save chriskrycho/306a82990dd82203073272e055df5cd1 to your computer and use it in GitHub Desktop.
An auto-tracked AsyncData and load helper (with no proxies!)
import { tracked } from '@glimmer/tracking';
import { helper } from '@ember/component/helper';
import { action } from '@ember/object';
/**
* @template T
* @type {WeakMap<Promise<T>, AsyncData<T, any>>}
*/
const PROMISE_STATE = new WeakMap();
/**
* The state of a promise.
*
* @template T The value from the promise if it resolves.
* @template E The error of the promise if it rejects.
*/
class AsyncData {
/** @type {Promise<T>} */
#promise;
/**
* @param {Promise<T>} promise The promise to load.
*/
constructor(promise) {
this.#promise = promise;
}
/**
* The resolution state of the promise: `'LOADING'` or `'LOADED'` or `'ERROR'`
* @type {'LOADING' | 'LOADED' | 'ERROR'}
*/
@tracked state = 'LOADING';
/**
* @type {T | null}
* @private
*/
@tracked _value = null;
/**
* @type {E | null}
* @private
*/
@tracked _error = null;
// The following getters allow us to refactor our internal management of the
// async state at will. For example, we could throw debug assertions when
// accessing properties at inappropriate times in the lifecycle (like a runtime
// [algebraic data type][ADT]), or make the underlying state *actually* be an
// ADT via TypeScript, etc.
//
// [ADT]: https://jnkr.tech/blog/introduction-to-algebraic-data-types
/**
* The value of the resolved promise. `null` if `isLoaded` is not `true`
*
* @type {T | null}
* @public
*/
get value() {
return this._value;
}
/**
* The error of the rejected promise. `null` if `isError` is not `true`
*
* @type {E | null}
* @public
*/
get error() {
return this._error;
}
/**
* If the state is LOADING.
*
* @type {Boolean}
* @public
*/
get isLoading() {
return this.state === 'LOADING';
}
/**
* If the state is LOADED.
*
* @type {Boolean}
* @public
*/
get isLoaded() {
return this.state === 'LOADED';
}
/**
* If the state is ERROR.
*
* @type {Boolean}
* @public
*/
get isError() {
return this.state === 'ERROR';
}
/**
* Resolves the promise with the specified value
*
* @param {T} value
* @return {void}
*/
@action resolveWith(value) {
this.state = 'LOADED';
this._value = value;
}
/**
* Rejects the promise and throws with the specified error
*
* @param {E} error
* @return {void}
*/
@action rejectWith(error) {
this.state = 'ERROR';
this._error = error;
}
@action then(callback) {
this.#promise = this.#promise.then(callback);
return this;
}
@action catch(callback) {
this.#promise = this.#promise.catch(callback);
return this;
}
@action finally(callback) {
this.#promise = this.#promise.finally(callback);
return this;
}
}
/**
* This function takes a promise and returns a LoadState object which contains
* information about the loading state as well as the resolved value or thrown
* error.
*
* @template T The type of the value from the promise if it resolves.
* @template E The error of the promise if it rejects.
* @param {T | Promise<T>} data The promise we want to operate on.
* @returns {AsyncData<T, E>} An object containing the state(, value, and error).
*/
export function load(data) {
const promise = data == null || typeof then !== 'function'
? Promise.resolve(data)
: data;
if (PROMISE_STATE.has(promise)) {
return PROMISE_STATE.get(promise);
}
const result = new AsyncData(promise);
promise.then(
result.resolveWith,
result.rejectWith
);
PROMISE_STATE.set(promise, result);
return result;
}
export default helper(([promise]) => load(promise));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment