ui/async_slot.svelte.ts

Composable async-operation slot for Svelte 5 reactive state classes.

A state class HOLDS one or more AsyncSlots via composition — one slot per distinct async operation (e.g. list + create + revoke). Each slot tracks the status, payload, and error of its operation independently, so state classes with multiple write paths don't accumulate ad-hoc creating / updating fields beside a single shared loading / error pair.

Core surface:

- Explicit four-value status — AsyncStatus from @fuzdev/fuz_util/async.js: `'initial' | 'pending' | 'success' | 'failure'. loading: false, error: null` would be ambiguous between "never tried" and "succeeded once and now resting"; the four-value status removes the need for a per-class submitted / hydrated flag. - Owns data: T | undefined — the success payload persists across retries (stale-while-revalidate). The sentinel is undefined (not null) so null stays available as a legitimate success value for nullable Ts. Pass T = void for write-only actions whose response isn't worth keeping. - Supersession via internal AbortController — a second run() aborts the first, and superseded results are silently discarded without writing to state. Removes the "in-flight call resolves after the locator advanced" race that locator-style state classes would otherwise need to compensate for. - AbortSignal threaded to the callback — RPC clients that accept a signal (or fetch) get cancellation for free; callers can also pass an external signal via {@link RunOptions} to bind the slot's lifetime to a component / page. - preserve_error_on_retry — opt-in to keeping the previous error visible while a retry is pending (default clears at the start of each run()). - Per-slot map_error — set once in the constructor ({map_error: to_rpc_error_message}); every run() gets the right normalization without re-passing per call. - Public run() — slots are composed, not subclassed, so call sites can invoke state.list.run(...) directly.

@example

class CellsState { readonly list = new AsyncSlot<{cells: ReadonlyArray<CellJson>}>(); readonly create = new AsyncSlot<{cell: CellJson}>({map_error: to_rpc_error_message}); async fetch() { await this.list.run((signal) => this.#api.cell_list({}, {signal})); } async submit_new(input: CellCreateInput) { const result = await this.create.run(() => this.#api.cell_create(input)); if (result) await this.fetch(); } }

Declarations
#

3 declarations

view source

AsyncSlot
#

ui/async_slot.svelte.ts view source

Reactive container for a single async operation.

generics

T

default void

E

default string

status

type AsyncStatus

data

type T | undefined

error

type E | null

error_data

The raw caught value from the last failed run(), for programmatic inspection.

type unknown

initial

Convenience derived: status === 'initial'.

type boolean

readonly

loading

Convenience derived: status === 'pending'.

type boolean

readonly

succeeded

Convenience derived: status === 'success'.

type boolean

readonly

failed

Convenience derived: status === 'failure'.

type boolean

readonly

constructor

type new <T = void, E = string>(options?: AsyncSlotOptions<T, E>): AsyncSlot<T, E>

options
type AsyncSlotOptions<T, E>
default {}

run

Run an async operation. The callback receives an AbortSignal it can forward to fetch / RPC clients that support cancellation; the slot also discards superseded results internally even if the callback ignores the signal.

Supersession rule: a second run() aborts the first's signal AND silently drops its commit if it resolves anyway. So back-to-back-to-back run() calls leave only the last call's result in data.

Abort rule: a run() that throws because of its own signal (manual abort(), external options.signal, OR supersession by another run()) does NOT promote to 'failure'. Manual / external aborts revert status to the previous resolved state ('initial' if no run() has ever succeeded, 'success' otherwise). Supersession is handled by the bail-on-mismatch check, leaving the second run's 'pending' standing.

type (fn: (signal: AbortSignal) => Promise<T>, options?: RunOptions): Promise<T | undefined>

fn
type (signal: AbortSignal) => Promise<T>
options
default {}
returns Promise<T | undefined>

the resolved value on success; undefined on failure, abort, or supersession

abort

Manually abort the in-flight run, if any. Reverts status synchronously to the prior resolved state — 'initial' if no run() (or set()) has ever succeeded on this slot, 'success' otherwise. The aborted run's eventual resolution / rejection is dropped without writing to state (the run's Promise resolves to undefined).

type (reason?: unknown): void

reason?
type unknown
optional
returns void

set

Replace data directly and mark the slot 'success'. For post-mutation hydration where the calling RPC already returned the canonical row (parallels CellState.set_cell).

Aborts any in-flight run() first — without this, the in-flight callback could resolve after set() and overwrite the explicit value (the bail-on-mismatch check only fires when #controller was rotated).

type (data: T): void

data
type T
returns void

reset

Reset to 'initial', clear data / error / error_data, and abort any in-flight run. After reset() the slot looks like a fresh instance with no initial option.

type (): void

returns void

AsyncSlotOptions
#

ui/async_slot.svelte.ts view source

AsyncSlotOptions<T, E>

generics

T

E

default string

initial

Seed data and put the slot in 'success' before any run(). Useful when the page already has the resource in hand (SSR hydration, a mutation response, hand-off from a parent slot).

type T

map_error

Convert a caught throw into the error value stored in {@link AsyncSlot.error}. Default extracts Error.message (falling back to 'Request failed' for non-Error throws). Pass to_rpc_error_message to unwrap JSON-RPC data.reason codes.

type (e: unknown) => E

preserve_error_on_retry

When true, the previous error / error_data survive the start of a new run() until the next success (or another failure overwrites them). Useful for retry UX that wants to keep the failure message visible alongside an inline spinner. Default false — run() clears the error at the start so the pending state reads "no current error."

type boolean

RunOptions
#

ui/async_slot.svelte.ts view source

RunOptions

signal

External signal chained into the slot's internal controller. Aborts the in-flight run when fired (alongside automatic supersession by the next run() and manual {@link AsyncSlot.abort} calls).

type AbortSignal

Imported by
#