// fallow-ignore-file unused-file // Async substrate for solidify.md §1 (headless primitives) + §4 (suspense-native // async). Will be consumed by `createComboboxItem` / `createSelectItem` // async-iterable paths once §1 lands. import { type Accessor, action, createMemo, onCleanup, refresh, type SourceAccessor, } from "solid-js"; import { type MaybeAccessor, readOption } from "../maybe-accessor"; // Stage 03 — async-compound shape spike. // // Validates the four axes from the stage stub against a minimal data // primitive that's small enough to test in isolation but exercises every // concern that the live Combobox-async port will hit: // // S — `commit` exposed as an `action()`-typed setter (visible in the // type, no "transparent upgrade"). // U — resource-owning memo gated by `lazy: true` + `unobserved` teardown, // which lets us hang an AbortController off the *one* signal that // represents subscription liveness. // W — `items()` is async (`createMemo` returning a Promise). Reads // outside `` throw `ASYNC_OUTSIDE_LOADING_BOUNDARY`. The // primitive does NOT bake in a wrapper-internal ``. // Stage 02 error contract — fetcher throws/rejects propagate through // the graph to ``. The primitive does not catch. // // `createProjection` (axis T) is intentionally not exercised here; for // scalar selection there's nothing to project. The retro carries this // forward as deferred. // // Companion doc: work-left/primitive-template.md §2.4. export interface CreateAsyncSourceOptions { /** * Reactive query. Each settled flip re-runs the fetcher; in-flight * fetches against the prior query are aborted. */ query: MaybeAccessor; /** * Async fetcher. `signal` aborts when: * 1. `query` flips before the fetch resolves (last-start-wins). * 2. The `items` memo loses every subscriber (`unobserved`). * 3. The owning scope disposes (`onCleanup`). * * Throwing or rejecting propagates through the graph to the nearest * `` boundary. The primitive does not swallow. */ fetch: (query: Q, signal: AbortSignal) => Promise; /** * Optional async commit. Exposed on the API as an `action()`-typed * setter — consumers awaiting it participate in the same transition * as the items memo, and `isPending(() => c.items())` reflects * commit-driven invalidations. */ commit?: (value: V, signal: AbortSignal) => Promise; } export interface AsyncSourceApi { query: Accessor; /** * Async accessor. Reads inside a `` ancestor suspend during * the initial fetch and reveal stale-while-revalidating during * re-fetches. Reads outside any `` ancestor throw * `ASYNC_OUTSIDE_LOADING_BOUNDARY`. * * Fetcher rejections re-throw on every read until the next successful * fetch — wrap in `` (stage 02 contract). */ items: Accessor; /** * `action()`-typed commit. Async generator under the hood — the * returned promise resolves when the action's transition settles. * Visible in the type signature (no "transparent upgrade" — the * action-ness is part of the public surface, axis S). */ commit: (value: V) => Promise; /** * Imperatively re-run the items fetcher. Useful after a server write * that doesn't flip `query`. Equivalent to `refresh(c.items)` but * keeps the memo handle private. */ refresh: () => void; } export function createAsyncSource( options: CreateAsyncSourceOptions, ): AsyncSourceApi { const query: Accessor = () => readOption(options.query); // `lazy: true` defers the first fetch until something reads `items()`. // Combined with `unobserved`, the resource lifecycle is: // // first read → fetch starts (controller A live). // query flip → memo re-runs → onCleanup aborts A → fetch starts B. // all readers go away → unobserved → abort B + null out. // reader returns → memo recomputes → fresh controller C. // // The AbortController is held in a closure-local that the unobserved // callback can reach. Per RFC 01, `unobserved` is per-signal/memo — // hanging it off the resource-owning memo gives compound-level // teardown semantics for free. let activeController: AbortController | null = null; // Snapshot bookkeeping — `lastError` is the most recent fetch rejection // (cleared whenever a new fetch starts). Reading `items()` outside a // `` boundary throws a runtime sentinel that's not a real // rejection; this flag lets `snapshot()` distinguish "fetch in flight" // from "fetch rejected" without inspecting Solid-internal error types. let lastError: unknown; let lastSettledItems: readonly V[] | undefined; type AsyncMemoAccessor = SourceAccessor> & SourceAccessor; const items: SourceAccessor = createMemo>( () => { const q = query(); activeController?.abort(); const ctrl = new AbortController(); activeController = ctrl; // Fires when the memo re-evaluates (dependency change) or // when its owner disposes. Belt-and-braces with `unobserved`: // onCleanup covers re-runs and dispose, unobserved covers // "all subscribers gone" without re-evaluation. onCleanup(() => { if (activeController === ctrl) activeController = null; ctrl.abort(); }); lastError = undefined; const p = options.fetch(q, ctrl.signal); p.then( (v) => { if (activeController === ctrl) lastSettledItems = v; }, (e) => { if (activeController === ctrl) lastError = e; }, ); return p; }, { lazy: true, name: "async-source.items", unobserved: () => { activeController?.abort(); activeController = null; }, }, ) as AsyncMemoAccessor; // Cast: the memo's compute returns Promise, but Solid 2 // async-memos surface the resolved value at the read site (suspending // in until ready). The runtime type is the unwrapped form; // the declared compute type is the promise. This matches RFC 05 — // `createMemo(() => fetchUser())` reads as `User`, not `Promise`. // // Cast target is `SourceAccessor` (= `Refreshable>`), not a // bare `Accessor` — `createMemo` returns the refreshable brand and we // keep it so `refresh(items)` below type-checks against the live memo. // `action()` wraps an async generator. The returned function is a // regular `(value: V) => Promise` — visible in the type — that // runs inside a transition. Aborts cascade through the same controller // pattern as the fetch path; consumers can branch on `signal.aborted` // inside their commit fn. Beta.28 routes generator rejections to the // returned promise without the local signals patch older betas required. const commitControllers = new Set(); onCleanup(() => { for (const controller of commitControllers) controller.abort(); commitControllers.clear(); }); const commit = options.commit ? action(async function* ( value: V, ): AsyncGenerator { const ctrl = new AbortController(); commitControllers.add(ctrl); try { await (options.commit as NonNullable)( value, ctrl.signal, ); yield; } finally { commitControllers.delete(ctrl); } }) : async (_value: V): Promise => undefined; const refreshItems = (): void => { // `refresh()` re-runs the memo even when `query` is unchanged. // `items` keeps its `Refreshable` brand from `createMemo`, so this // needs no cast. void refresh(items); }; const api: AsyncSourceApi = { query, items, commit, refresh: refreshItems, }; // Custom snapshot override — async compounds use a `{ status, items, // error }` projection per template §2.5. Reading `items()` outside a // `` boundary throws (a Promise for pending, an Error for // rejection); we catch both and surface them as data so observability // tools can call `snapshot(c)` from anywhere. // // Defined non-enumerable: `snapshot` is metadata, not part of the // compound's primary surface (`Object.keys(c)` should still report only // the four functional keys). const snapshotFn = (): Record => { const out: Record = { query: query() }; if (lastError !== undefined) { out.status = "error"; out.error = lastError; } else if (lastSettledItems !== undefined) { out.status = "settled"; out.items = lastSettledItems; } else { out.status = "pending"; } return out; }; Object.defineProperty(api as object, "snapshot", { value: snapshotFn, enumerable: false, configurable: true, writable: true, }); return api; }