import { Signal } from 'signal-polyfill'; import { SignalAsyncData } from './async-data.ts.js'; /** * Any tracked data accessed in a tracked function _before_ an `await` * will "entangle" with the function -- we can call these accessed tracked * properties, the "tracked prelude". If any properties within the tracked * payload change, the function will re-run. */ function signalFunction(fn) { if (arguments.length === 1) { if (typeof fn !== "function") { throw new Error("signalFunction must be called with a function passed"); } return new State(fn); } throw new Error("Unknown arity: signalFunction must be called with 1 argument"); } /** * State container that represents the asynchrony of a `signalFunction` */ class State { #data = new Signal.State(null); get data() { this.#computed.get(); return this.#data.get(); } #promise = new Signal.State(undefined); get promise() { this.#computed.get(); return this.#promise.get(); } /** * ember-async-data doesn't catch errors, * so we can't rely on it to protect us from "leaky errors" * during rendering. * * See also: https://github.com/qunitjs/qunit/issues/1736 */ #caughtError = new Signal.State(undefined); get caughtError() { this.#computed.get(); return this.#caughtError.get(); } #fn; #computed; constructor(fn) { this.#fn = fn; this.#computed = new Signal.Computed(() => { this.retry(); return this; }); } get state() { this.#computed.get(); return this.data?.state ?? "UNSTARTED"; } /** * Initially true, and remains true * until the underlying promise resolves or rejects. */ get isPending() { this.#computed.get(); if (!this.data) return true; return this.data.isPending ?? false; } /** * Alias for `isResolved || isRejected` */ get isFinished() { this.#computed.get(); return this.isResolved || this.isRejected; } /** * Alias for `isFinished` * which is in turn an alias for `isResolved || isRejected` */ get isSettled() { this.#computed.get(); return this.isFinished; } /** * Alias for `isPending` */ get isLoading() { this.#computed.get(); return this.isPending; } /** * When true, the function passed to `signalFunction` has resolved */ get isResolved() { this.#computed.get(); return this.data?.isResolved ?? false; } /** * Alias for `isRejected` */ get isError() { this.#computed.get(); return this.isRejected; } /** * When true, the function passed to `signalFunction` has errored */ get isRejected() { this.#computed.get(); return this.data?.isRejected ?? Boolean(this.caughtError) ?? false; } /** * this.data may not exist yet. * * Additionally, prior iterations of TrackedAsyncData did * not allow the accessing of data before * .state === 'RESOLVED' (isResolved). * * From a correctness standpoint, this is perfectly reasonable, * as it forces folks to handle the states involved with async functions. * * The original version of `signalFunction` did not use TrackedAsyncData, * and did not have these strictnesses upon property access, leaving folks * to be as correct or as fast/prototype-y as they wished. * * For now, `signalFunction` will retain that flexibility. */ get value() { this.#computed.get(); if (this.data?.isResolved) { // This is sort of a lie, but it ends up working out due to // how promises chain automatically when awaited return this.data.value; } return null; } /** * When the function passed to `signalFunction` throws an error, * that error will be the value returned by this property */ get error() { this.#computed.get(); if (this.state === "UNSTARTED" && this.caughtError) { return this.caughtError; } if (this.data?.state !== "REJECTED") { return null; } if (this.caughtError) { return this.caughtError; } return this.data?.error ?? null; } /** * Will re-invoke the function passed to `signalFunction` * this will also re-set some properties on the `State` instance. * This is the same `State` instance as before, as the `State` instance * is tied to the `fn` passed to `signalFunction` * * `error` or `resolvedValue` will remain as they were previously * until this promise resolves, and then they'll be updated to the new values. */ retry = async () => { try { /** * This function has two places where it can error: * - immediately when inovking `fn` (where auto-tracking occurs) * - after an await, "eventually" */ await this.#dangerousRetry(); } catch (e) { this.#caughtError.set(e); } }; async #dangerousRetry() { // We've previously had data, but we're about to run-again. // we need to do this again so `isLoading` goes back to `true` when re-running. // NOTE: we want to do this _even_ if this.data is already null. // it's all in the same tracking frame and the important thing is that // we can't *read* data here. this.#data.set(null); // We need to invoke this before going async so that tracked properties are // consumed (entangled with) synchronously this.#promise.set(this.#fn()); // TrackedAsyncData interacts with tracked data during instantiation. // We don't want this internal state to entangle with `signalFunction` // so that *only* the tracked data in `fn` can be entangled. await Promise.resolve(); /** * Before we await to start a new request, let's clear our error. * This is detached from the tracking frame (via the above await), * se the UI can update accordingly, without causing us to refetch */ this.#caughtError.set(null); this.#data.set(new SignalAsyncData(this.promise)); return this.promise; } } export { State, signalFunction }; //# sourceMappingURL=async-function.ts.js.map