diff --git a/.changeset/four-pots-occur.md b/.changeset/four-pots-occur.md new file mode 100644 index 000000000..5ef699a39 --- /dev/null +++ b/.changeset/four-pots-occur.md @@ -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` type is also exported so plugin hooks can declare their return shape against it. diff --git a/packages/react/README.md b/packages/react/README.md index e51234764..e90bb4c98 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -92,6 +92,43 @@ function useRpc() { Pass an array of capability names when a hook needs more than one (e.g. `['rpc', 'rpcSubscriptions']`) — the same `providerHint` is surfaced for whichever is missing. +### `useAction(fn)` + +Bridges any async function into a tracked action with `dispatch` / `status` / `data` / `error` / `reset`. Each `dispatch(...)` runs `fn` with a fresh `AbortSignal` and tracks the lifecycle through React state; calling `dispatch` again while a prior call 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. In-flight calls are unaffected — they continue with the closure they captured at dispatch time. + +```tsx +import { useAction } from '@solana/react'; +import { isAbortError } from '@solana/promises'; + +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(); + }); + + return ( + + ); +} +``` + +`dispatch` returns `Promise`. Fire-and-forget callers can ignore it and render from `status` / `data` / `error`. Awaiters that read the resolved value (e.g. to navigate on success) should filter superseded calls with `isAbortError` from `@solana/promises`: + +```tsx +try { + const { id } = await dispatch(body); + navigate(`/messages/${id}`); +} catch (err) { + if (isAbortError(err)) return; // superseded — state already reflects the newer call + // handle real error +} +``` + ## Hooks ### `useSignIn(uiWalletAccount, chain)` diff --git a/packages/react/package.json b/packages/react/package.json index b3eda1204..f598f9f5b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -80,6 +80,7 @@ "@solana/plugin-core": "workspace:*", "@solana/promises": "workspace:*", "@solana/signers": "workspace:*", + "@solana/subscribable": "workspace:*", "@solana/transaction-messages": "workspace:*", "@solana/transactions": "workspace:*", "@solana/wallet-standard-features": "^1.3.0", diff --git a/packages/react/src/__tests__/useAction-test.browser.tsx b/packages/react/src/__tests__/useAction-test.browser.tsx new file mode 100644 index 000000000..abc4132af --- /dev/null +++ b/packages/react/src/__tests__/useAction-test.browser.tsx @@ -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(); + 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(); + 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(); + signal.addEventListener('abort', () => reject(signal.reason)); + return promise; + }); + const { result } = renderHook(() => useAction(fn)); + + let firstCall!: Promise; + 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(); + 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); + }); +}); diff --git a/packages/react/src/__typetests__/useAction-typetest.ts b/packages/react/src/__typetests__/useAction-typetest.ts new file mode 100644 index 000000000..577010fcc --- /dev/null +++ b/packages/react/src/__typetests__/useAction-typetest.ts @@ -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; + result.data satisfies string | undefined; + } + + // The status field is a discriminated string union, not a generic string + { + const fn = (): Promise => 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 => Promise.resolve('ok'); + const { dispatch } = useAction(fn); + dispatch() satisfies Promise; + // @ts-expect-error - dispatch takes no arguments + dispatch('extra'); + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index ee2d7047c..a37397633 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,6 +4,7 @@ * @packageDocumentation */ export * from './ClientProvider'; +export * from './useAction'; export * from './useClient'; export * from './useClientCapability'; export * from './useSignAndSendTransaction'; diff --git a/packages/react/src/useAction.ts b/packages/react/src/useAction.ts new file mode 100644 index 000000000..71ae16ec3 --- /dev/null +++ b/packages/react/src/useAction.ts @@ -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 = { + /** 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; + /** 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 ( + * + * ); + * } + * ``` + * + * @see {@link ActionResult} + */ +export function useAction( + fn: (signal: AbortSignal, ...args: TArgs) => Promise, +): ActionResult { + // 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((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], + ); +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index c2ff6ce97..8cea19167 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "jsx": "react", - "lib": ["DOM", "ES2015", "ES2022.Object", "ESNext.Promise"] + "lib": ["DOM", "ES2015", "ES2022.Object", "ES2024.Promise"] }, "display": "@solana/react", "extends": "../tsconfig/base.json", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5fc5c0e1..ea6320d4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -962,6 +962,9 @@ importers: '@solana/signers': specifier: workspace:* version: link:../signers + '@solana/subscribable': + specifier: workspace:* + version: link:../subscribable '@solana/transaction-messages': specifier: workspace:* version: link:../transaction-messages