diff --git a/.changeset/clear-teams-train.md b/.changeset/clear-teams-train.md new file mode 100644 index 000000000..93c80941a --- /dev/null +++ b/.changeset/clear-teams-train.md @@ -0,0 +1,7 @@ +--- +'@solana/react': minor +--- + +Add `usePayer` and `useIdentity` React hooks. Each reads the corresponding value off the client and, when the client advertises `subscribeToPayer`/`subscribeToIdentity`, subscribes so the returned signer always reflects the latest payer/identity. Clients whose value is fixed fall back to a one-time read. + +If the plugin value throws (for example as the wallet plugin does when it owns payer/identity and a wallet is not connected), this is surfaced as `undefined` in the hooks. \ No newline at end of file diff --git a/examples/react-app/src/routes/root.tsx b/examples/react-app/src/routes/root.tsx index cfd9864a8..632ceadc0 100644 --- a/examples/react-app/src/routes/root.tsx +++ b/examples/react-app/src/routes/root.tsx @@ -28,6 +28,7 @@ function Root() { // with the rpc/subscriptions those cells read — rather than a render early. const { chain } = useClient(); const { connected, isStale } = useDisplayedWallet(); + if (!connected) { return ( diff --git a/packages/react/README.md b/packages/react/README.md index d1d08b51e..47ab888e2 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -169,6 +169,32 @@ function SendButton({ client, instructions }) { - **`usePlanTransaction(client)`** / **`usePlanTransactions(client)`** — plan a single, or multiple, transaction message(s) from an instruction input. Requires a client with transaction planning installed (`ClientWithTransactionPlanning`). `usePlanTransaction` resolves with a single transaction message; `usePlanTransactions` resolves with the full transaction plan. - **`useSendTransaction(client)`** / **`useSendTransactions(client)`** — sign, submit, and confirm a single, or multiple, transaction(s). Requires a client with transaction sending installed (`ClientWithTransactionSending`). Both accept flexible input (instructions, an instruction plan, or a transaction plan); `useSendTransaction` additionally accepts a single transaction message. +### Payer & identity + +Read the signer a client uses to pay for transactions (`payer`), or the wallet whose on-chain assets the app acts upon (`identity`), and re-render whenever it changes. Both hooks return the current `TransactionSigner`, or `undefined` while none is available. + +```tsx +import { useClient, useIdentity, usePayer } from '@solana/react'; +import type { AppClient } from './client'; + +function AccountBar() { + const client = useClient(); + const identity = useIdentity(client); + const payer = usePayer(client); + return ( +
+ {identity ? `Signed in as ${identity.address}` : 'Signed out'} + {payer ? `Paying with ${payer.address}` : 'No payer'} +
+ ); +} +``` + +- **`usePayer(client)`** — reads `client.payer`. Requires a client with a payer plugin installed (`ClientWithPayer`). +- **`useIdentity(client)`** — reads `client.identity`. Requires a client with an identity plugin installed (`ClientWithIdentity`). + +When the client also advertises `subscribeToPayer` / `subscribeToIdentity` (`ClientWithSubscribeToPayer` / `ClientWithSubscribeToIdentity`), each hook subscribes so the returned value always reflects the latest signer. For a client whose payer or identity is fixed for its lifetime, it falls back to a no-op subscription and reads the value once. + ### `useRequest(source, options?)` Fires a one-shot request on mount and re-fires whenever `source` changes identity. Returns `{ data, error, status, refresh }` where `status` is one of `'fetching' | 'success' | 'error' | 'disabled'`. Use it for RPC reads, or for any other one-shot async work an app needs (a `fetch`, a third-party SDK call, etc.). diff --git a/packages/react/src/__tests__/ClientProvider-test.browser.tsx b/packages/react/src/__tests__/ClientProvider-test.browser.tsx index 3ac99d656..2c4846392 100644 --- a/packages/react/src/__tests__/ClientProvider-test.browser.tsx +++ b/packages/react/src/__tests__/ClientProvider-test.browser.tsx @@ -109,7 +109,7 @@ describe('ClientProvider + useClient', () => { const clientPromise = Promise.reject>(boom); // Pre-attach a catch so the rejection isn't flagged as unhandled before React's // error-boundary subscription runs. - clientPromise.catch(() => { }); + clientPromise.catch(() => {}); const onError = jest.fn(); function Probe() { useClient(); diff --git a/packages/react/src/__tests__/useAction-test.browser.tsx b/packages/react/src/__tests__/useAction-test.browser.tsx index 28c74b81e..8e7bcab26 100644 --- a/packages/react/src/__tests__/useAction-test.browser.tsx +++ b/packages/react/src/__tests__/useAction-test.browser.tsx @@ -165,7 +165,10 @@ describe('useAction', () => { 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 } }); + const { result, rerender } = renderHook( + ({ value }: { value: number }) => useAction(async () => (captured = value)), + { initialProps: { value: 1 } }, + ); await act(async () => { await result.current.dispatchAsync(); diff --git a/packages/react/src/__tests__/useIdentity-test.browser.tsx b/packages/react/src/__tests__/useIdentity-test.browser.tsx new file mode 100644 index 000000000..fd5afde64 --- /dev/null +++ b/packages/react/src/__tests__/useIdentity-test.browser.tsx @@ -0,0 +1,96 @@ +import type { ClientWithIdentity, ClientWithSubscribeToIdentity, TransactionSigner } from '@solana/kit'; +import { act } from '@testing-library/react'; + +import { renderHook } from '../__test-utils__/render'; +import { useIdentity } from '../useIdentity'; + +describe('useIdentity', () => { + const signerA = { address: 'A' } as unknown as TransactionSigner; + const signerB = { address: 'B' } as unknown as TransactionSigner; + + it('returns the current identity', () => { + const client = { identity: signerA } as ClientWithIdentity; + const { result } = renderHook(() => useIdentity(client)); + expect(result.current).toBe(signerA); + }); + + it('subscribes and re-renders with the latest identity when the client is reactive', () => { + let listener: (() => void) | undefined; + const client = { + identity: signerA, + subscribeToIdentity: jest.fn(l => { + listener = l; + return () => {}; + }), + } as ClientWithIdentity & ClientWithSubscribeToIdentity; + const { result } = renderHook(() => useIdentity(client)); + + expect(result.current).toBe(signerA); + expect(client.subscribeToIdentity).toHaveBeenCalled(); + + act(() => { + client.identity = signerB; + listener!(); + }); + expect(result.current).toBe(signerB); + }); + + it('unsubscribes on unmount', () => { + const unsubscribe = jest.fn(); + const client = { + identity: signerA, + subscribeToIdentity: jest.fn(() => unsubscribe), + } as ClientWithIdentity & ClientWithSubscribeToIdentity; + const { unmount } = renderHook(() => useIdentity(client)); + + unmount(); + expect(unsubscribe).toHaveBeenCalled(); + }); + + it('returns undefined while the identity getter throws, then recovers when it becomes available', () => { + let connected = false; + let listener: (() => void) | undefined; + const client = { + get identity() { + if (!connected) { + throw new Error('No signing wallet connected'); + } + return signerA; + }, + subscribeToIdentity: jest.fn(l => { + listener = l; + return () => {}; + }), + } as ClientWithIdentity & ClientWithSubscribeToIdentity; + const { result } = renderHook(() => useIdentity(client)); + + expect(result.current).toBeUndefined(); + + act(() => { + connected = true; + listener!(); + }); + expect(result.current).toBe(signerA); + + act(() => { + connected = false; + listener!(); + }); + expect(result.current).toBeUndefined(); + }); + + it('has no subscription to react to changes when the client is not reactive, but reads the latest value on the next render', () => { + const client = { identity: signerA } as ClientWithIdentity; + const { result, rerender } = renderHook(() => useIdentity(client)); + expect(result.current).toBe(signerA); + + // Without a `subscribeToIdentity` function there is nothing to trigger a re-render on its + // own, so a bare mutation is not observed... + client.identity = signerB; + expect(result.current).toBe(signerA); + + // ...but the current value is read again on the next render. + rerender(); + expect(result.current).toBe(signerB); + }); +}); diff --git a/packages/react/src/__tests__/usePayer-test.browser.tsx b/packages/react/src/__tests__/usePayer-test.browser.tsx new file mode 100644 index 000000000..3b129d07c --- /dev/null +++ b/packages/react/src/__tests__/usePayer-test.browser.tsx @@ -0,0 +1,96 @@ +import type { ClientWithPayer, ClientWithSubscribeToPayer, TransactionSigner } from '@solana/kit'; +import { act } from '@testing-library/react'; + +import { renderHook } from '../__test-utils__/render'; +import { usePayer } from '../usePayer'; + +describe('usePayer', () => { + const signerA = { address: 'A' } as unknown as TransactionSigner; + const signerB = { address: 'B' } as unknown as TransactionSigner; + + it('returns the current payer', () => { + const client = { payer: signerA } as ClientWithPayer; + const { result } = renderHook(() => usePayer(client)); + expect(result.current).toBe(signerA); + }); + + it('subscribes and re-renders with the latest payer when the client is reactive', () => { + let listener: (() => void) | undefined; + const client = { + payer: signerA, + subscribeToPayer: jest.fn(l => { + listener = l; + return () => {}; + }), + } as ClientWithPayer & ClientWithSubscribeToPayer; + const { result } = renderHook(() => usePayer(client)); + + expect(result.current).toBe(signerA); + expect(client.subscribeToPayer).toHaveBeenCalled(); + + act(() => { + client.payer = signerB; + listener!(); + }); + expect(result.current).toBe(signerB); + }); + + it('unsubscribes on unmount', () => { + const unsubscribe = jest.fn(); + const client = { + payer: signerA, + subscribeToPayer: jest.fn(() => unsubscribe), + } as ClientWithPayer & ClientWithSubscribeToPayer; + const { unmount } = renderHook(() => usePayer(client)); + + unmount(); + expect(unsubscribe).toHaveBeenCalled(); + }); + + it('returns undefined while the payer getter throws, then recovers when it becomes available', () => { + let connected = false; + let listener: (() => void) | undefined; + const client = { + get payer() { + if (!connected) { + throw new Error('No signing wallet connected'); + } + return signerA; + }, + subscribeToPayer: jest.fn(l => { + listener = l; + return () => {}; + }), + } as ClientWithPayer & ClientWithSubscribeToPayer; + const { result } = renderHook(() => usePayer(client)); + + expect(result.current).toBeUndefined(); + + act(() => { + connected = true; + listener!(); + }); + expect(result.current).toBe(signerA); + + act(() => { + connected = false; + listener!(); + }); + expect(result.current).toBeUndefined(); + }); + + it('has no subscription to react to changes when the client is not reactive, but reads the latest value on the next render', () => { + const client = { payer: signerA } as ClientWithPayer; + const { result, rerender } = renderHook(() => usePayer(client)); + expect(result.current).toBe(signerA); + + // Without a `subscribeToPayer` function there is nothing to trigger a re-render on its + // own, so a bare mutation is not observed... + client.payer = signerB; + expect(result.current).toBe(signerA); + + // ...but the current value is read again on the next render. + rerender(); + expect(result.current).toBe(signerB); + }); +}); diff --git a/packages/react/src/__tests__/useReactiveStoreLifecycle-test.browser.tsx b/packages/react/src/__tests__/useReactiveStoreLifecycle-test.browser.tsx index 0eb1440f5..3af3b29c5 100644 --- a/packages/react/src/__tests__/useReactiveStoreLifecycle-test.browser.tsx +++ b/packages/react/src/__tests__/useReactiveStoreLifecycle-test.browser.tsx @@ -94,10 +94,7 @@ describe('useReactiveStoreLifecycle', () => { for (let i = 0; i < 40; i++) { rerender({ store: makeFakeStore() }); } - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('recreated its store'), - expect.any(Number), - ); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('recreated its store'), expect.any(Number)); }); it('does not warn when the store identity is stable across renders', () => { @@ -124,10 +121,7 @@ describe('useReactiveStoreLifecycle', () => { for (let i = 0; i < 40; i++) { rerender({ store: makeFakeStore() }); } - expect(errorSpy).not.toHaveBeenCalledWith( - expect.stringContaining('recreated its store'), - expect.anything(), - ); + expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('recreated its store'), expect.anything()); errorSpy.mockRestore(); }); }); diff --git a/packages/react/src/__tests__/useSubscription-test.browser.tsx b/packages/react/src/__tests__/useSubscription-test.browser.tsx index 8c689c523..8197f78d7 100644 --- a/packages/react/src/__tests__/useSubscription-test.browser.tsx +++ b/packages/react/src/__tests__/useSubscription-test.browser.tsx @@ -102,7 +102,7 @@ describe('useSubscription', () => { }); it('passes raw notifications through unchanged', async () => { - const sub = makeFakeSubscription<{ parent: bigint, root: bigint, slot: bigint }>(); + const sub = makeFakeSubscription<{ parent: bigint; root: bigint; slot: bigint }>(); const { result } = renderHook(() => useSubscription(sub.source)); await act(async () => await sub.publish({ parent: 9n, root: 8n, slot: 10n })); diff --git a/packages/react/src/__typetests__/useIdentity-typetest.ts b/packages/react/src/__typetests__/useIdentity-typetest.ts new file mode 100644 index 000000000..4e2b33b0f --- /dev/null +++ b/packages/react/src/__typetests__/useIdentity-typetest.ts @@ -0,0 +1,27 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import type { ClientWithIdentity, ClientWithSubscribeToIdentity, TransactionSigner } from '@solana/kit'; + +import { useIdentity } from '../useIdentity'; + +// [DESCRIBE] useIdentity +{ + // It returns the identity signer (or undefined while absent) for a static client + { + const client = {} as ClientWithIdentity; + useIdentity(client) satisfies TransactionSigner | undefined; + } + + // It accepts a reactive client that also advertises subscribeToIdentity + { + const client = {} as ClientWithIdentity & ClientWithSubscribeToIdentity; + useIdentity(client) satisfies TransactionSigner | undefined; + } + + // It rejects a client that lacks an identity + { + const client = {} as ClientWithSubscribeToIdentity; + // @ts-expect-error - client must have an identity + useIdentity(client); + } +} diff --git a/packages/react/src/__typetests__/usePayer-typetest.ts b/packages/react/src/__typetests__/usePayer-typetest.ts new file mode 100644 index 000000000..6b2b9d249 --- /dev/null +++ b/packages/react/src/__typetests__/usePayer-typetest.ts @@ -0,0 +1,27 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import type { ClientWithPayer, ClientWithSubscribeToPayer, TransactionSigner } from '@solana/kit'; + +import { usePayer } from '../usePayer'; + +// [DESCRIBE] usePayer +{ + // It returns the payer signer (or undefined while absent) for a static client + { + const client = {} as ClientWithPayer; + usePayer(client) satisfies TransactionSigner | undefined; + } + + // It accepts a reactive client that also advertises subscribeToPayer + { + const client = {} as ClientWithPayer & ClientWithSubscribeToPayer; + usePayer(client) satisfies TransactionSigner | undefined; + } + + // It rejects a client that lacks a payer + { + const client = {} as ClientWithSubscribeToPayer; + // @ts-expect-error - client must have a payer + usePayer(client); + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b4bf61d89..45c4889a4 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -7,6 +7,8 @@ export * from './ClientProvider'; export * from './useAction'; export * from './useClient'; export * from './useClientCapability'; +export * from './useIdentity'; +export * from './usePayer'; export * from './usePlanTransaction'; export * from './usePlanTransactions'; export * from './useRequest'; diff --git a/packages/react/src/query/__tests__/useRequestQuery-test.browser.tsx b/packages/react/src/query/__tests__/useRequestQuery-test.browser.tsx index 6509cd44b..88f7f231b 100644 --- a/packages/react/src/query/__tests__/useRequestQuery-test.browser.tsx +++ b/packages/react/src/query/__tests__/useRequestQuery-test.browser.tsx @@ -34,8 +34,8 @@ function createWrapper() { type DeepPartial = T extends (...args: infer A) => infer R ? (...args: A) => DeepPartial : T extends object - ? { [K in keyof T]?: DeepPartial } - : T; + ? { [K in keyof T]?: DeepPartial } + : T; // Wraps a partial `ReactiveActionStore` stub into a `ReactiveActionSource`, isolating the // type-narrowing cast to one place so spy-on-store tests stay legible. diff --git a/packages/react/src/swr/__tests__/useRequestSWR-test.browser.tsx b/packages/react/src/swr/__tests__/useRequestSWR-test.browser.tsx index 731233f91..40ef2441f 100644 --- a/packages/react/src/swr/__tests__/useRequestSWR-test.browser.tsx +++ b/packages/react/src/swr/__tests__/useRequestSWR-test.browser.tsx @@ -246,10 +246,9 @@ describe('useRequestSWR', () => { const withSignal = jest.fn(() => ({ dispatchAsync })); const source = stubActionSource({ dispatchAsync, withSignal }); const ctrl = new AbortController(); - renderHook( - () => useRequestSWR(['source-signal-identity'], source, { getAbortSignal: () => ctrl.signal }), - { wrapper }, - ); + renderHook(() => useRequestSWR(['source-signal-identity'], source, { getAbortSignal: () => ctrl.signal }), { + wrapper, + }); await waitFor(() => expect(withSignal).toHaveBeenCalledWith(ctrl.signal)); }); @@ -321,8 +320,7 @@ describe('useRequestSWR', () => { reactiveStore: () => createReactiveActionStore<[], string>(() => Promise.resolve(value)), }); const { result, rerender } = renderHook( - ({ source }: { source: ReactiveActionSource }) => - useRequestSWR(['source-latest'], source), + ({ source }: { source: ReactiveActionSource }) => useRequestSWR(['source-latest'], source), { initialProps: { source: sourceFor('a') }, wrapper }, ); await waitFor(() => expect(result.current.data).toBe('a')); diff --git a/packages/react/src/useIdentity.ts b/packages/react/src/useIdentity.ts new file mode 100644 index 000000000..ac002a8b8 --- /dev/null +++ b/packages/react/src/useIdentity.ts @@ -0,0 +1,47 @@ +import type { ClientWithIdentity, ClientWithSubscribeToIdentity, TransactionSigner } from '@solana/kit'; +import { useCallback, useSyncExternalStore } from 'react'; + +const NOOP_UNSUBSCRIBE = () => {}; + +/** + * Reads `client.identity` and re-renders whenever it changes, returning `undefined` while no + * identity is available. + * + * The identity is the {@link TransactionSigner} representing the wallet whose on-chain assets the + * application is acting upon. + * + * When the client advertises {@link ClientWithSubscribeToIdentity}, + * this hook subscribes via `client.subscribeToIdentity` so the returned value always reflects the + * latest identity. For a client whose identity is fixed for its lifetime, the hook falls back to a + * no-op subscription and simply reads the value once. + * + * @param client - A client with an identity plugin installed. If it also advertises + * `subscribeToIdentity`, the hook tracks changes reactively. + * @returns The current `client.identity` signer, or `undefined` if no identity is currently + * available. + * + * @example + * ```tsx + * const identity = useIdentity(client); + * return {identity ? `Signed in as ${identity.address}` : 'Signed out'}; + * ``` + * + * @see {@link usePayer} + */ +export function useIdentity( + client: ClientWithIdentity & Partial, +): TransactionSigner | undefined { + const subscribe = useCallback( + (onStoreChange: () => void) => + client.subscribeToIdentity ? client.subscribeToIdentity(onStoreChange) : NOOP_UNSUBSCRIBE, + [client], + ); + const getSnapshot = useCallback((): TransactionSigner | undefined => { + try { + return client.identity; + } catch { + return undefined; + } + }, [client]); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/packages/react/src/usePayer.ts b/packages/react/src/usePayer.ts new file mode 100644 index 000000000..6fefaba41 --- /dev/null +++ b/packages/react/src/usePayer.ts @@ -0,0 +1,44 @@ +import type { ClientWithPayer, ClientWithSubscribeToPayer, TransactionSigner } from '@solana/kit'; +import { useCallback, useSyncExternalStore } from 'react'; + +const NOOP_UNSUBSCRIBE = () => {}; + +/** + * Reads `client.payer` and re-renders whenever it changes, returning `undefined` while no payer is + * available. + * + * The payer is the {@link TransactionSigner} a client uses to sign and pay for transactions by + * default. + * + * When the client advertises {@link ClientWithSubscribeToPayer}, this hook subscribes via + * `client.subscribeToPayer` so the returned value always reflects the latest payer. For a client + * whose payer is fixed for its lifetime, the hook falls back to a no-op subscription and simply + * reads the value once. + * + * @param client - A client with a payer plugin installed. If it also advertises + * `subscribeToPayer`, the hook tracks changes reactively. + * @returns The current `client.payer` signer, or `undefined` if no payer is currently available. + * + * @example + * ```tsx + * const payer = usePayer(client); + * return {payer ? `Paying with ${payer.address}` : 'No payer'}; + * ``` + * + * @see {@link useIdentity} + */ +export function usePayer(client: ClientWithPayer & Partial): TransactionSigner | undefined { + const subscribe = useCallback( + (onStoreChange: () => void) => + client.subscribeToPayer ? client.subscribeToPayer(onStoreChange) : NOOP_UNSUBSCRIBE, + [client], + ); + const getSnapshot = useCallback((): TransactionSigner | undefined => { + try { + return client.payer; + } catch { + return undefined; + } + }, [client]); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}