From 84cd1f283b557bc5d167b12bee4bf4bb724756b3 Mon Sep 17 00:00:00 2001 From: Callum Date: Tue, 12 May 2026 09:25:04 +0000 Subject: [PATCH] Preserve last `error` through `running` for stale-while-revalidate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Errors from a `ReactiveActionStore` now persist through a subsequent `running` state, matching the existing behavior for `data`. A re-dispatch after a failure keeps the previous error in `state.error` until the new attempt resolves — `success` clears it, a new failure replaces it. This mirrors how SWR and TanStack Query handle revalidation: stale-while-revalidate applies symmetrically to data and errors, so consumers don't have to choose between flickering and losing context on retry. The `running` variant of `ReactiveActionState` widens from `error: undefined` to `error: unknown`, which surfaces through `useAction` as a behavior change: the `error` field now persists across a new `send(...)` call until that call resolves, instead of clearing immediately. The README, JSDoc, and tests are updated to match. `useRequest` will pick this up via a follow-up commit on the next branch in the stack. --- .changeset/thirty-parrots-invite.md | 6 ++++ .../src/__tests__/useAction-test.browser.tsx | 24 ++++++++++++++ packages/react/src/useAction.ts | 11 +++++-- packages/subscribable/README.md | 4 +-- .../__tests__/reactive-action-store-test.ts | 31 +++++++++++++++++++ .../subscribable/src/reactive-action-store.ts | 11 ++++--- 6 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 .changeset/thirty-parrots-invite.md diff --git a/.changeset/thirty-parrots-invite.md b/.changeset/thirty-parrots-invite.md new file mode 100644 index 000000000..8b3d86589 --- /dev/null +++ b/.changeset/thirty-parrots-invite.md @@ -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. diff --git a/packages/react/src/__tests__/useAction-test.browser.tsx b/packages/react/src/__tests__/useAction-test.browser.tsx index abc4132af..a3fd73403 100644 --- a/packages/react/src/__tests__/useAction-test.browser.tsx +++ b/packages/react/src/__tests__/useAction-test.browser.tsx @@ -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(); + 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(); let n = 0; diff --git a/packages/react/src/useAction.ts b/packages/react/src/useAction.ts index 71ae16ec3..e609f0fd1 100644 --- a/packages/react/src/useAction.ts +++ b/packages/react/src/useAction.ts @@ -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 @@ -37,7 +38,11 @@ export type ActionResult = { * `isAbortError` from `@solana/promises`. */ dispatch: (...args: TArgs) => Promise; - /** 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; diff --git a/packages/subscribable/README.md b/packages/subscribable/README.md index 1a0a2ac05..97dc1a5b7 100644 --- a/packages/subscribable/README.md +++ b/packages/subscribable/README.md @@ -72,12 +72,12 @@ The snapshot is a discriminated union: ```ts type ReactiveActionState = | { 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` (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. diff --git a/packages/subscribable/src/__tests__/reactive-action-store-test.ts b/packages/subscribable/src/__tests__/reactive-action-store-test.ts index 029cc9d39..38616806e 100644 --- a/packages/subscribable/src/__tests__/reactive-action-store-test.ts +++ b/packages/subscribable/src/__tests__/reactive-action-store-test.ts @@ -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(); + 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(); + 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'); diff --git a/packages/subscribable/src/reactive-action-store.ts b/packages/subscribable/src/reactive-action-store.ts index d6802e437..70ccdf554 100644 --- a/packages/subscribable/src/reactive-action-store.ts +++ b/packages/subscribable/src/reactive-action-store.ts @@ -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 = - | { 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' }; @@ -163,7 +163,8 @@ export function createReactiveActionStore