UNPKG

shelving

Version:

Toolkit for using data in JavaScript.

122 lines (121 loc) 5.02 kB
import { RequiredError } from "../error/RequiredError.js"; import { isAsync } from "../util/async.js"; import { ABORT, SKIP } from "../util/constants.js"; import { BusyStore } from "./BusyStore.js"; /** Zero passed to `refresh()` means "always refresh this value" (this is the default). */ export const ALWAYS_REFRESH = 0; /** Infinity passed to `refresh()` means "only refresh if this value is not loaded or invalid". */ export const AVOID_REFRESH = Infinity; /** * Store that fetches its values from a remote source. * * @param value The initial value for the store, or `NONE` if it does not have one yet. * @param callback An optional callback that, if set, will be called when the `refresh()` method is invoked to fetch the next value. * - Override `this._fetch()` in subclasses to define custom fetching behaviour for a subclass. */ export class FetchStore extends BusyStore { // Override to trigger a refresh when `this.loading` is read. // Calling `store.loading` signals intent to use the value. // We optimistically refresh so the value is available the next time the user wants it. get loading() { const loading = super.loading; if (this.invalidated || loading) void this.refresh(); return loading; } // Override to reset the invalidation state. // Setting a value means this store is no longer invalid. write(input) { if (input !== SKIP) this._invalidation = 0; super.write(input); } // Override to trigger refresh on `NONE` // Calling `store.value` signals intent to use the value. // We optimistically refresh so the value is available the next time the user wants it. read() { this.loading; // Ping loading to possibly trigger the intiial fetch. return super.read(); } // Override to consider invalid to be really old. get age() { return this.invalidated ? Infinity : super.age; } // Override to create to save `callback` constructor(value, callback) { super(value); this._callback = callback; } /** * Fetch the result for this store now. * - Triggered automatically when someone reads `value` or `loading`. * - Refreshes are de-duplicated. Concurrent calls while a fetch is in-flight return the same promise. * - Never throws — errors are stored as `reason`. * * @param maxAge The maximum age for whether we refresh or not. * - `0` zero means "always refresh" (this is the default). * - `Infinity` means "refresh only if store is still in a loading state. * - Any other value may or may not be stale based on `this.age` */ refresh(maxAge = ALWAYS_REFRESH) { if (this._pendingRefresh) return this._pendingRefresh; if (!this.stale(maxAge)) return false; try { const value = this._fetch(this.signal); // Retrieving a new signal calls `abort()` which cancels the previous one. if (isAsync(value)) return (this._pendingRefresh = this.await(value)); this.value = value; return true; } catch (thrown) { this.reason = thrown; return false; } } _pendingRefresh = undefined; /** * Current `AbortSignal` for this store's in-flight fetch. * - Created lazily; a new signal is issued each time `refresh()` starts a new fetch or `abort()` is called. * - Triggers `abort()` so any current awaits are cancelled. */ get signal() { this.abort(); this._aborter = new AbortController(); return this._aborter.signal; } _aborter; /** * Call the fetch callback to get the next value. * @param signal `AbortSignal` for the current fetch — passed through to the callback so it can cancel HTTP requests etc. */ _fetch(signal) { if (!this._callback) throw new RequiredError("FetchStore has no callback() function", { store: this, caller: this.refresh }); return this._callback(signal); } _callback; /** Whether this store is has currently been invalidated and needs a refresh. */ get invalidated() { return !!this._invalidation; } /** * Invalidate this store so a new fetch is triggered on the next read of `loading` or `value`. * - Triggers `abort()` so any current awaits are cancelled. */ invalidate() { this.abort(); this._invalidation++; } _invalidation = 0; // Override to abort any current in-flight fetch and pending async operation. // - Sends `ABORT` to the current `AbortSignal` and clears the controller (a new signal will be created on the next read or fetch). // - Any pending `await()` result will be silently discarded. abort() { this._aborter?.abort(ABORT); this._aborter = undefined; this._pendingRefresh = undefined; super.abort(); // clears _pendingValue } }