shelving
Version:
Toolkit for using data in JavaScript.
122 lines (121 loc) • 5.02 kB
JavaScript
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
}
}