diff --git a/.changeset/fresh-eggs-hear.md b/.changeset/fresh-eggs-hear.md new file mode 100644 index 000000000..0614a4dab --- /dev/null +++ b/.changeset/fresh-eggs-hear.md @@ -0,0 +1,16 @@ +--- +'@solana/react': minor +--- + +Add `useRequest` — a React hook for one-shot RPC (or similar) reads. Pass a memoized `ReactiveActionSource` (satisfied by `PendingRpcRequest`) and the hook fires the request on mount, re-fires whenever the source identity changes, and aborts the in-flight call on cleanup. + +```tsx +const source = useMemo(() => client.rpc.getLatestBlockhash(), [client]); +const { data, error, refresh } = useRequest(source); +``` + +The result reports `status` as one of `loading | loaded | error | retrying | disabled`. After a prior success, calling `refresh()` keeps `data` populated and reports `status: 'retrying'` so UIs can show stale content while revalidating instead of flashing to blank. Pass `null` for the source to gate the request off — useful while inputs aren't yet known. The result then reports `status: 'disabled'`. + +Optional `perRequestSignal: () => AbortSignal` is a factory invoked on every attempt (initial fire + every `refresh()`). The returned signal is passed through to the underlying `.reactiveStore({ perRequestSignal })`. The natural use is per-attempt timeouts: `perRequestSignal: () => AbortSignal.timeout(5_000)` gives every attempt its own 5-second clock that resets on refresh. The factory is held in a ref synced to the latest render, so inline closures are fine — no `useCallback` needed. + +The new `RequestResult` and `UseRequestOptions` types are exported alongside the hook so plugin hooks built on top can declare their return shape against them. diff --git a/packages/react/README.md b/packages/react/README.md index 706cff96b..0df5ecac6 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -129,6 +129,59 @@ try { } ``` +### `useRequest(source, options?)` + +Fires a one-shot request on mount and re-fires whenever `source` changes identity. Returns `{ data, error, status, isLoading, refresh }`. Use it for RPC reads that don't have a subscription counterpart (`getLatestBlockhash`, `getBalance`, `getEpochInfo`, …) or for one-shot reads of values you'd otherwise subscribe to. + +`source` is any `{ reactiveStore(): ReactiveActionStore<[], T> }` — `PendingRpcRequest` is the canonical implementation. Memoize it with `useMemo` keyed on the inputs it depends on. Pass `null` to disable (the result reports `status: 'disabled'`). + +```tsx +import { useClientCapability, useRequest } from '@solana/react'; +import type { ClientWithRpc, GetLatestBlockhashApi } from '@solana/kit'; + +function LatestBlockhash() { + const client = useClientCapability>({ + capability: 'rpc', + hookName: 'useLatestBlockhash', + providerHint: 'Install `solanaRpc()` on the client.', + }); + const source = useMemo(() => client.rpc.getLatestBlockhash(), [client]); + const { data, error, refresh } = useRequest(source); + if (error) return ; + return

{data ? `Blockhash: ${data.value.blockhash}` : 'Loading…'}

