Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/thirty-parrots-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@solana/subscribable': minor
'@solana/react': minor
---

Preserve the last `error` on a `ReactiveActionStore` through subsequent `running` states, matching the existing stale-while-revalidate behavior for `data`. A re-dispatch after a failure now keeps the previous error visible until the new attempt resolves, mirroring how SWR and TanStack Query handle revalidation. `success` clears the error; `reset()` clears both. This also affects `useAction`, whose `error` field now persists through a new `send()` until the new call resolves.
24 changes: 24 additions & 0 deletions packages/react/src/__tests__/useAction-test.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,30 @@ describe('useAction', () => {
expect(result.current.data).toBeUndefined();
});

it('keeps prior error through a subsequent running state (stale-while-revalidate)', async () => {
const boom = new Error('boom');
const { promise: secondPending, resolve: resolveSecond } = Promise.withResolvers<string>();
let n = 0;
const fn = () => (++n === 1 ? Promise.reject(boom) : secondPending);
const { result } = renderHook(() => useAction(fn));

await act(async () => {
await result.current.dispatch().catch(() => {});
});
expect(result.current.status).toBe('error');
expect(result.current.error).toBe(boom);

act(() => {
void result.current.dispatch();
});
expect(result.current.status).toBe('running');
expect(result.current.error).toBe(boom); // stale error preserved during revalidation

await act(async () => resolveSecond('ok'));
expect(result.current.status).toBe('success');
expect(result.current.error).toBeUndefined();
});

it('keeps prior data through a subsequent running state (stale-while-revalidate)', async () => {
const { promise: secondPending, resolve: resolveSecond } = Promise.withResolvers<string>();
let n = 0;
Expand Down
11 changes: 8 additions & 3 deletions packages/react/src/useAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffec
* (and plugin-specific hooks built on top of it).
*
* Lifecycle: starts at `idle`. Each `dispatch(...)` flips to `running`, then to `success` or
* `error` depending on the outcome. `data` from a prior `success` persists through subsequent
* `running` states for stale-while-revalidate UX; only `reset()` clears it.
* `error` depending on the outcome. `data` from a prior `success` and `error` from a prior failure
* both persist through subsequent `running` states for stale-while-revalidate UX. `success` clears
* `error`; only `reset()` clears `data`.
*
* Calling `dispatch(...)` while a previous call is in flight aborts the first via its
* `AbortSignal` and replaces it. Awaiters of the superseded call see a rejection with an
Expand All @@ -37,7 +38,11 @@ export type ActionResult<TArgs extends readonly unknown[], TResult> = {
* `isAbortError` from `@solana/promises`.
*/
dispatch: (...args: TArgs) => Promise<TResult>;
/** The error on failure, or `undefined`. */
/**
* The error from the most recent failed call, or `undefined`. Persists through a subsequent
* `running` state so UIs can keep showing the prior failure while a retry is in flight; a
* subsequent `success` clears it.
*/
error: unknown;
/** `true` when `status === 'error'`. */
isError: boolean;
Expand Down
4 changes: 2 additions & 2 deletions packages/subscribable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,12 @@ The snapshot is a discriminated union:
```ts
type ReactiveActionState<TResult> =
| { status: 'idle'; data: undefined; error: undefined }
| { status: 'running'; data: TResult | undefined; error: undefined }
| { status: 'running'; data: TResult | undefined; error: unknown }
| { 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.
`data` is the last successful result and `error` is the last failure; both survive across transitions so UIs can render stale content (data **or** error) while a retry is in flight. `success` clears `error`; only `reset()` clears `data`.

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.

Expand Down
31 changes: 31 additions & 0 deletions packages/subscribable/src/__tests__/reactive-action-store-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,37 @@ describe('createReactiveActionStore', () => {
resolveSecond('second');
});

it('preserves the last `error` across a subsequent `running` state', async () => {
expect.assertions(1);
const failure = new Error('boom');
const { promise: second } = Promise.withResolvers<string>();
const results = [Promise.reject(failure), second];
const store = createReactiveActionStore(() => results.shift()!);
await store.dispatchAsync().catch(() => {});
store.dispatch();
expect(store.getState()).toStrictEqual({
data: undefined,
error: failure,
status: 'running',
});
});

it('preserves both stale `data` and stale `error` across a subsequent `running` state', async () => {
expect.assertions(1);
const failure = new Error('boom');
const { promise: third } = Promise.withResolvers<string>();
const results = [Promise.resolve('first'), Promise.reject(failure), third];
const store = createReactiveActionStore(() => results.shift()!);
await store.dispatchAsync();
await store.dispatchAsync().catch(() => {});
store.dispatch();
expect(store.getState()).toStrictEqual({
data: 'first',
error: failure,
status: 'running',
});
});

it('preserves the last successful `data` across a subsequent `error` state', async () => {
expect.assertions(1);
const failure = new Error('boom');
Expand Down
11 changes: 6 additions & 5 deletions packages/subscribable/src/reactive-action-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ export type ReactiveActionStatus = 'error' | 'idle' | 'running' | 'success';
/**
* 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.
* `data` holds the most recent successful result and `error` holds the most recent failure. Both
* persist through subsequent `running` states so call sites can keep rendering stale content
* while a retry is in flight. `success` clears `error`; only `reset()` clears `data`.
*/
export type ReactiveActionState<TResult> =
| { readonly data: TResult | undefined; readonly error: undefined; readonly status: 'running' }
| { readonly data: TResult | undefined; readonly error: unknown; readonly status: 'error' }
| { readonly data: TResult | undefined; readonly error: unknown; readonly status: 'running' }
| { readonly data: TResult; readonly error: undefined; readonly status: 'success' }
| { readonly data: undefined; readonly error: undefined; readonly status: 'idle' };

Expand Down Expand Up @@ -163,7 +163,8 @@ export function createReactiveActionStore<TArgs extends readonly unknown[], TRes
currentController = controller;
const signal = userSignal ? AbortSignal.any([controller.signal, userSignal]) : controller.signal;
const previousData = state.data;
setState({ data: previousData, error: undefined, status: 'running' });
const previousError = state.error;
setState({ data: previousData, error: previousError, status: 'running' });
try {
const result = await getAbortablePromise(fn(signal, ...args), signal);
if (signal.aborted) {
Expand Down
Loading