Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thin-cats-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@solana/subscribable': minor
---

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.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()` 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 }`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!


## Changesets & Releases

Expand Down
54 changes: 54 additions & 0 deletions packages/subscribable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ store.subscribe(() => {

The individual `getState()` and `getError()` getters on `ReactiveStreamStore<T>` are `@deprecated` &mdash; prefer `getUnifiedState()`, which exposes the same information with a stable snapshot identity and `status` discriminator.

### `ReactiveActionStore<TArgs, TResult>`

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 ReactiveActionState<TResult> =
| { 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<T>` (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<TEventMap>`

This type allows you to type `addEventListener` and `removeEventListener` so that the call signature of the listener matches the event type given.
Expand All @@ -87,6 +105,42 @@ target.dispatchEvent(new CustomEvent('candyVended', { detail: { flavor: 'raspber

## Functions

### `createReactiveActionStore(fn)`

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 = createReactiveActionStore(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 && <AccountCard account={data} />}
{status === 'running' && <InlineSpinner />}
{status === 'error' && <RetryBanner error={error} onRetry={() => store.dispatch(someAccountId)} />}
{status === 'idle' && data === undefined && <button onClick={() => store.dispatch(someAccountId)}>Load</button>}
</>
);
```

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.
Comment thread
mcintyre94 marked this conversation as resolved.
Comment thread
mcintyre94 marked this conversation as resolved.
- `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 })`

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.
Expand Down
3 changes: 2 additions & 1 deletion packages/subscribable/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@
"maintained node versions"
],
"dependencies": {
"@solana/errors": "workspace:*"
"@solana/errors": "workspace:*",
"@solana/promises": "workspace:*"
},
"devDependencies": {
"@solana/event-target-impl": "workspace:*"
Expand Down
Loading
Loading