From c83b2e681f49831e3df722c631ca752ff5781595 Mon Sep 17 00:00:00 2001 From: Callum Date: Mon, 20 Apr 2026 19:56:20 +0000 Subject: [PATCH 1/2] Add `createActionStore` to `@solana/subscribable` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `createActionStore` to `@solana/subscribable`: a framework-agnostic state machine that turns any async function into a `{ dispatch, getState, subscribe, reset }` store. The snapshot is a discriminated `ActionState` keyed on `status: 'idle' | 'running' | 'success' | 'error'`, so the store always has a defined snapshot and consumers never need to handle `undefined`. Each `dispatch` creates a fresh `AbortController` and aborts any in-flight predecessor. The superseded call's outcome is dropped unconditionally — stale resolutions and stale failures can never overwrite the newer call's state. The wrapped function receives the `AbortSignal` as its first argument so it can cooperatively cancel network or compute work. This is intended to become the core of a `useAction` React hook but works equally well against Svelte stores, Vue's `shallowRef`, or a vanilla consumer. Chosen as a sibling of `ReactiveStore` rather than a subtype — the contracts diverge on whether the snapshot is always defined and on how errors surface — and the README calls out the distinction. `dispose()` was intentionally omitted: `reset()` already aborts in-flight work and listeners are garbage-collected when the store is dropped, so a separate disposer would be scope for a future change rather than part of the initial surface. --- .changeset/thin-cats-drop.md | 5 + CLAUDE.md | 1 + packages/subscribable/README.md | 54 +++ packages/subscribable/package.json | 3 +- .../src/__tests__/action-store-test.ts | 426 ++++++++++++++++++ packages/subscribable/src/action-store.ts | 145 ++++++ packages/subscribable/src/index.ts | 1 + packages/subscribable/tsconfig.json | 2 +- pnpm-lock.yaml | 3 + 9 files changed, 638 insertions(+), 2 deletions(-) create mode 100644 .changeset/thin-cats-drop.md create mode 100644 packages/subscribable/src/__tests__/action-store-test.ts create mode 100644 packages/subscribable/src/action-store.ts diff --git a/.changeset/thin-cats-drop.md b/.changeset/thin-cats-drop.md new file mode 100644 index 000000000..f11e13172 --- /dev/null +++ b/.changeset/thin-cats-drop.md @@ -0,0 +1,5 @@ +--- +'@solana/subscribable': minor +--- + +Added `createActionStore` — a framework-agnostic state machine that wraps an async function and exposes a `{ dispatch, dispatchAsync, getState, subscribe, reset }` contract compatible with `useSyncExternalStore`, Svelte stores, Vue's `shallowRef`, and similar reactive primitives. `dispatch` is synchronous and fire-and-forget (safe from UI event handlers); `dispatchAsync` returns a promise that resolves to the wrapped function's result and rejects on failure or supersede — use `isAbortError` from `@solana/promises` to filter aborts. Each call creates a fresh `AbortController` and aborts the previous one, so rapid successive dispatches only produce one final state transition — the outcome of the most recent call. diff --git a/CLAUDE.md b/CLAUDE.md index a8eb58a0c..60707d0ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,7 @@ All errors use the `SolanaError` class from `@solana/errors`. Key rules: - **Dev-only code**: Guard with `__DEV__` (e.g. verbose error messages, debug assertions). - **Formatting**: ESLint via `@solana/eslint-config-solana`, Prettier via `@solana/prettier-config-solana`. Run `pnpm style:fix` to auto-fix. - **All publishable packages share a fixed version** (currently in lockstep). +- **Deferred promises**: Use `Promise.withResolvers()` instead of hand-rolling a `new Promise((resolve, reject) => ...)` with captured externals. Do not reintroduce a `deferred()` helper — `Promise.withResolvers` already returns `{ promise, resolve, reject }`. ## Changesets & Releases diff --git a/packages/subscribable/README.md b/packages/subscribable/README.md index f3da4720c..ce62fa3d4 100644 --- a/packages/subscribable/README.md +++ b/packages/subscribable/README.md @@ -63,6 +63,24 @@ store.subscribe(() => { The individual `getState()` and `getError()` getters on `ReactiveStreamStore` are `@deprecated` — prefer `getUnifiedState()`, which exposes the same information with a stable snapshot identity and `status` discriminator. +### `ActionStore` + +A framework-agnostic state machine for wrapping an async action (a function you dispatch on demand — like a form submission, a mutation, or an on-click fetch). It exposes a `{ dispatch, getState, subscribe, reset }` contract that bridges trivially into `useSyncExternalStore`, Svelte stores, Vue's `shallowRef`, and similar reactive primitives. + +The snapshot is a discriminated union: + +```ts +type ActionState = + | { status: 'idle'; data: undefined; error: undefined } + | { status: 'running'; data: TResult | undefined; error: undefined } + | { status: 'success'; data: TResult; error: undefined } + | { status: 'error'; data: TResult | undefined; error: unknown }; +``` + +`data` is the last successful result and survives across transitions — a `running` or `error` snapshot still carries the last value so UIs can render stale content while a retry is in flight. Only `reset()` clears it. + +Unlike `ReactiveStreamStore` (which models a stream of values with a separate error channel), `ActionStore` models a one-shot-per-dispatch lifecycle where errors are part of the snapshot. + ### `TypedEventEmitter` This type allows you to type `addEventListener` and `removeEventListener` so that the call signature of the listener matches the event type given. @@ -87,6 +105,42 @@ target.dispatchEvent(new CustomEvent('candyVended', { detail: { flavor: 'raspber ## Functions +### `createActionStore(fn)` + +Wraps an async function in an `ActionStore`. Each `dispatch` creates a fresh `AbortController` and aborts the previous one, so a rapid succession of dispatches only produces one final state transition — the outcome of the most recent call. The wrapped function receives the `AbortSignal` as its first argument, followed by the arguments passed to `dispatch`. + +```tsx +const store = createActionStore(async (signal: AbortSignal, accountId: Address) => { + const response = await fetch(`/api/accounts/${accountId}`, { signal }); + return response.json(); +}); + +// React — stale-while-revalidate: keep showing the card during retries. +const { data, error, status } = useSyncExternalStore(store.subscribe, store.getState); +return ( + <> + {data !== undefined && } + {status === 'running' && } + {status === 'error' && store.dispatch(someAccountId)} />} + {status === 'idle' && data === undefined && } + +); +``` + +Things to note: + +- Starts at `{ status: 'idle' }`. `getState()` always returns a defined snapshot. +- `dispatch` is a stable reference — safe to pass into memoized callbacks without re-renders. +- Two ways to trigger the action: + - `dispatch(...)` — fire-and-forget. Returns `undefined` synchronously and never throws; safe to call from UI event handlers without a `.catch`. Failures surface on state as `{ status: 'error' }`. + - `dispatchAsync(...)` — returns a promise that resolves to the wrapped function's result. Rejects on failure and with an `AbortError` when superseded or `reset()`. Use from imperative code that needs the resolved value; pair with [`isAbortError`](../promises#isaborterrorerr) from `@solana/promises` to filter abort rejections. +- Calling either dispatch while one is in flight aborts the previous call; its outcome is dropped from state regardless of which variant started it. +- `data` survives across transitions: a fresh `running` or `error` snapshot carries the last successful result so call sites can keep rendering stale content while a retry is in flight. Only `reset()` clears it. +- `reset()` aborts the in-flight dispatch and restores the idle snapshot, clearing both `data` and `error`. +- Subscribers are notified only when the snapshot's `status`, `data`, or `error` actually changes, so redundant transitions (`dispatch` while already `running` with the same `data`, `reset` while already `idle`) are silent. +- `fn` is captured at construction, so the store holds a closure over whatever `fn` referenced at that moment. In React, create the store once (`useState(() => createActionStore(...))` or `useRef`) and read the latest closure through a ref if you need it to change between renders — don't call `createActionStore` directly in a render body. +- The store holds strong references to its subscribers. Non-framework consumers that subscribe without unsubscribing will keep their listeners (and anything the listeners close over) alive for the lifetime of the store. + ### `createAsyncIterableFromDataPublisher({ abortSignal, dataChannelName, dataPublisher, errorChannelName })` Returns an `AsyncIterable` given a data publisher. The iterable will produce iterators that vend messages published to `dataChannelName` and will throw the first time a message is published to `errorChannelName`. Triggering the abort signal will cause all iterators spawned from this iterator to return once they have published all queued messages. diff --git a/packages/subscribable/package.json b/packages/subscribable/package.json index 204ab9606..b25681fa0 100644 --- a/packages/subscribable/package.json +++ b/packages/subscribable/package.json @@ -74,7 +74,8 @@ "maintained node versions" ], "dependencies": { - "@solana/errors": "workspace:*" + "@solana/errors": "workspace:*", + "@solana/promises": "workspace:*" }, "devDependencies": { "@solana/event-target-impl": "workspace:*" diff --git a/packages/subscribable/src/__tests__/action-store-test.ts b/packages/subscribable/src/__tests__/action-store-test.ts new file mode 100644 index 000000000..f4857e64a --- /dev/null +++ b/packages/subscribable/src/__tests__/action-store-test.ts @@ -0,0 +1,426 @@ +import { createActionStore } from '../action-store'; + +describe('createActionStore', () => { + it('starts in the `idle` state', () => { + const store = createActionStore(() => Promise.resolve('never')); + expect(store.getState()).toStrictEqual({ + data: undefined, + error: undefined, + status: 'idle', + }); + }); + + it('transitions `idle` → `running` → `success` on a successful dispatch', async () => { + expect.assertions(2); + const { promise, resolve } = Promise.withResolvers(); + const store = createActionStore(() => promise); + const dispatched = store.dispatchAsync(); + expect(store.getState()).toStrictEqual({ + data: undefined, + error: undefined, + status: 'running', + }); + resolve(42); + await dispatched; + expect(store.getState()).toStrictEqual({ + data: 42, + error: undefined, + status: 'success', + }); + }); + + it('transitions `idle` → `running` → `error` when the dispatch rejects', async () => { + expect.assertions(2); + const { promise, reject } = Promise.withResolvers(); + const store = createActionStore(() => promise); + const dispatched = store.dispatchAsync(); + expect(store.getState()).toStrictEqual({ + data: undefined, + error: undefined, + status: 'running', + }); + const failure = new Error('boom'); + reject(failure); + await dispatched.catch(() => {}); + expect(store.getState()).toStrictEqual({ + data: undefined, + error: failure, + status: 'error', + }); + }); + + it('returns `undefined` synchronously from `dispatch`', () => { + const store = createActionStore(() => Promise.reject(new Error('boom'))); + expect(store.dispatch()).toBeUndefined(); + }); + + it('rejects `dispatchAsync` on failure so callers can `try/catch`', async () => { + expect.assertions(1); + const store = createActionStore(() => Promise.reject(new Error('boom'))); + await expect(store.dispatchAsync()).rejects.toThrow('boom'); + }); + + it('resolves `dispatchAsync` with the wrapped function result on success', async () => { + expect.assertions(1); + const store = createActionStore(() => Promise.resolve(42)); + await expect(store.dispatchAsync()).resolves.toBe(42); + }); + + it('forwards the `AbortSignal` as the first argument of the wrapped function', async () => { + expect.assertions(1); + const fn = jest.fn, [AbortSignal, string, number]>(() => Promise.resolve('ok')); + const store = createActionStore(fn); + await store.dispatchAsync('hello', 7); + expect(fn.mock.calls).toStrictEqual([[expect.any(AbortSignal), 'hello', 7]]); + }); + + it('aborts an in-flight dispatch when a new dispatch supersedes it', () => { + const signals: AbortSignal[] = []; + const store = createActionStore((signal: AbortSignal) => { + signals.push(signal); + return new Promise(() => {}); + }); + store.dispatch(); + store.dispatch(); + expect(signals[0].aborted).toBe(true); + }); + + it('rejects a superseded `dispatchAsync` with an `AbortError`', async () => { + expect.assertions(1); + const store = createActionStore(() => new Promise(() => {})); + const first = store.dispatchAsync(); + store.dispatch(); + await expect(first).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('rejects `dispatchAsync` with an `AbortError` if superseded after `fn` resolves but before the continuation runs', async () => { + expect.assertions(1); + const { promise, resolve } = Promise.withResolvers(); + const store = createActionStore(() => promise); + const first = store.dispatchAsync(); + resolve('stale'); + // Let the wrapper promise resolve, then synchronously supersede before + // `dispatchAsync`'s await continuation runs. + await Promise.resolve(); + store.dispatch(); + await expect(first).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('rejects `dispatchAsync` with an `AbortError` if superseded after `fn` rejects but before the continuation runs', async () => { + expect.assertions(1); + const { promise, reject } = Promise.withResolvers(); + const store = createActionStore(() => promise); + const first = store.dispatchAsync(); + reject(new Error('nope')); + // Let the wrapper promise reject, then synchronously supersede before + // `dispatchAsync`'s catch continuation runs — caller should see AbortError, + // not the masked real error. + await Promise.resolve(); + store.dispatch(); + await expect(first).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('reflects only the most recent dispatch when two dispatches run in quick succession', async () => { + expect.assertions(1); + const { promise: firstPromise, resolve: resolveFirst } = Promise.withResolvers(); + const { promise: secondPromise, resolve: resolveSecond } = Promise.withResolvers(); + const results = [firstPromise, secondPromise]; + const store = createActionStore(() => results.shift()!); + store.dispatch(); + const second = store.dispatchAsync(); + resolveSecond('second'); + await second; + expect(store.getState()).toStrictEqual({ + data: 'second', + error: undefined, + status: 'success', + }); + resolveFirst('first'); + }); + + it('does not overwrite the superseding call when a superseded call rejects later', async () => { + expect.assertions(1); + const { promise: firstPromise, reject: rejectFirst } = Promise.withResolvers(); + const { promise: secondPromise, resolve: resolveSecond } = Promise.withResolvers(); + const results = [firstPromise, secondPromise]; + const store = createActionStore(() => results.shift()!); + store.dispatch(); + const second = store.dispatchAsync(); + resolveSecond('winner'); + await second; + rejectFirst(new Error('late loser')); + await Promise.resolve(); + await Promise.resolve(); + expect(store.getState()).toStrictEqual({ + data: 'winner', + error: undefined, + status: 'success', + }); + }); + + it('does not overwrite the idle state when a superseded call resolves after `reset`', async () => { + expect.assertions(1); + const { promise, resolve } = Promise.withResolvers(); + const store = createActionStore(() => promise); + store.dispatch(); + store.reset(); + resolve('stale'); + await Promise.resolve(); + await Promise.resolve(); + expect(store.getState()).toStrictEqual({ + data: undefined, + error: undefined, + status: 'idle', + }); + }); + + describe('reset()', () => { + it('returns the store to idle from a success state', async () => { + expect.assertions(1); + const store = createActionStore(() => Promise.resolve('ok')); + await store.dispatchAsync(); + store.reset(); + expect(store.getState()).toStrictEqual({ + data: undefined, + error: undefined, + status: 'idle', + }); + }); + + it('returns the store to idle from an error state', async () => { + expect.assertions(1); + const store = createActionStore(() => Promise.reject(new Error('boom'))); + await store.dispatchAsync().catch(() => {}); + store.reset(); + expect(store.getState()).toStrictEqual({ + data: undefined, + error: undefined, + status: 'idle', + }); + }); + + it('aborts an in-flight dispatch', () => { + const signals: AbortSignal[] = []; + const store = createActionStore((signal: AbortSignal) => { + signals.push(signal); + return new Promise(() => {}); + }); + store.dispatch(); + store.reset(); + expect(signals[0].aborted).toBe(true); + }); + + it('rejects an in-flight `dispatchAsync` with an `AbortError`', async () => { + expect.assertions(1); + const store = createActionStore(() => new Promise(() => {})); + const dispatched = store.dispatchAsync(); + store.reset(); + await expect(dispatched).rejects.toMatchObject({ name: 'AbortError' }); + }); + }); + + describe('subscribe()', () => { + it('notifies listeners on transition to `running`', () => { + const store = createActionStore(() => new Promise(() => {})); + const listener = jest.fn(); + store.subscribe(listener); + store.dispatch(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('notifies listeners on transition to `success`', async () => { + expect.assertions(1); + const { promise, resolve } = Promise.withResolvers(); + const store = createActionStore(() => promise); + const dispatched = store.dispatchAsync(); + const listener = jest.fn(); + store.subscribe(listener); + resolve('ok'); + await dispatched; + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('notifies listeners on transition to `error`', async () => { + expect.assertions(1); + const { promise, reject } = Promise.withResolvers(); + const store = createActionStore(() => promise); + const dispatched = store.dispatchAsync(); + const listener = jest.fn(); + store.subscribe(listener); + reject(new Error('boom')); + await dispatched.catch(() => {}); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('does not notify listeners that unsubscribed before the transition', async () => { + expect.assertions(1); + const store = createActionStore(() => Promise.resolve('ok')); + const listener = jest.fn(); + const unsubscribe = store.subscribe(listener); + unsubscribe(); + await store.dispatchAsync(); + expect(listener).not.toHaveBeenCalled(); + }); + + it('notifies multiple listeners independently', async () => { + expect.assertions(2); + const store = createActionStore(() => Promise.resolve('ok')); + const listenerA = jest.fn(); + const listenerB = jest.fn(); + store.subscribe(listenerA); + store.subscribe(listenerB); + await store.dispatchAsync(); + expect(listenerA).toHaveBeenCalledTimes(2); + expect(listenerB).toHaveBeenCalledTimes(2); + }); + + it('the unsubscribe function is idempotent', () => { + const store = createActionStore(() => Promise.resolve('ok')); + const unsubscribe = store.subscribe(jest.fn()); + expect(() => { + unsubscribe(); + unsubscribe(); + }).not.toThrow(); + }); + + it('notifies on reset() from a non-idle state', async () => { + expect.assertions(1); + const store = createActionStore(() => Promise.resolve('ok')); + await store.dispatchAsync(); + const listener = jest.fn(); + store.subscribe(listener); + store.reset(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('does not notify on reset() when already idle', () => { + const store = createActionStore(() => Promise.resolve('ok')); + const listener = jest.fn(); + store.subscribe(listener); + store.reset(); + expect(listener).not.toHaveBeenCalled(); + }); + + it('does not notify on a superseding dispatch when the state is already `running`', () => { + const store = createActionStore(() => new Promise(() => {})); + store.dispatch(); + const listener = jest.fn(); + store.subscribe(listener); + store.dispatch(); + expect(listener).not.toHaveBeenCalled(); + }); + }); + + it('has a stable `dispatch` reference across state changes', async () => { + expect.assertions(1); + const store = createActionStore(() => Promise.resolve('ok')); + const initial = store.dispatch; + await store.dispatchAsync(); + store.reset(); + expect(store.dispatch).toBe(initial); + }); + + it('returns a stable snapshot reference for the `idle` state across resets', async () => { + expect.assertions(1); + const store = createActionStore(() => Promise.resolve('ok')); + const idleBefore = store.getState(); + await store.dispatchAsync(); + store.reset(); + expect(store.getState()).toBe(idleBefore); + }); + + describe('stale-while-revalidate', () => { + it('preserves the last successful `data` across a subsequent `running` state', async () => { + expect.assertions(1); + const { promise: second, resolve: resolveSecond } = Promise.withResolvers(); + const results = [Promise.resolve('first'), second]; + const store = createActionStore(() => results.shift()!); + await store.dispatchAsync(); + store.dispatch(); + expect(store.getState()).toStrictEqual({ + data: 'first', + error: undefined, + status: 'running', + }); + resolveSecond('second'); + }); + + it('preserves the last successful `data` across a subsequent `error` state', async () => { + expect.assertions(1); + const failure = new Error('boom'); + const results = [Promise.resolve('first'), Promise.reject(failure)]; + const store = createActionStore(() => results.shift()!); + await store.dispatchAsync(); + await store.dispatchAsync().catch(() => {}); + expect(store.getState()).toStrictEqual({ + data: 'first', + error: failure, + status: 'error', + }); + }); + + it('replaces stale `data` when a subsequent dispatch succeeds with a new value', async () => { + expect.assertions(1); + const results = [Promise.resolve('first'), Promise.resolve('second')]; + const store = createActionStore(() => results.shift()!); + await store.dispatchAsync(); + await store.dispatchAsync(); + expect(store.getState()).toStrictEqual({ + data: 'second', + error: undefined, + status: 'success', + }); + }); + + it('clears a previous error once a subsequent dispatch succeeds', async () => { + expect.assertions(1); + const results = [Promise.reject(new Error('boom')), Promise.resolve('ok')]; + const store = createActionStore(() => results.shift()!); + await store.dispatchAsync().catch(() => {}); + await store.dispatchAsync(); + expect(store.getState()).toStrictEqual({ + data: 'ok', + error: undefined, + status: 'success', + }); + }); + + it('clears `data` on reset()', async () => { + expect.assertions(1); + const store = createActionStore(() => Promise.resolve('ok')); + await store.dispatchAsync(); + store.reset(); + expect(store.getState()).toStrictEqual({ + data: undefined, + error: undefined, + status: 'idle', + }); + }); + + it('does not restore stale `data` after reset() when a new dispatch runs', () => { + const { promise } = Promise.withResolvers(); + const results = [Promise.resolve('first'), promise]; + const store = createActionStore(() => results.shift()!); + store.dispatch(); + store.reset(); + store.dispatch(); + expect(store.getState()).toStrictEqual({ + data: undefined, + error: undefined, + status: 'running', + }); + }); + + it('keeps `data` undefined in the error state when no prior success occurred', async () => { + expect.assertions(1); + const failure = new Error('boom'); + const store = createActionStore(() => Promise.reject(failure)); + await store.dispatchAsync().catch(() => {}); + expect(store.getState()).toStrictEqual({ + data: undefined, + error: failure, + status: 'error', + }); + }); + }); +}); diff --git a/packages/subscribable/src/action-store.ts b/packages/subscribable/src/action-store.ts new file mode 100644 index 000000000..aa2076257 --- /dev/null +++ b/packages/subscribable/src/action-store.ts @@ -0,0 +1,145 @@ +import { AbortController } from '@solana/event-target-impl'; +import { getAbortablePromise } from '@solana/promises'; + +/** Lifecycle status of an {@link ActionStore}. */ +export type ActionStatus = 'error' | 'idle' | 'running' | 'success'; + +/** + * Discriminated state of an {@link ActionStore}, keyed by {@link ActionStatus}. + * + * `data` holds the most recent successful result and persists through subsequent `running` and + * `error` states so call sites can keep rendering stale content while a retry is in flight. Only + * `reset()` clears it. + */ +export type ActionState = + | { readonly data: TResult | undefined; readonly error: undefined; readonly status: 'running' } + | { readonly data: TResult | undefined; readonly error: unknown; readonly status: 'error' } + | { readonly data: TResult; readonly error: undefined; readonly status: 'success' } + | { readonly data: undefined; readonly error: undefined; readonly status: 'idle' }; + +/** + * A framework-agnostic state machine that wraps an async function and exposes a + * `{ dispatch, getState, subscribe, reset }` contract. Bridges trivially into + * `useSyncExternalStore`, Svelte stores, Vue's `shallowRef`, and similar reactive primitives. + * + * @see {@link createActionStore} + */ +export type ActionStore = { + /** + * Fire-and-forget dispatch. Returns `undefined` synchronously and never throws — failures + * surface on state as `{ status: 'error' }`, and superseded or `reset()`-aborted calls produce + * no state update. Use from UI event handlers; there's no promise to handle or `.catch`. + * + * @see {@link ActionStore.dispatchAsync} when you need the resolved value or propagated errors. + */ + readonly dispatch: (...args: TArgs) => void; + /** + * Promise-returning dispatch for imperative callers. Resolves with the wrapped function's + * result on success. Rejects with the thrown error on failure, and with an `AbortError` when + * the call is superseded or `reset()` is invoked — filter those with `isAbortError` from + * `@solana/promises`. + */ + readonly dispatchAsync: (...args: TArgs) => Promise; + /** Returns the current state. */ + readonly getState: () => ActionState; + /** Aborts any in-flight dispatch and resets the state to `{ status: 'idle' }`. */ + readonly reset: () => void; + /** Registers a listener called on every state change. Returns an unsubscribe function. */ + readonly subscribe: (listener: () => void) => () => void; +}; + +const IDLE_STATE: ActionState = Object.freeze({ + data: undefined, + error: undefined, + status: 'idle', +}); + +/** + * Wraps an async function in an {@link ActionStore}. Each `dispatch` creates a fresh + * {@link AbortController} and aborts the previous one; the superseded call's outcome is dropped, + * so only the most recent dispatch can mutate state. + * + * The wrapped function receives the `AbortSignal` as its first argument, followed by whatever + * arguments were passed to `dispatch`. + * + * @typeParam TArgs - Argument tuple forwarded from `dispatch` to `fn`. + * @typeParam TResult - Resolved value type of `fn`. + * @param fn - Async function to wrap. Receives an {@link AbortSignal} plus the dispatch arguments. + * @return An {@link ActionStore} exposing `dispatch`, `dispatchAsync`, `getState`, `subscribe`, + * and `reset`. + * + * @example + * ```ts + * const store = createActionStore(async (signal, accountId: Address) => { + * const response = await fetch(`/api/accounts/${accountId}`, { signal }); + * return response.json(); + * }); + * + * store.subscribe(() => console.log(store.getState())); + * store.dispatch(someAccountId); // fire-and-forget; state is the source of truth + * + * // Or, when you need the resolved value imperatively: + * const account = await store.dispatchAsync(someAccountId); + * ``` + * + * @see {@link ActionStore} + */ +export function createActionStore( + fn: (signal: AbortSignal, ...args: TArgs) => Promise, +): ActionStore { + let state: ActionState = IDLE_STATE; + let currentController: AbortController | undefined; + const listeners = new Set<() => void>(); + + function setState(next: ActionState) { + if (state.status === next.status && state.data === next.data && state.error === next.error) { + return; + } + state = next; + listeners.forEach(listener => listener()); + } + + const dispatchAsync = async (...args: TArgs): Promise => { + currentController?.abort(); + const controller = new AbortController(); + currentController = controller; + const { signal } = controller; + const previousData = state.data; + setState({ data: previousData, error: undefined, status: 'running' }); + try { + const result = await getAbortablePromise(fn(signal, ...args), signal); + if (signal.aborted) { + throw signal.reason; + } + setState({ data: result, error: undefined, status: 'success' }); + return result; + } catch (error) { + if (signal.aborted) { + throw signal.reason; + } + setState({ data: previousData, error, status: 'error' }); + throw error; + } + }; + + const dispatch = (...args: TArgs): void => { + dispatchAsync(...args).catch(() => {}); + }; + + return { + dispatch, + dispatchAsync, + getState: () => state, + reset: () => { + currentController?.abort(); + currentController = undefined; + setState(IDLE_STATE); + }, + subscribe: listener => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/packages/subscribable/src/index.ts b/packages/subscribable/src/index.ts index 55bb9c321..f19620a5b 100644 --- a/packages/subscribable/src/index.ts +++ b/packages/subscribable/src/index.ts @@ -6,6 +6,7 @@ * * @packageDocumentation */ +export * from './action-store'; export * from './async-iterable'; export * from './data-publisher'; export * from './demultiplex'; diff --git a/packages/subscribable/tsconfig.json b/packages/subscribable/tsconfig.json index d2e9125ee..e481a5851 100644 --- a/packages/subscribable/tsconfig.json +++ b/packages/subscribable/tsconfig.json @@ -4,6 +4,6 @@ "extends": "../tsconfig/base.json", "include": ["../build-scripts/build-time-constants.d.ts", "src"], "compilerOptions": { - "lib": ["DOM", "ES2015"] + "lib": ["DOM", "ES2015", "ES2024.Promise"] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6eb950d9..562412f9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1417,6 +1417,9 @@ importers: '@solana/errors': specifier: workspace:* version: link:../errors + '@solana/promises': + specifier: workspace:* + version: link:../promises typescript: specifier: '>=5.4.0' version: 5.9.3 From 40afbcb44187023935f113d35fb7fd62adb88a5a Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 22 Apr 2026 13:41:08 +0000 Subject: [PATCH 2/2] Rename ActionStore to ReactiveActionStore --- .changeset/thin-cats-drop.md | 2 +- packages/subscribable/README.md | 14 ++-- ...-test.ts => reactive-action-store-test.ts} | 76 +++++++++---------- packages/subscribable/src/index.ts | 2 +- ...tion-store.ts => reactive-action-store.ts} | 34 ++++----- 5 files changed, 64 insertions(+), 64 deletions(-) rename packages/subscribable/src/__tests__/{action-store-test.ts => reactive-action-store-test.ts} (83%) rename packages/subscribable/src/{action-store.ts => reactive-action-store.ts} (80%) diff --git a/.changeset/thin-cats-drop.md b/.changeset/thin-cats-drop.md index f11e13172..ca0512f7a 100644 --- a/.changeset/thin-cats-drop.md +++ b/.changeset/thin-cats-drop.md @@ -2,4 +2,4 @@ '@solana/subscribable': minor --- -Added `createActionStore` — a framework-agnostic state machine that wraps an async function and exposes a `{ dispatch, dispatchAsync, getState, subscribe, reset }` contract compatible with `useSyncExternalStore`, Svelte stores, Vue's `shallowRef`, and similar reactive primitives. `dispatch` is synchronous and fire-and-forget (safe from UI event handlers); `dispatchAsync` returns a promise that resolves to the wrapped function's result and rejects on failure or supersede — use `isAbortError` from `@solana/promises` to filter aborts. Each call creates a fresh `AbortController` and aborts the previous one, so rapid successive dispatches only produce one final state transition — the outcome of the most recent call. +Added `createReactiveActionStore` — a framework-agnostic state machine that wraps an async function and exposes a `{ dispatch, dispatchAsync, getState, subscribe, reset }` contract compatible with `useSyncExternalStore`, Svelte stores, Vue's `shallowRef`, and similar reactive primitives. `dispatch` is synchronous and fire-and-forget (safe from UI event handlers); `dispatchAsync` returns a promise that resolves to the wrapped function's result and rejects on failure or supersede — use `isAbortError` from `@solana/promises` to filter aborts. Each call creates a fresh `AbortController` and aborts the previous one, so rapid successive dispatches only produce one final state transition — the outcome of the most recent call. diff --git a/packages/subscribable/README.md b/packages/subscribable/README.md index ce62fa3d4..c7f192189 100644 --- a/packages/subscribable/README.md +++ b/packages/subscribable/README.md @@ -63,14 +63,14 @@ store.subscribe(() => { The individual `getState()` and `getError()` getters on `ReactiveStreamStore` are `@deprecated` — prefer `getUnifiedState()`, which exposes the same information with a stable snapshot identity and `status` discriminator. -### `ActionStore` +### `ReactiveActionStore` A framework-agnostic state machine for wrapping an async action (a function you dispatch on demand — like a form submission, a mutation, or an on-click fetch). It exposes a `{ dispatch, getState, subscribe, reset }` contract that bridges trivially into `useSyncExternalStore`, Svelte stores, Vue's `shallowRef`, and similar reactive primitives. The snapshot is a discriminated union: ```ts -type ActionState = +type ReactiveActionState = | { status: 'idle'; data: undefined; error: undefined } | { status: 'running'; data: TResult | undefined; error: undefined } | { status: 'success'; data: TResult; error: undefined } @@ -79,7 +79,7 @@ type ActionState = `data` is the last successful result and survives across transitions — a `running` or `error` snapshot still carries the last value so UIs can render stale content while a retry is in flight. Only `reset()` clears it. -Unlike `ReactiveStreamStore` (which models a stream of values with a separate error channel), `ActionStore` models a one-shot-per-dispatch lifecycle where errors are part of the snapshot. +Unlike `ReactiveStreamStore` (which models a stream of values with a separate error channel), `ReactiveActionStore` models a one-shot-per-dispatch lifecycle where errors are part of the snapshot. ### `TypedEventEmitter` @@ -105,12 +105,12 @@ target.dispatchEvent(new CustomEvent('candyVended', { detail: { flavor: 'raspber ## Functions -### `createActionStore(fn)` +### `createReactiveActionStore(fn)` -Wraps an async function in an `ActionStore`. Each `dispatch` creates a fresh `AbortController` and aborts the previous one, so a rapid succession of dispatches only produces one final state transition — the outcome of the most recent call. The wrapped function receives the `AbortSignal` as its first argument, followed by the arguments passed to `dispatch`. +Wraps an async function in a `ReactiveActionStore`. Each `dispatch` creates a fresh `AbortController` and aborts the previous one, so a rapid succession of dispatches only produces one final state transition — the outcome of the most recent call. The wrapped function receives the `AbortSignal` as its first argument, followed by the arguments passed to `dispatch`. ```tsx -const store = createActionStore(async (signal: AbortSignal, accountId: Address) => { +const store = createReactiveActionStore(async (signal: AbortSignal, accountId: Address) => { const response = await fetch(`/api/accounts/${accountId}`, { signal }); return response.json(); }); @@ -138,7 +138,7 @@ Things to note: - `data` survives across transitions: a fresh `running` or `error` snapshot carries the last successful result so call sites can keep rendering stale content while a retry is in flight. Only `reset()` clears it. - `reset()` aborts the in-flight dispatch and restores the idle snapshot, clearing both `data` and `error`. - Subscribers are notified only when the snapshot's `status`, `data`, or `error` actually changes, so redundant transitions (`dispatch` while already `running` with the same `data`, `reset` while already `idle`) are silent. -- `fn` is captured at construction, so the store holds a closure over whatever `fn` referenced at that moment. In React, create the store once (`useState(() => createActionStore(...))` or `useRef`) and read the latest closure through a ref if you need it to change between renders — don't call `createActionStore` directly in a render body. +- `fn` is captured at construction, so the store holds a closure over whatever `fn` referenced at that moment. In React, create the store once (`useState(() => createReactiveActionStore(...))` or `useRef`) and read the latest closure through a ref if you need it to change between renders — don't call `createReactiveActionStore` directly in a render body. - The store holds strong references to its subscribers. Non-framework consumers that subscribe without unsubscribing will keep their listeners (and anything the listeners close over) alive for the lifetime of the store. ### `createAsyncIterableFromDataPublisher({ abortSignal, dataChannelName, dataPublisher, errorChannelName })` diff --git a/packages/subscribable/src/__tests__/action-store-test.ts b/packages/subscribable/src/__tests__/reactive-action-store-test.ts similarity index 83% rename from packages/subscribable/src/__tests__/action-store-test.ts rename to packages/subscribable/src/__tests__/reactive-action-store-test.ts index f4857e64a..531ca380e 100644 --- a/packages/subscribable/src/__tests__/action-store-test.ts +++ b/packages/subscribable/src/__tests__/reactive-action-store-test.ts @@ -1,8 +1,8 @@ -import { createActionStore } from '../action-store'; +import { createReactiveActionStore } from '../reactive-action-store'; -describe('createActionStore', () => { +describe('createReactiveActionStore', () => { it('starts in the `idle` state', () => { - const store = createActionStore(() => Promise.resolve('never')); + const store = createReactiveActionStore(() => Promise.resolve('never')); expect(store.getState()).toStrictEqual({ data: undefined, error: undefined, @@ -13,7 +13,7 @@ describe('createActionStore', () => { it('transitions `idle` → `running` → `success` on a successful dispatch', async () => { expect.assertions(2); const { promise, resolve } = Promise.withResolvers(); - const store = createActionStore(() => promise); + const store = createReactiveActionStore(() => promise); const dispatched = store.dispatchAsync(); expect(store.getState()).toStrictEqual({ data: undefined, @@ -32,7 +32,7 @@ describe('createActionStore', () => { it('transitions `idle` → `running` → `error` when the dispatch rejects', async () => { expect.assertions(2); const { promise, reject } = Promise.withResolvers(); - const store = createActionStore(() => promise); + const store = createReactiveActionStore(() => promise); const dispatched = store.dispatchAsync(); expect(store.getState()).toStrictEqual({ data: undefined, @@ -50,33 +50,33 @@ describe('createActionStore', () => { }); it('returns `undefined` synchronously from `dispatch`', () => { - const store = createActionStore(() => Promise.reject(new Error('boom'))); + const store = createReactiveActionStore(() => Promise.reject(new Error('boom'))); expect(store.dispatch()).toBeUndefined(); }); it('rejects `dispatchAsync` on failure so callers can `try/catch`', async () => { expect.assertions(1); - const store = createActionStore(() => Promise.reject(new Error('boom'))); + const store = createReactiveActionStore(() => Promise.reject(new Error('boom'))); await expect(store.dispatchAsync()).rejects.toThrow('boom'); }); it('resolves `dispatchAsync` with the wrapped function result on success', async () => { expect.assertions(1); - const store = createActionStore(() => Promise.resolve(42)); + const store = createReactiveActionStore(() => Promise.resolve(42)); await expect(store.dispatchAsync()).resolves.toBe(42); }); it('forwards the `AbortSignal` as the first argument of the wrapped function', async () => { expect.assertions(1); const fn = jest.fn, [AbortSignal, string, number]>(() => Promise.resolve('ok')); - const store = createActionStore(fn); + const store = createReactiveActionStore(fn); await store.dispatchAsync('hello', 7); expect(fn.mock.calls).toStrictEqual([[expect.any(AbortSignal), 'hello', 7]]); }); it('aborts an in-flight dispatch when a new dispatch supersedes it', () => { const signals: AbortSignal[] = []; - const store = createActionStore((signal: AbortSignal) => { + const store = createReactiveActionStore((signal: AbortSignal) => { signals.push(signal); return new Promise(() => {}); }); @@ -87,7 +87,7 @@ describe('createActionStore', () => { it('rejects a superseded `dispatchAsync` with an `AbortError`', async () => { expect.assertions(1); - const store = createActionStore(() => new Promise(() => {})); + const store = createReactiveActionStore(() => new Promise(() => {})); const first = store.dispatchAsync(); store.dispatch(); await expect(first).rejects.toMatchObject({ name: 'AbortError' }); @@ -96,7 +96,7 @@ describe('createActionStore', () => { it('rejects `dispatchAsync` with an `AbortError` if superseded after `fn` resolves but before the continuation runs', async () => { expect.assertions(1); const { promise, resolve } = Promise.withResolvers(); - const store = createActionStore(() => promise); + const store = createReactiveActionStore(() => promise); const first = store.dispatchAsync(); resolve('stale'); // Let the wrapper promise resolve, then synchronously supersede before @@ -109,7 +109,7 @@ describe('createActionStore', () => { it('rejects `dispatchAsync` with an `AbortError` if superseded after `fn` rejects but before the continuation runs', async () => { expect.assertions(1); const { promise, reject } = Promise.withResolvers(); - const store = createActionStore(() => promise); + const store = createReactiveActionStore(() => promise); const first = store.dispatchAsync(); reject(new Error('nope')); // Let the wrapper promise reject, then synchronously supersede before @@ -125,7 +125,7 @@ describe('createActionStore', () => { const { promise: firstPromise, resolve: resolveFirst } = Promise.withResolvers(); const { promise: secondPromise, resolve: resolveSecond } = Promise.withResolvers(); const results = [firstPromise, secondPromise]; - const store = createActionStore(() => results.shift()!); + const store = createReactiveActionStore(() => results.shift()!); store.dispatch(); const second = store.dispatchAsync(); resolveSecond('second'); @@ -143,7 +143,7 @@ describe('createActionStore', () => { const { promise: firstPromise, reject: rejectFirst } = Promise.withResolvers(); const { promise: secondPromise, resolve: resolveSecond } = Promise.withResolvers(); const results = [firstPromise, secondPromise]; - const store = createActionStore(() => results.shift()!); + const store = createReactiveActionStore(() => results.shift()!); store.dispatch(); const second = store.dispatchAsync(); resolveSecond('winner'); @@ -161,7 +161,7 @@ describe('createActionStore', () => { it('does not overwrite the idle state when a superseded call resolves after `reset`', async () => { expect.assertions(1); const { promise, resolve } = Promise.withResolvers(); - const store = createActionStore(() => promise); + const store = createReactiveActionStore(() => promise); store.dispatch(); store.reset(); resolve('stale'); @@ -177,7 +177,7 @@ describe('createActionStore', () => { describe('reset()', () => { it('returns the store to idle from a success state', async () => { expect.assertions(1); - const store = createActionStore(() => Promise.resolve('ok')); + const store = createReactiveActionStore(() => Promise.resolve('ok')); await store.dispatchAsync(); store.reset(); expect(store.getState()).toStrictEqual({ @@ -189,7 +189,7 @@ describe('createActionStore', () => { it('returns the store to idle from an error state', async () => { expect.assertions(1); - const store = createActionStore(() => Promise.reject(new Error('boom'))); + const store = createReactiveActionStore(() => Promise.reject(new Error('boom'))); await store.dispatchAsync().catch(() => {}); store.reset(); expect(store.getState()).toStrictEqual({ @@ -201,7 +201,7 @@ describe('createActionStore', () => { it('aborts an in-flight dispatch', () => { const signals: AbortSignal[] = []; - const store = createActionStore((signal: AbortSignal) => { + const store = createReactiveActionStore((signal: AbortSignal) => { signals.push(signal); return new Promise(() => {}); }); @@ -212,7 +212,7 @@ describe('createActionStore', () => { it('rejects an in-flight `dispatchAsync` with an `AbortError`', async () => { expect.assertions(1); - const store = createActionStore(() => new Promise(() => {})); + const store = createReactiveActionStore(() => new Promise(() => {})); const dispatched = store.dispatchAsync(); store.reset(); await expect(dispatched).rejects.toMatchObject({ name: 'AbortError' }); @@ -221,7 +221,7 @@ describe('createActionStore', () => { describe('subscribe()', () => { it('notifies listeners on transition to `running`', () => { - const store = createActionStore(() => new Promise(() => {})); + const store = createReactiveActionStore(() => new Promise(() => {})); const listener = jest.fn(); store.subscribe(listener); store.dispatch(); @@ -231,7 +231,7 @@ describe('createActionStore', () => { it('notifies listeners on transition to `success`', async () => { expect.assertions(1); const { promise, resolve } = Promise.withResolvers(); - const store = createActionStore(() => promise); + const store = createReactiveActionStore(() => promise); const dispatched = store.dispatchAsync(); const listener = jest.fn(); store.subscribe(listener); @@ -243,7 +243,7 @@ describe('createActionStore', () => { it('notifies listeners on transition to `error`', async () => { expect.assertions(1); const { promise, reject } = Promise.withResolvers(); - const store = createActionStore(() => promise); + const store = createReactiveActionStore(() => promise); const dispatched = store.dispatchAsync(); const listener = jest.fn(); store.subscribe(listener); @@ -254,7 +254,7 @@ describe('createActionStore', () => { it('does not notify listeners that unsubscribed before the transition', async () => { expect.assertions(1); - const store = createActionStore(() => Promise.resolve('ok')); + const store = createReactiveActionStore(() => Promise.resolve('ok')); const listener = jest.fn(); const unsubscribe = store.subscribe(listener); unsubscribe(); @@ -264,7 +264,7 @@ describe('createActionStore', () => { it('notifies multiple listeners independently', async () => { expect.assertions(2); - const store = createActionStore(() => Promise.resolve('ok')); + const store = createReactiveActionStore(() => Promise.resolve('ok')); const listenerA = jest.fn(); const listenerB = jest.fn(); store.subscribe(listenerA); @@ -275,7 +275,7 @@ describe('createActionStore', () => { }); it('the unsubscribe function is idempotent', () => { - const store = createActionStore(() => Promise.resolve('ok')); + const store = createReactiveActionStore(() => Promise.resolve('ok')); const unsubscribe = store.subscribe(jest.fn()); expect(() => { unsubscribe(); @@ -285,7 +285,7 @@ describe('createActionStore', () => { it('notifies on reset() from a non-idle state', async () => { expect.assertions(1); - const store = createActionStore(() => Promise.resolve('ok')); + const store = createReactiveActionStore(() => Promise.resolve('ok')); await store.dispatchAsync(); const listener = jest.fn(); store.subscribe(listener); @@ -294,7 +294,7 @@ describe('createActionStore', () => { }); it('does not notify on reset() when already idle', () => { - const store = createActionStore(() => Promise.resolve('ok')); + const store = createReactiveActionStore(() => Promise.resolve('ok')); const listener = jest.fn(); store.subscribe(listener); store.reset(); @@ -302,7 +302,7 @@ describe('createActionStore', () => { }); it('does not notify on a superseding dispatch when the state is already `running`', () => { - const store = createActionStore(() => new Promise(() => {})); + const store = createReactiveActionStore(() => new Promise(() => {})); store.dispatch(); const listener = jest.fn(); store.subscribe(listener); @@ -313,7 +313,7 @@ describe('createActionStore', () => { it('has a stable `dispatch` reference across state changes', async () => { expect.assertions(1); - const store = createActionStore(() => Promise.resolve('ok')); + const store = createReactiveActionStore(() => Promise.resolve('ok')); const initial = store.dispatch; await store.dispatchAsync(); store.reset(); @@ -322,7 +322,7 @@ describe('createActionStore', () => { it('returns a stable snapshot reference for the `idle` state across resets', async () => { expect.assertions(1); - const store = createActionStore(() => Promise.resolve('ok')); + const store = createReactiveActionStore(() => Promise.resolve('ok')); const idleBefore = store.getState(); await store.dispatchAsync(); store.reset(); @@ -334,7 +334,7 @@ describe('createActionStore', () => { expect.assertions(1); const { promise: second, resolve: resolveSecond } = Promise.withResolvers(); const results = [Promise.resolve('first'), second]; - const store = createActionStore(() => results.shift()!); + const store = createReactiveActionStore(() => results.shift()!); await store.dispatchAsync(); store.dispatch(); expect(store.getState()).toStrictEqual({ @@ -349,7 +349,7 @@ describe('createActionStore', () => { expect.assertions(1); const failure = new Error('boom'); const results = [Promise.resolve('first'), Promise.reject(failure)]; - const store = createActionStore(() => results.shift()!); + const store = createReactiveActionStore(() => results.shift()!); await store.dispatchAsync(); await store.dispatchAsync().catch(() => {}); expect(store.getState()).toStrictEqual({ @@ -362,7 +362,7 @@ describe('createActionStore', () => { it('replaces stale `data` when a subsequent dispatch succeeds with a new value', async () => { expect.assertions(1); const results = [Promise.resolve('first'), Promise.resolve('second')]; - const store = createActionStore(() => results.shift()!); + const store = createReactiveActionStore(() => results.shift()!); await store.dispatchAsync(); await store.dispatchAsync(); expect(store.getState()).toStrictEqual({ @@ -375,7 +375,7 @@ describe('createActionStore', () => { it('clears a previous error once a subsequent dispatch succeeds', async () => { expect.assertions(1); const results = [Promise.reject(new Error('boom')), Promise.resolve('ok')]; - const store = createActionStore(() => results.shift()!); + const store = createReactiveActionStore(() => results.shift()!); await store.dispatchAsync().catch(() => {}); await store.dispatchAsync(); expect(store.getState()).toStrictEqual({ @@ -387,7 +387,7 @@ describe('createActionStore', () => { it('clears `data` on reset()', async () => { expect.assertions(1); - const store = createActionStore(() => Promise.resolve('ok')); + const store = createReactiveActionStore(() => Promise.resolve('ok')); await store.dispatchAsync(); store.reset(); expect(store.getState()).toStrictEqual({ @@ -400,7 +400,7 @@ describe('createActionStore', () => { it('does not restore stale `data` after reset() when a new dispatch runs', () => { const { promise } = Promise.withResolvers(); const results = [Promise.resolve('first'), promise]; - const store = createActionStore(() => results.shift()!); + const store = createReactiveActionStore(() => results.shift()!); store.dispatch(); store.reset(); store.dispatch(); @@ -414,7 +414,7 @@ describe('createActionStore', () => { it('keeps `data` undefined in the error state when no prior success occurred', async () => { expect.assertions(1); const failure = new Error('boom'); - const store = createActionStore(() => Promise.reject(failure)); + const store = createReactiveActionStore(() => Promise.reject(failure)); await store.dispatchAsync().catch(() => {}); expect(store.getState()).toStrictEqual({ data: undefined, diff --git a/packages/subscribable/src/index.ts b/packages/subscribable/src/index.ts index f19620a5b..7788f8225 100644 --- a/packages/subscribable/src/index.ts +++ b/packages/subscribable/src/index.ts @@ -6,7 +6,7 @@ * * @packageDocumentation */ -export * from './action-store'; +export * from './reactive-action-store'; export * from './async-iterable'; export * from './data-publisher'; export * from './demultiplex'; diff --git a/packages/subscribable/src/action-store.ts b/packages/subscribable/src/reactive-action-store.ts similarity index 80% rename from packages/subscribable/src/action-store.ts rename to packages/subscribable/src/reactive-action-store.ts index aa2076257..fda5674bc 100644 --- a/packages/subscribable/src/action-store.ts +++ b/packages/subscribable/src/reactive-action-store.ts @@ -1,17 +1,17 @@ import { AbortController } from '@solana/event-target-impl'; import { getAbortablePromise } from '@solana/promises'; -/** Lifecycle status of an {@link ActionStore}. */ -export type ActionStatus = 'error' | 'idle' | 'running' | 'success'; +/** Lifecycle status of a {@link ReactiveActionStore}. */ +export type ReactiveActionStatus = 'error' | 'idle' | 'running' | 'success'; /** - * Discriminated state of an {@link ActionStore}, keyed by {@link ActionStatus}. + * Discriminated state of a {@link ReactiveActionStore}, keyed by {@link ReactiveActionStatus}. * * `data` holds the most recent successful result and persists through subsequent `running` and * `error` states so call sites can keep rendering stale content while a retry is in flight. Only * `reset()` clears it. */ -export type ActionState = +export type ReactiveActionState = | { readonly data: TResult | undefined; readonly error: undefined; readonly status: 'running' } | { readonly data: TResult | undefined; readonly error: unknown; readonly status: 'error' } | { readonly data: TResult; readonly error: undefined; readonly status: 'success' } @@ -22,15 +22,15 @@ export type ActionState = * `{ dispatch, getState, subscribe, reset }` contract. Bridges trivially into * `useSyncExternalStore`, Svelte stores, Vue's `shallowRef`, and similar reactive primitives. * - * @see {@link createActionStore} + * @see {@link createReactiveActionStore} */ -export type ActionStore = { +export type ReactiveActionStore = { /** * Fire-and-forget dispatch. Returns `undefined` synchronously and never throws — failures * surface on state as `{ status: 'error' }`, and superseded or `reset()`-aborted calls produce * no state update. Use from UI event handlers; there's no promise to handle or `.catch`. * - * @see {@link ActionStore.dispatchAsync} when you need the resolved value or propagated errors. + * @see {@link ReactiveActionStore.dispatchAsync} when you need the resolved value or propagated errors. */ readonly dispatch: (...args: TArgs) => void; /** @@ -41,21 +41,21 @@ export type ActionStore = { */ readonly dispatchAsync: (...args: TArgs) => Promise; /** Returns the current state. */ - readonly getState: () => ActionState; + readonly getState: () => ReactiveActionState; /** Aborts any in-flight dispatch and resets the state to `{ status: 'idle' }`. */ readonly reset: () => void; /** Registers a listener called on every state change. Returns an unsubscribe function. */ readonly subscribe: (listener: () => void) => () => void; }; -const IDLE_STATE: ActionState = Object.freeze({ +const IDLE_STATE: ReactiveActionState = Object.freeze({ data: undefined, error: undefined, status: 'idle', }); /** - * Wraps an async function in an {@link ActionStore}. Each `dispatch` creates a fresh + * Wraps an async function in a {@link ReactiveActionStore}. Each `dispatch` creates a fresh * {@link AbortController} and aborts the previous one; the superseded call's outcome is dropped, * so only the most recent dispatch can mutate state. * @@ -65,12 +65,12 @@ const IDLE_STATE: ActionState = Object.freeze({ * @typeParam TArgs - Argument tuple forwarded from `dispatch` to `fn`. * @typeParam TResult - Resolved value type of `fn`. * @param fn - Async function to wrap. Receives an {@link AbortSignal} plus the dispatch arguments. - * @return An {@link ActionStore} exposing `dispatch`, `dispatchAsync`, `getState`, `subscribe`, + * @return A {@link ReactiveActionStore} exposing `dispatch`, `dispatchAsync`, `getState`, `subscribe`, * and `reset`. * * @example * ```ts - * const store = createActionStore(async (signal, accountId: Address) => { + * const store = createReactiveActionStore(async (signal, accountId: Address) => { * const response = await fetch(`/api/accounts/${accountId}`, { signal }); * return response.json(); * }); @@ -82,16 +82,16 @@ const IDLE_STATE: ActionState = Object.freeze({ * const account = await store.dispatchAsync(someAccountId); * ``` * - * @see {@link ActionStore} + * @see {@link ReactiveActionStore} */ -export function createActionStore( +export function createReactiveActionStore( fn: (signal: AbortSignal, ...args: TArgs) => Promise, -): ActionStore { - let state: ActionState = IDLE_STATE; +): ReactiveActionStore { + let state: ReactiveActionState = IDLE_STATE; let currentController: AbortController | undefined; const listeners = new Set<() => void>(); - function setState(next: ActionState) { + function setState(next: ReactiveActionState) { if (state.status === next.status && state.data === next.data && state.error === next.error) { return; }