; +} +``` + +`refresh()` re-fires the request manually — useful for a "Retry" button on an error state, or a user-initiated reload. After a prior success, a `refresh()` keeps the old `data` populated and reports `status: 'retrying'` so UIs can show stale content while revalidating. + +```tsx +function Balance({ address }: { address: Address | null }) { + const client = useClientCapability>({ + capability: 'rpc', + hookName: 'useBalance', + providerHint: 'Install `solanaRpc()` on the client.', + }); + // Disabled until an address is selected. + const source = useMemo(() => (address ? client.rpc.getBalance(address) : null), [client, address]); + const { data, status } = useRequest(source); + if (status === 'disabled') return

Select an account to see its balance.

; + return

{data?.value !== undefined ? `${data.value} lamports` : 'Loading…'}

; +} +``` + +#### Per-attempt cancellation + +Pass `perRequestSignal` to attach a cancellation signal to each individual attempt — initial fire plus every `refresh()`. The natural use is per-attempt timeouts: + +```tsx +const { data, error, refresh } = useRequest(source, { + // Each attempt gets a fresh 5-second clock. `refresh()` resets it. + perRequestSignal: () => AbortSignal.timeout(5_000), +}); +``` + +The factory is held in a ref synced to the latest render, so inline closures are fine — no `useCallback` needed. To kill the hook entirely (e.g. on a route change), do it the React-native way: set the memoized source to `null` (the result reports `disabled`), or let the component unmount. + ## Hooks ### `useSignIn(uiWalletAccount, chain)` diff --git a/packages/react/src/__tests__/useRequest-test.browser.tsx b/packages/react/src/__tests__/useRequest-test.browser.tsx new file mode 100644 index 000000000..bd252eb1a --- /dev/null +++ b/packages/react/src/__tests__/useRequest-test.browser.tsx @@ -0,0 +1,192 @@ +import { createReactiveActionStore, ReactiveActionSource } from '@solana/subscribable'; +import { act, renderHook } from '@testing-library/react'; + +import { useRequest } from '../useRequest'; + +function makeFakeRequest(): { + fn: jest.Mock, [AbortSignal]>; + rejectLatest: (err: unknown) => void; + resolveLatest: (value: T) => void; + source: ReactiveActionSource; +} { + let latest: PromiseWithResolvers | null = null; + const fn = jest.fn, [AbortSignal]>(() => { + latest = Promise.withResolvers(); + return latest.promise; + }); + return { + fn, + rejectLatest(err) { + latest!.reject(err); + }, + resolveLatest(value) { + latest!.resolve(value); + }, + source: { + reactiveStore(options) { + const store = createReactiveActionStore<[], T>(fn, options); + store.dispatch(); + return store; + }, + }, + }; +} + +describe('useRequest', () => { + it('auto-dispatches on mount and transitions loading → loaded', async () => { + const req = makeFakeRequest(); + const { result } = renderHook(() => useRequest(req.source)); + + expect(result.current.status).toBe('loading'); + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + expect(req.fn).toHaveBeenCalledTimes(1); + + await act(async () => req.resolveLatest('hi')); + expect(result.current.status).toBe('loaded'); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBe('hi'); + }); + + it('reports error status with the error value when the call rejects', async () => { + const boom = new Error('boom'); + const req = makeFakeRequest(); + const { result } = renderHook(() => useRequest(req.source)); + + await act(async () => req.rejectLatest(boom)); + expect(result.current.status).toBe('error'); + expect(result.current.error).toBe(boom); + }); + + it('refresh() re-dispatches and transitions retrying → loaded while preserving stale data', async () => { + const req = makeFakeRequest(); + const { result } = renderHook(() => useRequest(req.source)); + + await act(async () => req.resolveLatest('first')); + expect(result.current.data).toBe('first'); + expect(req.fn).toHaveBeenCalledTimes(1); + + act(() => result.current.refresh()); + expect(req.fn).toHaveBeenCalledTimes(2); + expect(result.current.status).toBe('retrying'); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBe('first'); + + await act(async () => req.resolveLatest('second')); + expect(result.current.status).toBe('loaded'); + expect(result.current.data).toBe('second'); + }); + + it('rebuilds the store and fires a fresh request when the source identity changes', () => { + const reqA = makeFakeRequest(); + const reqB = makeFakeRequest(); + const { rerender } = renderHook( + ({ which }: { which: 'a' | 'b' }) => useRequest(which === 'a' ? reqA.source : reqB.source), + { initialProps: { which: 'a' } }, + ); + expect(reqA.fn).toHaveBeenCalledTimes(1); + expect(reqB.fn).not.toHaveBeenCalled(); + + rerender({ which: 'b' }); + expect(reqB.fn).toHaveBeenCalledTimes(1); + }); + + it('reports status: disabled when the source is null', () => { + const { result } = renderHook(() => useRequest(null)); + expect(result.current.status).toBe('disabled'); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + }); + + it('starts firing when the source transitions from null to a real source', () => { + const req = makeFakeRequest(); + const initialProps: { source: ReactiveActionSource | null } = { source: null }; + const { result, rerender } = renderHook(({ source }) => useRequest(source), { initialProps }); + expect(result.current.status).toBe('disabled'); + expect(req.fn).not.toHaveBeenCalled(); + + rerender({ source: req.source }); + expect(result.current.status).toBe('loading'); + expect(req.fn).toHaveBeenCalledTimes(1); + }); + + it('returns to disabled when the source transitions from a real source to null', async () => { + const req = makeFakeRequest(); + const initialProps: { source: ReactiveActionSource | null } = { source: req.source }; + const { result, rerender } = renderHook(({ source }) => useRequest(source), { initialProps }); + await act(async () => req.resolveLatest('hi')); + expect(result.current.status).toBe('loaded'); + + rerender({ source: null }); + expect(result.current.status).toBe('disabled'); + expect(result.current.data).toBeUndefined(); + }); + + it('aborts the in-flight request when the source transitions to null', () => { + const req = makeFakeRequest(); + const initialProps: { source: ReactiveActionSource | null } = { source: req.source }; + const { rerender } = renderHook(({ source }) => useRequest(source), { initialProps }); + const inFlightSignal = req.fn.mock.calls[0]![0]; + expect(inFlightSignal.aborted).toBe(false); + + rerender({ source: null }); + expect(inFlightSignal.aborted).toBe(true); + }); + + it('aborts the in-flight request when the component unmounts', () => { + const req = makeFakeRequest(); + const { unmount } = renderHook(() => useRequest(req.source)); + const inFlightSignal = req.fn.mock.calls[0]![0]; + expect(inFlightSignal.aborted).toBe(false); + + unmount(); + expect(inFlightSignal.aborted).toBe(true); + }); + + it('keeps a stable refresh reference across re-renders', () => { + const req = makeFakeRequest(); + const { result, rerender } = renderHook(() => useRequest(req.source)); + const { refresh } = result.current; + rerender(); + expect(result.current.refresh).toBe(refresh); + }); + + it('invokes perRequestSignal on every attempt with a fresh signal', () => { + const req = makeFakeRequest(); + const signals: AbortSignal[] = []; + const perRequestSignal = jest.fn(() => { + const ctrl = new AbortController(); + signals.push(ctrl.signal); + return ctrl.signal; + }); + const { result } = renderHook(() => useRequest(req.source, { perRequestSignal })); + + expect(perRequestSignal).toHaveBeenCalledTimes(1); + + act(() => result.current.refresh()); + expect(perRequestSignal).toHaveBeenCalledTimes(2); + expect(signals[1]).not.toBe(signals[0]); // fresh identity per attempt + }); + + it('aborting the perRequestSignal transitions the current attempt to error; refresh starts a fresh one', async () => { + const req = makeFakeRequest(); + let currentCtrl: AbortController | undefined; + const perRequestSignal = () => { + currentCtrl = new AbortController(); + return currentCtrl.signal; + }; + const { result } = renderHook(() => useRequest(req.source, { perRequestSignal })); + + const timeoutReason = new Error('timeout'); + await act(async () => currentCtrl!.abort(timeoutReason)); + expect(result.current.status).toBe('error'); + expect(result.current.error).toBe(timeoutReason); + + act(() => result.current.refresh()); + expect(currentCtrl!.signal.aborted).toBe(false); // brand-new controller for the new attempt + + await act(async () => req.resolveLatest('recovered')); + expect(result.current.status).toBe('loaded'); + expect(result.current.data).toBe('recovered'); + }); +}); diff --git a/packages/react/src/__typetests__/useRequest-typetest.ts b/packages/react/src/__typetests__/useRequest-typetest.ts new file mode 100644 index 000000000..e2f536a7b --- /dev/null +++ b/packages/react/src/__typetests__/useRequest-typetest.ts @@ -0,0 +1,19 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import { ReactiveActionSource } from '@solana/subscribable'; + +import { RequestResult, useRequest } from '../useRequest'; + +const slotSource = null as unknown as ReactiveActionSource<{ slot: bigint }>; + +// [DESCRIBE] useRequest +{ + // Infers T from the source + useRequest(slotSource) satisfies RequestResult<{ slot: bigint }>; + + // The source argument accepts null + useRequest<{ slot: bigint }>(null) satisfies RequestResult<{ slot: bigint }>; + + // Options accept a `perRequestSignal` factory + useRequest(slotSource, { perRequestSignal: () => AbortSignal.timeout(5_000) }); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a37397633..ce8705a6e 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -7,6 +7,7 @@ export * from './ClientProvider'; export * from './useAction'; export * from './useClient'; export * from './useClientCapability'; +export * from './useRequest'; export * from './useSignAndSendTransaction'; export * from './useSignIn'; export * from './useSignMessage'; diff --git a/packages/react/src/staticStores.ts b/packages/react/src/staticStores.ts new file mode 100644 index 000000000..d577be309 --- /dev/null +++ b/packages/react/src/staticStores.ts @@ -0,0 +1,30 @@ +import { ReactiveActionStore } from '@solana/subscribable'; + +const DISABLED_ACTION_STATE = Object.freeze({ + data: undefined, + error: undefined, + status: 'idle' as const, +}); + +const noopUnsubscribe = () => {}; +const noopSubscribe = () => noopUnsubscribe; +const rejectedAbortError = (): Promise => Promise.reject(new DOMException('Aborted', 'AbortError')); + +/** + * A {@link ReactiveActionStore} that never transitions out of `idle` and rejects any attempt to + * dispatch. Returned by `useRequest` (and other action-store hooks) when their factory function + * returns `null`, signalling that the call should be gated off — for example because a required + * input (an address, a query string) is not yet known. + * + * The hook's result bridge maps this store's `idle` state to a `disabled` status so call sites + * can distinguish "not enabled" from "loading" without an extra flag. + */ +export function disabledActionStore(): ReactiveActionStore<[], T> { + return { + dispatch: noopUnsubscribe, + dispatchAsync: rejectedAbortError, + getState: () => DISABLED_ACTION_STATE, + reset: noopUnsubscribe, + subscribe: noopSubscribe, + }; +} diff --git a/packages/react/src/useAction.ts b/packages/react/src/useAction.ts index a9f5e8ad5..047a21ebe 100644 --- a/packages/react/src/useAction.ts +++ b/packages/react/src/useAction.ts @@ -1,10 +1,7 @@ import { createReactiveActionStore } from '@solana/subscribable'; -import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { useEffect, 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; +import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; /** * Reactive state and controls for an async action managed by {@link useAction} diff --git a/packages/react/src/useIsomorphicLayoutEffect.ts b/packages/react/src/useIsomorphicLayoutEffect.ts new file mode 100644 index 000000000..4dd5e2f75 --- /dev/null +++ b/packages/react/src/useIsomorphicLayoutEffect.ts @@ -0,0 +1,11 @@ +import { useEffect, useLayoutEffect } from 'react'; + +/** + * `useLayoutEffect` warns when run on the server because layout effects only make sense after a + * DOM is mounted. For our use, plain `useEffect` is functionally equivalent on the server (no + * event handlers can fire mid-render anyway), so we pick at module load time and stay silent + * during SSR. + * + * @internal + */ +export const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; diff --git a/packages/react/src/useRequest.ts b/packages/react/src/useRequest.ts new file mode 100644 index 000000000..774754555 --- /dev/null +++ b/packages/react/src/useRequest.ts @@ -0,0 +1,117 @@ +import { ReactiveActionSource } from '@solana/subscribable'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { disabledActionStore } from './staticStores'; +import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; +import { useRequestResult } from './useRequestResult'; + +/** + * Reactive state for a one-shot request managed by {@link useRequest}. + * + * Lifecycle: starts at `loading` (or `disabled` when the source is `null`) and auto-fires on + * mount; transitions to `loaded` on success or `error` on failure. `refresh()` re-fires the + * request; while a refresh is in flight after a prior success, status is `retrying` and `data` + * still holds the stale value (stale-while-revalidate). + * + * @typeParam T - The value the underlying request resolves to. + */ +export type RequestResult = { + /** The most recent successful value, or `undefined` while loading or when disabled. */ + data: T | undefined; + /** The error from the most recent failed call, or `undefined`. */ + error: unknown; + /** `true` only on the very first `loading` state — `false` during `retrying` so spinners don't replace stale content. */ + isLoading: boolean; + /** Re-fire the request. Each call starts a fresh attempt with a fresh `perRequestSignal`. Stable reference. */ + refresh: () => void; + /** + * Lifecycle status as a discriminated string: + * - `loading`: first call in flight, no data yet. + * - `loaded`: call succeeded. + * - `error`: call failed; `refresh()` will retry. + * - `retrying`: re-fire after a prior success or error; `data` still holds the stale value. + * - `disabled`: source was `null` — no request was fired. + */ + status: 'disabled' | 'error' | 'loaded' | 'loading' | 'retrying'; +}; + +/** Options accepted by {@link useRequest}. */ +export type UseRequestOptions = { + /** + * Factory invoked on every attempt (initial fire + every `refresh()`). The returned signal is + * passed through to the underlying `.reactiveStore({ perRequestSignal })` so aborting it + * cancels just the current attempt. + * + * The most common use is per-attempt timeouts: `perRequestSignal: () => AbortSignal.timeout(5000)` + * gives every attempt its own 5-second clock that resets on `refresh()`. + * + * Held in a ref synced to the latest render's closure — there is no need to memoize an inline + * factory. + */ + perRequestSignal?: () => AbortSignal; +}; + +/** + * Fire a one-shot request on mount and re-fire each time `source` changes identity or `refresh()` + * is called. Returns reactive state tracking the call's lifecycle. + * + * The hook accepts any {@link ReactiveActionSource} — the `{ reactiveStore() }` duck-type satisfied + * by `PendingRpcRequest` and other plugin-authored pending objects (e.g. a DAS client's + * `getAsset(address)`). Pass `null` to disable; the result reports `status: 'disabled'`. + * + * Memoize the source with `useMemo` keyed on whatever inputs it depends on. Stable identity is + * how the hook knows when to re-fire — and because the deps live on a native `useMemo`, + * `react-hooks/exhaustive-deps` catches stale closures by default. + * + * @typeParam T - The value the underlying request resolves to. + * + * @example + * ```tsx + * function LatestBlockhash() { + * const client = useClientCapability>({ + * capability: 'rpc', + * hookName: 'useLatestBlockhash', + * providerHint: 'Install `solanaRpc()` on the client.', + * }); + * const source = useMemo(() => client.rpc.getLatestBlockhash(), [client]); + * const { data, error, refresh } = useRequest(source, { + * perRequestSignal: () => AbortSignal.timeout(5_000), + * }); + * if (error) return ; + * return

{data ? `Blockhash: ${data.value.blockhash}` : 'Loading…'}

; + * } + * ``` + * + * @see {@link RequestResult} + * @see {@link UseRequestOptions} + */ +export function useRequest(source: ReactiveActionSource | null, options?: UseRequestOptions): RequestResult { + // Ref-sync the per-request factory so inline closures don't churn the memo below. Each + // dispatch invokes the latest factory at fire time. + const perRequestSignalRef = useRef(options?.perRequestSignal); + useIsomorphicLayoutEffect(() => { + perRequestSignalRef.current = options?.perRequestSignal; + }); + + // One store per `source`. `refresh()` dispatches on the same store — each dispatch calls the + // factory again, so per-attempt signals stay fresh without rebuilding the store. The action + // store's built-in stale-while-revalidate handles `data` across attempts. + const store = useMemo(() => { + if (source == null) return disabledActionStore(); + // The factory closes over `perRequestSignalRef` and reads `.current` at dispatch time + // (not during render). The `react-hooks/refs` rule can't see through the + // closure-then-deferred-call pattern, so we silence it here. + // eslint-disable-next-line react-hooks/refs + return source.reactiveStore({ + perRequestSignal: () => perRequestSignalRef.current?.(), + }); + }, [source]); + + // Tear down on source change / unmount. `store.reset()` aborts the in-flight network request + // via the action store's internal controller. + useEffect(() => () => store.reset(), [store]); + + const refresh = useCallback(() => store.dispatch(), [store]); + + return useRequestResult(store, refresh); +} diff --git a/packages/react/src/useRequestResult.ts b/packages/react/src/useRequestResult.ts new file mode 100644 index 000000000..1121bd44a --- /dev/null +++ b/packages/react/src/useRequestResult.ts @@ -0,0 +1,37 @@ +import { ReactiveActionStore } from '@solana/subscribable'; +import { useMemo, useSyncExternalStore } from 'react'; + +import { RequestResult } from './useRequest'; + +/** + * Subscribes to a {@link ReactiveActionStore} and maps its `idle | running | success | error` + * lifecycle onto the {@link RequestResult} shape consumed by `useRequest`. + * + * - `idle` (only reachable via `disabledActionStore`) → `disabled` + * - `running` with no prior successful value → `loading` + * - `running` with a prior successful value → `retrying` + * - `success` → `loaded` + * - `error` → `error` + * + * The action store's built-in stale-while-revalidate carries `state.data` across attempts, so + * the bridge doesn't need to mirror it. + * + * @internal + */ +export function useRequestResult(store: ReactiveActionStore<[], T>, refresh: () => void): RequestResult { + const state = useSyncExternalStore(store.subscribe, store.getState); + return useMemo(() => { + switch (state.status) { + case 'idle': + return { data: undefined, error: undefined, isLoading: false, refresh, status: 'disabled' }; + case 'running': + return state.data !== undefined + ? { data: state.data, error: undefined, isLoading: false, refresh, status: 'retrying' } + : { data: undefined, error: undefined, isLoading: true, refresh, status: 'loading' }; + case 'success': + return { data: state.data, error: undefined, isLoading: false, refresh, status: 'loaded' }; + case 'error': + return { data: state.data, error: state.error, isLoading: false, refresh, status: 'error' }; + } + }, [state, refresh]); +}