-
Notifications
You must be signed in to change notification settings - Fork 181
Add useAction for tracked async actions
#1612
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mcintyre94
wants to merge
1
commit into
react/provider
Choose a base branch
from
react/use-action
base: react/provider
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| --- | ||
| '@solana/react': minor | ||
| --- | ||
|
|
||
| Add `useAction` — a React hook that bridges any async function into a tracked action with `dispatch` / `status` / `data` / `error` / `reset` and supersede-on-second-call semantics. Built on `createReactiveActionStore` from `@solana/subscribable`. | ||
|
|
||
| The wrapped function receives a fresh `AbortSignal` per `send(...)`. Calling `dispatch` again while a prior call is in flight aborts the first; awaiters of the superseded call see a rejection with an `AbortError` filterable via `isAbortError` from `@solana/promises`. `data` from a prior `success` persists through subsequent `running` states for stale-while-revalidate UX; only `reset()` clears it. | ||
|
|
||
| `fn` is held in a ref synced to the latest render's closure, so values it captures (form state, route params, etc.) are always fresh on each new `send(...)` without the caller needing to maintain a `deps` array. In-flight calls are unaffected — they continue with the closure they captured at dispatch time. Matches the convention used by `useMutation` in TanStack Query and `useWriteContract` in wagmi. | ||
|
|
||
| The shared `ActionResult<TArgs, TResult>` type is also exported so plugin hooks can declare their return shape against it. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
135 changes: 135 additions & 0 deletions
135
packages/react/src/__tests__/useAction-test.browser.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| import { isAbortError } from '@solana/promises'; | ||
| import { act } from '@testing-library/react'; | ||
|
|
||
| import { renderHook } from '../__test-utils__/render'; | ||
|
|
||
| import { useAction } from '../useAction'; | ||
|
|
||
| describe('useAction', () => { | ||
| it('transitions idle → running → success', async () => { | ||
| const { promise, resolve } = Promise.withResolvers<string>(); | ||
| const fn = jest.fn((_s: AbortSignal, _arg: string) => promise); | ||
| const { result } = renderHook(() => useAction(fn)); | ||
|
|
||
| expect(result.current.status).toBe('idle'); | ||
| expect(result.current.data).toBeUndefined(); | ||
|
|
||
| act(() => { | ||
| void result.current.dispatch('hello'); | ||
| }); | ||
| expect(result.current.status).toBe('running'); | ||
| expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal), 'hello'); | ||
|
|
||
| await act(async () => resolve('world')); | ||
| expect(result.current.status).toBe('success'); | ||
| expect(result.current.data).toBe('world'); | ||
| }); | ||
|
|
||
| it('transitions idle → running → error', async () => { | ||
| const boom = new Error('boom'); | ||
| const { promise, reject } = Promise.withResolvers<string>(); | ||
| const { result } = renderHook(() => useAction((_s: AbortSignal) => promise)); | ||
|
|
||
| act(() => { | ||
| result.current.dispatch().catch(() => {}); | ||
| }); | ||
| expect(result.current.status).toBe('running'); | ||
|
|
||
| await act(async () => reject(boom)); | ||
| expect(result.current.status).toBe('error'); | ||
| expect(result.current.error).toBe(boom); | ||
| }); | ||
|
|
||
| it('aborts the prior call when dispatch is invoked again while one is in flight', async () => { | ||
| const fn = jest.fn((signal: AbortSignal) => { | ||
| const { promise, reject } = Promise.withResolvers<string>(); | ||
| signal.addEventListener('abort', () => reject(signal.reason)); | ||
| return promise; | ||
| }); | ||
| const { result } = renderHook(() => useAction(fn)); | ||
|
|
||
| let firstCall!: Promise<string>; | ||
| act(() => { | ||
| firstCall = result.current.dispatch(); | ||
| }); | ||
| // Pre-attach a no-op catch so the second call's eventual abort rejection (when the hook | ||
| // unmounts) doesn't surface as an unhandled rejection. | ||
| act(() => { | ||
| result.current.dispatch().catch(() => {}); | ||
| }); | ||
|
|
||
| expect(fn.mock.calls[0][0].aborted).toBe(true); | ||
| expect(fn.mock.calls[1][0].aborted).toBe(false); | ||
|
|
||
| const firstError = await firstCall.catch((err: unknown) => err); | ||
| expect(isAbortError(firstError)).toBe(true); | ||
| }); | ||
|
|
||
| it('await dispatch(...) resolves to the function result on success', async () => { | ||
| const { result } = renderHook(() => useAction(async (_s: AbortSignal, n: number) => n * 2)); | ||
| await act(async () => { | ||
| await expect(result.current.dispatch(21)).resolves.toBe(42); | ||
| }); | ||
| expect(result.current.data).toBe(42); | ||
| }); | ||
|
|
||
| it('reset() returns to idle and clears data', async () => { | ||
| const { result } = renderHook(() => useAction(async () => 'hi')); | ||
| await act(async () => { | ||
| await result.current.dispatch(); | ||
| }); | ||
| expect(result.current.data).toBe('hi'); | ||
|
|
||
| act(() => result.current.reset()); | ||
| expect(result.current.status).toBe('idle'); | ||
| expect(result.current.data).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; | ||
| const fn = () => (++n === 1 ? Promise.resolve('first') : secondPending); | ||
| const { result } = renderHook(() => useAction(fn)); | ||
|
|
||
| await act(async () => { | ||
| await result.current.dispatch(); | ||
| }); | ||
| expect(result.current.data).toBe('first'); | ||
|
|
||
| act(() => { | ||
| void result.current.dispatch(); | ||
| }); | ||
| expect(result.current.status).toBe('running'); | ||
| expect(result.current.data).toBe('first'); // stale data preserved during revalidation | ||
|
|
||
| await act(async () => resolveSecond('second')); | ||
| expect(result.current.data).toBe('second'); | ||
| }); | ||
|
|
||
| it('uses the latest fn closure on each new call', async () => { | ||
| let captured: number | null = null; | ||
| const { result, rerender } = renderHook(({ value }: { value: number }) => useAction(async () => (captured = value)), { initialProps: { value: 1 } }); | ||
|
|
||
| await act(async () => { | ||
| await result.current.dispatch(); | ||
| }); | ||
| expect(captured).toBe(1); | ||
|
|
||
| rerender({ value: 2 }); | ||
| await act(async () => { | ||
| await result.current.dispatch(); | ||
| }); | ||
| expect(captured).toBe(2); | ||
| }); | ||
|
|
||
| it('keeps stable dispatch / reset references across re-renders even as fn changes', () => { | ||
| const { result, rerender } = renderHook(({ tag }: { tag: string }) => useAction(async () => tag), { | ||
| initialProps: { tag: 'a' }, | ||
| }); | ||
| const { dispatch, reset } = result.current; | ||
|
|
||
| rerender({ tag: 'b' }); | ||
| expect(result.current.dispatch).toBe(dispatch); | ||
| expect(result.current.reset).toBe(reset); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| /* eslint-disable @typescript-eslint/no-floating-promises */ | ||
| /* eslint-disable @typescript-eslint/require-await */ | ||
| /* eslint-disable react-hooks/rules-of-hooks */ | ||
|
|
||
| import { ActionResult, useAction } from '../useAction'; | ||
|
|
||
| // [DESCRIBE] useAction | ||
| { | ||
| // It infers TArgs and TResult from the wrapped function | ||
| { | ||
| const result = useAction(async (_signal: AbortSignal, value: number) => `n=${value}`); | ||
| result satisfies ActionResult<[value: number], string>; | ||
| result.dispatch(7) satisfies Promise<string>; | ||
| result.data satisfies string | undefined; | ||
| } | ||
|
|
||
| // The status field is a discriminated string union, not a generic string | ||
| { | ||
| const fn = (): Promise<number> => Promise.resolve(1); | ||
| const { status } = useAction(fn); | ||
| status satisfies 'error' | 'idle' | 'running' | 'success'; | ||
| // @ts-expect-error - 'pending' is not a valid status | ||
| status satisfies 'pending'; | ||
| } | ||
|
|
||
| // dispatch rejects calls that pass the wrong argument types | ||
| { | ||
| const { dispatch } = useAction(async (_signal: AbortSignal, _value: number) => 0); | ||
| dispatch(1); | ||
| // @ts-expect-error - argument should be a number | ||
| dispatch('not a number'); | ||
| } | ||
|
|
||
| // Zero-argument actions get a zero-argument dispatch | ||
| { | ||
| const fn = (): Promise<string> => Promise.resolve('ok'); | ||
| const { dispatch } = useAction(fn); | ||
| dispatch() satisfies Promise<string>; | ||
| // @ts-expect-error - dispatch takes no arguments | ||
| dispatch('extra'); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import { createReactiveActionStore } from '@solana/subscribable'; | ||
| import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; | ||
|
|
||
| // `useLayoutEffect` warns on the server. The ref-sync only needs to be in place by the time an | ||
| // event handler can fire, which can't happen during SSR — so on the server, plain `useEffect` | ||
| // is functionally equivalent and silent. | ||
| const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; | ||
|
|
||
| /** | ||
| * Reactive state and controls for an async action managed by {@link useAction} | ||
| * (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. | ||
| * | ||
| * 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 | ||
| * `AbortError`, filterable via `isAbortError` from `@solana/promises`. | ||
| * | ||
| * @typeParam TArgs - The argument tuple `dispatch` accepts; forwarded to the wrapped function | ||
| * after the abort signal. | ||
| * @typeParam TResult - The value the wrapped function resolves to on success. | ||
| */ | ||
| export type ActionResult<TArgs extends readonly unknown[], TResult> = { | ||
| /** The result on success, or `undefined` if no successful call has happened yet. */ | ||
| data: TResult | undefined; | ||
| /** | ||
| * Trigger the action. Resolves with the wrapped function's result, or rejects with the thrown | ||
| * error. Calling `dispatch` again while a prior call is in flight aborts the first and rejects | ||
| * its promise with an `AbortError`. Stable reference. | ||
| * | ||
| * Mirrors `ReactiveActionStore.dispatchAsync` — combined into a single function on the hook | ||
| * because React event handlers typically don't await the result. Fire-and-forget callers can | ||
| * ignore the returned promise and render from `status` / `data` / `error`. Awaiters that read | ||
| * the resolved value (e.g. to navigate on success) should filter supersede rejections with | ||
| * `isAbortError` from `@solana/promises`. | ||
| */ | ||
| dispatch: (...args: TArgs) => Promise<TResult>; | ||
| /** The error on failure, or `undefined`. */ | ||
| error: unknown; | ||
| /** `true` when `status === 'error'`. */ | ||
| isError: boolean; | ||
| /** `true` when `status === 'idle'`. */ | ||
| isIdle: boolean; | ||
| /** `true` when `status === 'running'` — a dispatch is in flight. */ | ||
| isRunning: boolean; | ||
| /** `true` when `status === 'success'`. */ | ||
| isSuccess: boolean; | ||
| /** Reset state back to `idle`, aborting any in-flight call. Stable reference. */ | ||
| reset: () => void; | ||
| /** | ||
| * The current lifecycle status as a discriminated string. The `isIdle` / `isRunning` / | ||
| * `isSuccess` / `isError` booleans below are derived from this — pick whichever reads better | ||
| * at the call site. | ||
| */ | ||
| status: 'error' | 'idle' | 'running' | 'success'; | ||
| }; | ||
|
|
||
| /** | ||
| * Bridge an arbitrary async function into a reactive {@link ActionResult}. Each `dispatch(...)` | ||
| * call runs the function with a fresh {@link AbortSignal} and tracks its lifecycle through React | ||
| * state; a second call while a first is in flight aborts the first. | ||
| * | ||
| * `fn` is held in a ref that always points at the latest closure — there is no `deps` array to | ||
| * maintain. Each `dispatch(...)` invokes the most recently rendered `fn`, so values captured | ||
| * inside (e.g. form state, route params) are always fresh without explicit dependency tracking. | ||
| * In-flight calls are unaffected — they continue with the closure they captured at dispatch time. | ||
| * | ||
| * @typeParam TArgs - The argument tuple `dispatch` accepts; forwarded to `fn` after the abort | ||
| * signal. | ||
| * @typeParam TResult - The value `fn` resolves to on success. | ||
| * | ||
| * @example | ||
| * ```tsx | ||
| * import { useAction } from '@solana/react'; | ||
| * | ||
| * function PostMessageButton({ url, body }: { url: string; body: string }) { | ||
| * const { dispatch, isRunning, error } = useAction(async (signal, content: string) => { | ||
| * const res = await fetch(url, { body: content, method: 'POST', signal }); | ||
| * if (!res.ok) throw new Error(`HTTP ${res.status}`); | ||
| * return res.json() as Promise<{ id: string }>; | ||
| * }); | ||
| * return ( | ||
| * <button disabled={isRunning} onClick={() => dispatch(body)}> | ||
| * {isRunning ? 'Posting…' : error ? 'Retry' : 'Post'} | ||
| * </button> | ||
| * ); | ||
| * } | ||
| * ``` | ||
| * | ||
| * @see {@link ActionResult} | ||
| */ | ||
| export function useAction<TArgs extends readonly unknown[], TResult>( | ||
| fn: (signal: AbortSignal, ...args: TArgs) => Promise<TResult>, | ||
| ): ActionResult<TArgs, TResult> { | ||
| // Stable callback over the latest closure. Similar to `useEffectEvent`, but we need to | ||
| // pass the callback to `createReactiveActionStore` so need to implement the pattern manually. | ||
| const fnRef = useRef(fn); | ||
| useIsomorphicLayoutEffect(() => { | ||
| fnRef.current = fn; | ||
| }); | ||
|
|
||
| // `createReactiveActionStore` only reads the callback when the returned `dispatch` is | ||
| // called, not during render. The `react-hooks/refs` rule doesn't know that, so we silence it. | ||
| // eslint-disable-next-line react-hooks/refs | ||
| const [store] = useState(() => | ||
| createReactiveActionStore<TArgs, TResult>((signal, ...args) => fnRef.current(signal, ...args)), | ||
| ); | ||
|
|
||
| // Reset on unmount so any in-flight call is aborted and state is dropped. | ||
| useEffect(() => () => store.reset(), [store]); | ||
|
|
||
| const state = useSyncExternalStore(store.subscribe, store.getState); | ||
|
|
||
| return useMemo( | ||
| () => ({ | ||
| data: state.data, | ||
| dispatch: store.dispatchAsync, | ||
| error: state.error, | ||
| isError: state.status === 'error', | ||
| isIdle: state.status === 'idle', | ||
| isRunning: state.status === 'running', | ||
| isSuccess: state.status === 'success', | ||
| reset: store.reset, | ||
| status: state.status, | ||
| }), | ||
| [state, store], | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.