diff --git a/.changeset/icy-loops-show.md b/.changeset/icy-loops-show.md new file mode 100644 index 000000000..bbd4af1f0 --- /dev/null +++ b/.changeset/icy-loops-show.md @@ -0,0 +1,14 @@ +--- +'@solana/react': minor +'@solana/errors': minor +--- + +Add `ClientProvider`, `useClient`, and `useClientCapability` — the Kit client context layer for React. + +`ClientProvider` publishes a caller-owned Kit client to its subtree. Required by `useClient`, `useClientCapability`, and any plugin-specific hook that depends on a client capability — generic primitives like `useAction` work against arbitrary async functions and don't need a provider. The provider accepts both synchronous clients and promise-returning ones — when given a promise (e.g. `createClient().use(asyncPlugin())`), it suspends via the nearest `` boundary until the client resolves. On React 19 it delegates to `React.use(promise)`; on React 18 an internal thrown-promise shim, keyed by promise identity, honours the same contract. + +`useClient()` is the basic context accessor. Defaults to the base `Client` shape; callers who know a specific plugin is installed may widen the type via the generic. Throws a new `SolanaError` with code `SOLANA_ERROR__REACT__MISSING_PROVIDER` when called outside a provider. + +`useClientCapability({ capability, hookName, providerHint })` runtime-checks that the requested capability (or capabilities) is installed on the client and throws `SOLANA_ERROR__REACT__MISSING_CAPABILITY` — surfacing the calling `hookName` and a `providerHint` — when it isn't. Plugin-hook authors use this to fail loudly at mount instead of letting a missing plugin surface later as `undefined`. + +Two new error codes (`SOLANA_ERROR__REACT__MISSING_PROVIDER`, `SOLANA_ERROR__REACT__MISSING_CAPABILITY`) are reserved in the `[9000000-9000999]` range. diff --git a/CLAUDE.md b/CLAUDE.md index 61301dbbd..27147dda6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,7 @@ Four private "impl" packages (`@solana/crypto-impl`, `@solana/text-encoding-impl - **`expect.assertions`**: Only use `expect.assertions(n)` in **async** tests (where you need to guarantee the expected number of assertions ran). Synchronous tests do not need it. - **Flushing async state**: When a test needs to wait for queued microtasks or promise chains to settle, prefer `jest.useFakeTimers()` + `await jest.runAllTimersAsync()` over hand-rolled `flushMicrotasks` helpers that `await Promise.resolve()` in a loop. The loop count is fragile and breaks as soon as an extra `.then` is introduced. When enabling fake timers in a scoped `beforeEach` (i.e. not at the top of the file), pair it with an `afterEach(() => { jest.useRealTimers(); })` so subsequent describes don't inherit fake timers. - **Placeholder mocks**: When a test mock must satisfy an interface but a particular method shouldn't be called in that test, make the stub throw/reject rather than using a bare `jest.fn()` that silently returns `undefined`. For sync methods use `jest.fn().mockImplementation(() => { throw new Error('not implemented'); })`; for async methods use `jest.fn().mockRejectedValue(new Error('not implemented'))`. An accidental call then fails the test loudly instead of producing `undefined` and a confusing downstream assertion error. +- **React hook tests**: Use `renderHook` (and `render`) from `packages/react/src/__test-utils__/render.tsx` — these wrap every tree in ``. Do NOT import `renderHook` / `render` directly from `@testing-library/react` for new tests in the `@solana/react` package. StrictMode's dev double-render surfaces render-phase impurity (side effects in `useMemo` / state initializers, missing effect cleanups, refs read during render) that would otherwise only manifest in real apps. When effect setups legitimately double under StrictMode, assert on end-state (signal aborted, store reset to idle) rather than raw call counts. ## Error System diff --git a/packages/errors/src/codes.ts b/packages/errors/src/codes.ts index cbb5c7610..09b9477b0 100644 --- a/packages/errors/src/codes.ts +++ b/packages/errors/src/codes.ts @@ -394,6 +394,11 @@ export const SOLANA_ERROR__WALLET__NOT_CONNECTED = 8900000; export const SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED = 8900001; export const SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE = 8900002; +// React-binding errors. +// Reserve error codes in the range [9000000-9000999]. +export const SOLANA_ERROR__REACT__MISSING_PROVIDER = 9000000; +export const SOLANA_ERROR__REACT__MISSING_CAPABILITY = 9000001; + // Invariant violation errors. // Reserve error codes in the range [9900000-9900999]. // These errors should only be thrown when there is a bug with the @@ -623,6 +628,8 @@ export type SolanaErrorCode = | typeof SOLANA_ERROR__PROGRAM_CLIENTS__UNEXPECTED_RESOLVED_INSTRUCTION_INPUT_TYPE | typeof SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_ACCOUNT_TYPE | typeof SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_INSTRUCTION_TYPE + | typeof SOLANA_ERROR__REACT__MISSING_CAPABILITY + | typeof SOLANA_ERROR__REACT__MISSING_PROVIDER | typeof SOLANA_ERROR__RPC__API_PLAN_MISSING_FOR_RPC_METHOD | typeof SOLANA_ERROR__RPC__INTEGER_OVERFLOW | typeof SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR diff --git a/packages/errors/src/context.ts b/packages/errors/src/context.ts index 67bafec40..a2cd84a49 100644 --- a/packages/errors/src/context.ts +++ b/packages/errors/src/context.ts @@ -177,6 +177,8 @@ import { SOLANA_ERROR__PROGRAM_CLIENTS__UNEXPECTED_RESOLVED_INSTRUCTION_INPUT_TYPE, SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_ACCOUNT_TYPE, SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_INSTRUCTION_TYPE, + SOLANA_ERROR__REACT__MISSING_CAPABILITY, + SOLANA_ERROR__REACT__MISSING_PROVIDER, SOLANA_ERROR__RPC__API_PLAN_MISSING_FOR_RPC_METHOD, SOLANA_ERROR__RPC__INTEGER_OVERFLOW, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, @@ -784,6 +786,14 @@ export type SolanaErrorContext = ReadonlyContextValue< instructionType: number | string; programName: string; }; + [SOLANA_ERROR__REACT__MISSING_CAPABILITY]: { + capabilities: readonly string[]; + hookName: string; + providerHint: string; + }; + [SOLANA_ERROR__REACT__MISSING_PROVIDER]: { + hookName: string; + }; [SOLANA_ERROR__RPC_SUBSCRIPTIONS__CANNOT_CREATE_SUBSCRIPTION_PLAN]: { notificationName: string; }; diff --git a/packages/errors/src/messages.ts b/packages/errors/src/messages.ts index dd9c925ed..98e6c4ced 100644 --- a/packages/errors/src/messages.ts +++ b/packages/errors/src/messages.ts @@ -207,6 +207,8 @@ import { SOLANA_ERROR__PROGRAM_CLIENTS__UNEXPECTED_RESOLVED_INSTRUCTION_INPUT_TYPE, SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_ACCOUNT_TYPE, SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_INSTRUCTION_TYPE, + SOLANA_ERROR__REACT__MISSING_CAPABILITY, + SOLANA_ERROR__REACT__MISSING_PROVIDER, SOLANA_ERROR__RPC__API_PLAN_MISSING_FOR_RPC_METHOD, SOLANA_ERROR__RPC__INTEGER_OVERFLOW, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, @@ -866,6 +868,10 @@ export const SolanaErrorMessages: Readonly<{ 'Transaction has $actualCount instructions but the maximum allowed is $maxAllowed', [SOLANA_ERROR__TRANSACTION__TOO_MANY_ACCOUNTS_IN_INSTRUCTION]: 'The instruction at index $instructionIndex has $actualCount account references but the maximum allowed is $maxAllowed', + [SOLANA_ERROR__REACT__MISSING_CAPABILITY]: + '`$hookName` requires the following capabilities to be installed on the client: [$capabilities]. $providerHint', + [SOLANA_ERROR__REACT__MISSING_PROVIDER]: + '`$hookName` was called outside of a `ClientProvider`. Mount a `` in the ancestor tree.', [SOLANA_ERROR__WALLET__NOT_CONNECTED]: 'Cannot $operation: no wallet connected', [SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED]: 'No signing wallet connected (status: $status)', [SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE]: 'Connected wallet does not support signing', diff --git a/packages/react/README.md b/packages/react/README.md index 5ae00298c..e51234764 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -13,6 +13,85 @@ This package contains React hooks for building Solana apps. +## Kit client bindings + +The Kit client is a plugin-extensible value built outside the React tree (`createClient().use(...)`) and published to descendants by `ClientProvider`. The hooks in this section connect the React tree to that client. Higher-level hooks (live data, RPC requests, wallet actions) sit on top of these and ship from each Kit plugin's `/react` subpath. + +### `ClientProvider` + +Publishes a caller-owned Kit client to its subtree. Required for `useClient`, `useClientCapability`, and any plugin-specific hook that depends on a client capability. Generic primitives like `useAction` work against arbitrary async functions and don't need a provider. + +```tsx +import { createClient } from '@solana/kit'; +import { ClientProvider } from '@solana/react'; + +const client = createClient(); // .use(...) plugins as needed + +function App() { + return ( + + + + ); +} +``` + +The `client` reference must be stable across renders — build it at module scope, or memoise it with `useMemo` when its config is reactive (e.g. a cluster toggle). + +When a plugin's `.use()` is async, `createClient().use(...)` returns a promise. Pass it directly; the provider suspends via the nearest `` boundary until it resolves. + +```tsx +import { Suspense, useMemo } from 'react'; + +function Root() { + const clientPromise = useMemo(() => createClient().use(someAsyncPlugin()), []); + return ( + }> + + + + + ); +} +``` + +### `useClient()` + +Reads the Kit client published by the nearest `ClientProvider`. Throws a `SolanaError` with code `SOLANA_ERROR__REACT__MISSING_PROVIDER` if no provider is mounted. + +Defaults to the base `Client` shape. Callers who know a specific plugin is installed may widen the type via the generic — this is a pure cast with no runtime check, so reach for `useClientCapability` when a missing plugin should fail loudly at mount instead of surfacing later as `undefined`. + +```tsx +import { ClientWithRpc, GetEpochInfoApi } from '@solana/kit'; +import { useClient } from '@solana/react'; + +function ManualSend() { + const client = useClient>(); + return ; +} +``` + +### `useClientCapability(config)` + +Reads the client and asserts at mount that the requested capability is installed, narrowing the return type via the generic. Throws a `SolanaError` with code `SOLANA_ERROR__REACT__MISSING_CAPABILITY` when the capability is absent — including `hookName` and `providerHint` so users can fix the mistake without cross-referencing docs. + +Use this from the implementation of plugin-specific hooks. Apps that need ad-hoc access can reach for `useClient` directly and supply their own narrowing. + +```tsx +import { ClientWithRpc, GetEpochInfoApi } from '@solana/kit'; +import { useClientCapability } from '@solana/react'; + +function useRpc() { + return useClientCapability>({ + capability: 'rpc', + hookName: 'useRpc', + providerHint: 'Install `solanaRpc()` on the client.', + }); +} +``` + +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. + ## Hooks ### `useSignIn(uiWalletAccount, chain)` diff --git a/packages/react/package.json b/packages/react/package.json index 4a7682f84..a75862917 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -77,6 +77,7 @@ "@solana/addresses": "workspace:*", "@solana/errors": "workspace:*", "@solana/keys": "workspace:*", + "@solana/plugin-core": "workspace:*", "@solana/promises": "workspace:*", "@solana/signers": "workspace:*", "@solana/transaction-messages": "workspace:*", diff --git a/packages/react/src/ClientProvider.tsx b/packages/react/src/ClientProvider.tsx new file mode 100644 index 000000000..29eb91073 --- /dev/null +++ b/packages/react/src/ClientProvider.tsx @@ -0,0 +1,77 @@ +import type { Client } from '@solana/plugin-core'; +import React from 'react'; + +import { usePromise } from './usePromise'; + +const ClientContext = /*#__PURE__*/ React.createContext | null>(null); + +/** + * The React context that holds the Kit client published by the nearest {@link ClientProvider}. + * Exported for advanced cases such as third-party providers that wrap and extend the client; most + * consumers should reach for {@link useClient} or one of the higher-level hooks instead. + */ +export { ClientContext }; + +/** + * Props accepted by {@link ClientProvider}. + */ +export type ClientProviderProps = Readonly<{ + children?: React.ReactNode; + /** + * The Kit client to publish to descendants, or a promise resolving to one (e.g. when the + * client has async plugins). The reference must be stable across renders — build it at + * module scope or memoise it with `useMemo` when its config is reactive. + */ + client: Client | Promise>; +}>; + +/** + * Publishes a caller-owned Kit client to its subtree. Required for `useClient`, + * `useClientCapability`, and any plugin-specific hook that depends on a client capability. + * + * Plugin composition belongs in plain Kit — the provider does no composition, lifecycle + * management, or disposal; it is a value channel, not a lifecycle channel. When config changes at + * runtime (e.g. cluster toggle), rebuild the client in `useMemo` and pass the new reference; the + * subtree resubscribes against the new client identity. + * + * Async client support: when `client` is a promise (e.g. `createClient().use(asyncPlugin())`), + * the provider suspends the subtree via the nearest `` boundary until the promise + * resolves. On React 19 this delegates to `React.use(promise)`; on React 18 a thrown-promise shim + * keyed by promise identity preserves the same contract. + * + * @example Sync client + * ```tsx + * import { createClient } from '@solana/kit'; + * import { ClientProvider } from '@solana/react'; + * + * const client = createClient(); // .use(...) plugins as needed + * + * function App() { + * return ( + * + * + * + * ); + * } + * ``` + * + * @example Async client (Suspense) + * ```tsx + * const clientPromise = useMemo( + * () => createClient().use(someAsyncPlugin()), + * [], + * ); + * + * }> + * + * + * + * + * ``` + * + * @see {@link useClient} + */ +export function ClientProvider({ children, client }: ClientProviderProps): React.ReactElement { + const resolved = usePromise(client); + return {children}; +} diff --git a/packages/react/src/__test-utils__/render.tsx b/packages/react/src/__test-utils__/render.tsx new file mode 100644 index 000000000..a820fd5a2 --- /dev/null +++ b/packages/react/src/__test-utils__/render.tsx @@ -0,0 +1,41 @@ +import React, { ComponentType, ReactElement, ReactNode, StrictMode } from 'react'; +import { + render as baseRender, + renderHook as baseRenderHook, + RenderHookOptions, + RenderOptions, +} from '@testing-library/react'; + +/** + * Shared test renderers that wrap every React tree in ``. + * + * StrictMode's dev double-render surfaces render-phase impurity (side effects in `useMemo` or + * `useState` initializers, missing effect cleanups, refs read during render) that would + * otherwise only manifest in real apps. Using these helpers across all React hook / component + * tests catches that class of bug at test time. + * + * Composes with caller-supplied wrappers: `renderHook(() => useFoo(), { wrapper: Provider })` + * still works — the `Provider` is rendered inside `StrictMode`. + * + * Re-export from this module rather than `@testing-library/react` directly so the StrictMode + * wrap is automatic. + */ + +function composeWithStrictMode( + Inner: ComponentType<{ children: ReactNode }> | undefined, +): ComponentType<{ children: ReactNode }> { + return function StrictModeWrapper({ children }) { + return {Inner ? {children} : children}; + }; +} + +export function renderHook( + callback: (props: TProps) => TResult, + options?: RenderHookOptions, +): ReturnType> { + return baseRenderHook(callback, { ...options, wrapper: composeWithStrictMode(options?.wrapper) }); +} + +export function render(ui: ReactElement, options?: RenderOptions): ReturnType { + return baseRender(ui, { ...options, wrapper: composeWithStrictMode(options?.wrapper) }); +} diff --git a/packages/react/src/__tests__/ClientProvider-test.browser.tsx b/packages/react/src/__tests__/ClientProvider-test.browser.tsx new file mode 100644 index 000000000..504d72c8d --- /dev/null +++ b/packages/react/src/__tests__/ClientProvider-test.browser.tsx @@ -0,0 +1,142 @@ +import { isSolanaError, SOLANA_ERROR__REACT__MISSING_PROVIDER } from '@solana/errors'; +import { Client, createClient } from '@solana/plugin-core'; +import { act } from '@testing-library/react'; +import React, { Suspense } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; + +import { ClientProvider } from '../ClientProvider'; +import { useClient } from '../useClient'; +import { render, renderHook } from '../__test-utils__/render'; + +describe('ClientProvider + useClient', () => { + it('publishes the client to descendants and returns the same reference across renders', () => { + const client = createClient(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const { result, rerender } = renderHook(() => useClient(), { wrapper }); + expect(result.current).toBe(client); + rerender(); + expect(result.current).toBe(client); + }); + + it('throws SolanaError MISSING_PROVIDER when `useClient` is called outside a provider', () => { + const { result } = renderHook(() => { + try { + return useClient(); + } catch (err) { + return err; + } + }); + expect(isSolanaError(result.current, SOLANA_ERROR__REACT__MISSING_PROVIDER)).toBe(true); + expect((result.current as { context: { hookName: string } }).context.hookName).toBe('useClient'); + }); + + it('lets the nearest provider win for nested mounts', () => { + const outer = createClient(); + const inner = createClient(); + // Separate spies per probe — StrictMode renders each probe twice, so a single shared spy + // can't be ordered (we'd see outer, outer, inner, inner or interleaved). Per-probe spies + // let us assert each probe only ever saw its own provider's client. + const onRenderOuter = jest.fn(); + const onRenderInner = jest.fn(); + function OuterProbe() { + onRenderOuter(useClient()); + return null; + } + function InnerProbe() { + onRenderInner(useClient()); + return null; + } + render( + + + + + + , + ); + expect(onRenderOuter).toHaveBeenCalledWith(outer); + expect(onRenderOuter).not.toHaveBeenCalledWith(inner); + expect(onRenderInner).toHaveBeenCalledWith(inner); + expect(onRenderInner).not.toHaveBeenCalledWith(outer); + }); + + describe('async client', () => { + it('renders the children once the client promise has resolved', async () => { + const client = createClient(); + const clientPromise = Promise.resolve(client); + const onRender = jest.fn(); + function Probe() { + onRender(useClient()); + return
ready
; + } + let queryByTestId!: ReturnType['queryByTestId']; + await act(async () => { + ({ queryByTestId } = render( + loading}> + + + + , + )); + }); + expect(queryByTestId('fallback')).toBeNull(); + expect(queryByTestId('probe')).not.toBeNull(); + expect(onRender).toHaveBeenLastCalledWith(client); + }); + + it('suspends while the promise is pending', () => { + const clientPromise = new Promise>(() => { + /* never resolves */ + }); + function Probe() { + useClient(); + return
ready
; + } + const { queryByTestId } = render( + loading}> + + + + , + ); + expect(queryByTestId('fallback')).not.toBeNull(); + expect(queryByTestId('probe')).toBeNull(); + }); + + it('lets a rejected client promise propagate to the nearest error boundary', async () => { + const boom = new Error('boom'); + 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(() => {}); + const onError = jest.fn(); + function Probe() { + useClient(); + return
ready
; + } + let queryByTestId!: ReturnType['queryByTestId']; + await act(async () => { + ({ queryByTestId } = render( + { + onError(error); + return
{(error as Error).message}
; + }} + > + loading}> + + + + +
, + )); + }); + expect(queryByTestId('caught')).not.toBeNull(); + expect(queryByTestId('caught')!.textContent).toBe('boom'); + expect(queryByTestId('probe')).toBeNull(); + expect(onError).toHaveBeenCalledWith(boom); + }); + }); +}); diff --git a/packages/react/src/__tests__/useClientCapability-test.browser.tsx b/packages/react/src/__tests__/useClientCapability-test.browser.tsx new file mode 100644 index 000000000..d131dd5f8 --- /dev/null +++ b/packages/react/src/__tests__/useClientCapability-test.browser.tsx @@ -0,0 +1,115 @@ +import { + isSolanaError, + SOLANA_ERROR__REACT__MISSING_CAPABILITY, + SOLANA_ERROR__REACT__MISSING_PROVIDER, +} from '@solana/errors'; +import { createClient } from '@solana/plugin-core'; +import React from 'react'; + +import { ClientProvider } from '../ClientProvider'; +import { useClient } from '../useClient'; +import { useClientCapability } from '../useClientCapability'; +import { renderHook } from '../__test-utils__/render'; + +type ClientWithFoo = { foo: { hello(): string } }; + +function wrapperFor(client: ReturnType>) { + return ({ children }: { children: React.ReactNode }) => {children}; +} + +describe('useClientCapability', () => { + it('returns the client when the capability is present', () => { + const client = createClient({ foo: { hello: () => 'world' } }); + const { result } = renderHook( + () => + useClientCapability({ + capability: 'foo', + hookName: 'useFoo', + providerHint: 'Install fooPlugin().', + }), + { wrapper: wrapperFor(client) }, + ); + expect(result.current).toBe(client); + }); + + it('throws MISSING_CAPABILITY with hookName + providerHint when the capability is absent', () => { + const client = createClient(); // no `foo` capability + const { result } = renderHook( + () => { + try { + return useClientCapability({ + capability: 'foo', + hookName: 'useFoo', + providerHint: 'Install fooPlugin().', + }); + } catch (err) { + return err; + } + }, + { wrapper: wrapperFor(client) }, + ); + expect(isSolanaError(result.current, SOLANA_ERROR__REACT__MISSING_CAPABILITY)).toBe(true); + const ctx = ( + result.current as { context: { capabilities: readonly string[]; hookName: string; providerHint: string } } + ).context; + expect(ctx.capabilities).toEqual(['foo']); + expect(ctx.hookName).toBe('useFoo'); + expect(ctx.providerHint).toBe('Install fooPlugin().'); + }); + + it('reports only the missing entries when capability is an array', () => { + const client = createClient<{ rpc: object }>({ rpc: {} }); // missing rpcSubscriptions only + const { result } = renderHook( + () => { + try { + return useClientCapability<{ rpc: object; rpcSubscriptions: object }>({ + capability: ['rpc', 'rpcSubscriptions'], + hookName: 'useLiveData', + providerHint: 'Install solanaRpcConnection().', + }); + } catch (err) { + return err; + } + }, + { wrapper: wrapperFor(client) }, + ); + expect(isSolanaError(result.current, SOLANA_ERROR__REACT__MISSING_CAPABILITY)).toBe(true); + expect((result.current as { context: { capabilities: readonly string[] } }).context.capabilities).toEqual([ + 'rpcSubscriptions', + ]); + }); + + it('reports every missing entry when several capabilities are absent', () => { + const client = createClient<{ rpc: object }>({ rpc: {} }); // missing rpcSubscriptions and wallet + const { result } = renderHook( + () => { + try { + return useClientCapability<{ rpc: object; rpcSubscriptions: object; wallet: object }>({ + capability: ['rpc', 'rpcSubscriptions', 'wallet'], + hookName: 'useEverything', + providerHint: 'Install the missing plugins.', + }); + } catch (err) { + return err; + } + }, + { wrapper: wrapperFor(client) }, + ); + expect(isSolanaError(result.current, SOLANA_ERROR__REACT__MISSING_CAPABILITY)).toBe(true); + expect((result.current as { context: { capabilities: readonly string[] } }).context.capabilities).toEqual([ + 'rpcSubscriptions', + 'wallet', + ]); + }); + + it('underlying useClient throws MISSING_PROVIDER outside a provider', () => { + const { result } = renderHook(() => { + try { + return useClient(); + } catch (err) { + return err; + } + }); + expect(isSolanaError(result.current, SOLANA_ERROR__REACT__MISSING_PROVIDER)).toBe(true); + }); +}); diff --git a/packages/react/src/__typetests__/useClient-typetest.ts b/packages/react/src/__typetests__/useClient-typetest.ts new file mode 100644 index 000000000..cc941e7f3 --- /dev/null +++ b/packages/react/src/__typetests__/useClient-typetest.ts @@ -0,0 +1,33 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import type { Client } from '@solana/plugin-core'; + +import { useClient } from '../useClient'; + +type ClientWithFoo = { foo: { hello(): string } }; + +// [DESCRIBE] useClient +{ + // It defaults to `Client` + { + const client = useClient(); + client satisfies Client; + // @ts-expect-error - the base shape carries no plugin capabilities + void client.foo; + } + + // It narrows to the requested shape via the generic + { + const client = useClient(); + client satisfies Client; + client.foo.hello() satisfies string; + // @ts-expect-error - capability not declared in the generic + void client.bar; + } + + // The narrowed client retains the `use` method from `Client` + { + const client = useClient(); + client.use satisfies Client['use']; + } +} diff --git a/packages/react/src/__typetests__/useClientCapability-typetest.ts b/packages/react/src/__typetests__/useClientCapability-typetest.ts new file mode 100644 index 000000000..0935332f3 --- /dev/null +++ b/packages/react/src/__typetests__/useClientCapability-typetest.ts @@ -0,0 +1,50 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import type { Client } from '@solana/plugin-core'; + +import { useClientCapability } from '../useClientCapability'; + +type ClientWithRpc = { rpc: { getEpoch(): bigint } }; +type ClientWithRpcAndSubs = ClientWithRpc & { rpcSubscriptions: { slotChanges(): void } }; + +// [DESCRIBE] useClientCapability +{ + // It narrows the return type via the generic + { + const client = useClientCapability({ + capability: 'rpc', + hookName: 'useRpc', + providerHint: 'Install rpcPlugin().', + }); + client satisfies Client; + client.rpc.getEpoch() satisfies bigint; + // @ts-expect-error - capability not declared in the generic + void client.rpcSubscriptions; + } + + // The `capability` config field accepts a single name + { + useClientCapability({ + capability: 'rpc', + hookName: 'useRpc', + providerHint: 'Install rpcPlugin().', + }); + } + + // The `capability` config field accepts an array of names + { + useClientCapability({ + capability: ['rpc', 'rpcSubscriptions'], + hookName: 'useLiveData', + providerHint: 'Install solanaRpcConnection().', + }); + } + + // It rejects configs missing required fields + { + useClientCapability( + // @ts-expect-error - missing hookName + providerHint + { capability: 'rpc' }, + ); + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 1125a2f7a..ee2d7047c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -3,6 +3,9 @@ * * @packageDocumentation */ +export * from './ClientProvider'; +export * from './useClient'; +export * from './useClientCapability'; export * from './useSignAndSendTransaction'; export * from './useSignIn'; export * from './useSignMessage'; diff --git a/packages/react/src/useClient.ts b/packages/react/src/useClient.ts new file mode 100644 index 000000000..dd3785788 --- /dev/null +++ b/packages/react/src/useClient.ts @@ -0,0 +1,39 @@ +import { SOLANA_ERROR__REACT__MISSING_PROVIDER, SolanaError } from '@solana/errors'; +import type { Client } from '@solana/plugin-core'; +import React from 'react'; + +import { ClientContext } from './ClientProvider'; + +/** + * Reads the Kit client published by the nearest {@link ClientProvider}. Throws a + * {@link SolanaError} with code {@link SOLANA_ERROR__REACT__MISSING_PROVIDER} if no provider is + * mounted in the ancestor tree. + * + * Defaults to the base {@link Client} shape. Callers who know a specific plugin is installed may + * widen the type via the generic — this is a pure cast with no runtime capability check, so reach + * for {@link useClientCapability} when a missing plugin should fail loudly at mount instead of + * surfacing later as `undefined`. + * + * @typeParam TClient - The shape the client is expected to satisfy. Pure type assertion. + * + * @example + * ```tsx + * import { ClientWithRpc, GetEpochInfoApi } from '@solana/kit'; + * import { useClient } from '@solana/react'; + * + * function ManualSend() { + * const client = useClient>(); + * return ; + * } + * ``` + * + * @see {@link ClientProvider} + * @see {@link useClientCapability} + */ +export function useClient(): Client { + const client = React.useContext(ClientContext); + if (client == null) { + throw new SolanaError(SOLANA_ERROR__REACT__MISSING_PROVIDER, { hookName: 'useClient' }); + } + return client as Client; +} diff --git a/packages/react/src/useClientCapability.ts b/packages/react/src/useClientCapability.ts new file mode 100644 index 000000000..43ab14ddf --- /dev/null +++ b/packages/react/src/useClientCapability.ts @@ -0,0 +1,75 @@ +import { SOLANA_ERROR__REACT__MISSING_CAPABILITY, SolanaError } from '@solana/errors'; +import type { Client } from '@solana/plugin-core'; + +import { useClient } from './useClient'; + +/** + * Configuration for {@link useClientCapability}. + */ +export type UseClientCapabilityConfig = Readonly<{ + /** + * The capability name (or names) the hook depends on. Each is checked against the client with + * a runtime `in` test before the narrowed value is returned. Pass an array when the hook + * needs multiple capabilities (e.g. `['rpc', 'rpcSubscriptions']`); the same `providerHint` is + * used for any that's missing. + */ + capability: string | readonly string[]; + /** + * Name of the calling hook, surfaced in the missing-capability error so users can locate the + * call site quickly. + */ + hookName: string; + /** + * Free-form actionable hint shown alongside the error — usually a one-liner naming the plugin + * (or family of plugins) the user should install. + */ + providerHint: string; +}>; + +/** + * Reads the client from the nearest {@link ClientProvider} and asserts at mount that the + * requested capability is installed, narrowing the return type via the generic. Throws a + * {@link SolanaError} with code {@link SOLANA_ERROR__REACT__MISSING_CAPABILITY} when the + * capability is absent — including the calling `hookName` and a `providerHint` so users can fix + * the mistake without cross-referencing docs. + * + * Use this from the implementation of plugin-specific hooks. Apps that need ad-hoc access without + * a runtime check can reach for {@link useClient} directly and supply their own type narrowing. + * + * @typeParam TClient - The narrowed client shape returned once the capability assertion passes. + * Always pass this generic — the hook can't infer it from a string. + * + * @example + * ```ts + * import { ClientWithRpc, GetEpochInfoApi } from '@solana/kit'; + * import { useClientCapability } from '@solana/react'; + * + * function useRpc() { + * return useClientCapability>({ + * capability: 'rpc', + * hookName: 'useRpc', + * providerHint: 'Install `solanaRpc()` on the client.', + * }); + * } + * ``` + * + * @see {@link useClient} + * @see {@link ClientProvider} + */ +export function useClientCapability({ + capability, + hookName, + providerHint, +}: UseClientCapabilityConfig): Client { + const client = useClient(); + const required = typeof capability === 'string' ? [capability] : capability; + const missing = required.filter(name => !Object.hasOwn(client, name)); + if (missing.length > 0) { + throw new SolanaError(SOLANA_ERROR__REACT__MISSING_CAPABILITY, { + capabilities: missing, + hookName, + providerHint, + }); + } + return client; +} diff --git a/packages/react/src/usePromise.ts b/packages/react/src/usePromise.ts new file mode 100644 index 000000000..bdc52dd64 --- /dev/null +++ b/packages/react/src/usePromise.ts @@ -0,0 +1,69 @@ +import React from 'react'; + +type CacheEntry = + | Readonly<{ promise: PromiseLike; status: 'pending' }> + | Readonly<{ reason: unknown; status: 'rejected' }> + | Readonly<{ status: 'fulfilled'; value: T }>; + +const cache = new WeakMap, CacheEntry>(); + +function trackedPromise(promise: PromiseLike): PromiseLike { + // Returns the *chained* promise (not the original). React subscribes to the thrown value to + // know when to retry; throwing the chained one means retry runs strictly after our + // cache-update handler, so the next render is guaranteed to see the settled entry. + return promise.then( + value => { + cache.set(promise, { status: 'fulfilled', value }); + }, + reason => { + cache.set(promise, { reason, status: 'rejected' }); + }, + ); +} + +/** + * React 18 fallback for `React.use(promise)` — suspends by throwing the promise on first render, + * returns the resolved value on subsequent renders, and re-throws the rejection if the promise + * settles with an error. + * + * The promise identity must be stable across renders so the cache keyed off it can find the + * settled entry on the second render. The {@link ClientProvider}'s consumer-facing contract + * documents this — pass a memoised or module-scope promise. + */ +function usePromiseShim(promise: PromiseLike): T { + let entry = cache.get(promise) as CacheEntry | undefined; + if (entry == null) { + const tracked = trackedPromise(promise); + entry = { promise: tracked, status: 'pending' }; + cache.set(promise, entry); + } + if (entry.status === 'pending') throw entry.promise; + if (entry.status === 'rejected') throw entry.reason; + return entry.value; +} + +type ReactWithUse = typeof React & { + use?: (promise: PromiseLike) => T; +}; + +function isPromiseLike(value: PromiseLike | T): value is PromiseLike { + return ( + !!value && + (typeof value === 'object' || typeof value === 'function') && + typeof (value as PromiseLike).then === 'function' + ); +} + +/** + * Returns `value` directly if it is a plain value, or unwraps it (suspending the subtree until + * it settles) if it is a {@link PromiseLike}. Designed to be invoked unconditionally so consumers + * don't have to gate on the runtime shape of the input — that keeps the call site + * rules-of-hooks-friendly even when the same provider sees both sync and async clients. + * + * The `React.use` lookup is deferred to call time so the module has no top-level side effects + * (agadoo's tree-shake check fails otherwise on the property-access expression). + */ +export function usePromise(value: PromiseLike | T): T { + if (!isPromiseLike(value)) return value; + return ((React as ReactWithUse).use ?? usePromiseShim)(value); +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 72f5bc5fc..c2ff6ce97 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", "ESNext.Promise"] + "lib": ["DOM", "ES2015", "ES2022.Object", "ESNext.Promise"] }, "display": "@solana/react", "extends": "../tsconfig/base.json", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a51d3fcd..c2befcb25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -953,6 +953,9 @@ importers: '@solana/keys': specifier: workspace:* version: link:../keys + '@solana/plugin-core': + specifier: workspace:* + version: link:../plugin-core '@solana/promises': specifier: workspace:* version: link:../promises