Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/icy-loops-show.md
Original file line number Diff line number Diff line change
@@ -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 `<Suspense>` 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<TClient>()` 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<TClient>({ 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.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<StrictMode>`. 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

Expand Down
7 changes: 7 additions & 0 deletions packages/errors/src/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/errors/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
};
Expand Down
6 changes: 6 additions & 0 deletions packages/errors/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 `<ClientProvider client={client}>` 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',
Expand Down
79 changes: 79 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ClientProvider client={client}>
<Shell />
</ClientProvider>
);
}
```

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 `<Suspense>` boundary until it resolves.

```tsx
import { Suspense, useMemo } from 'react';

function Root() {
const clientPromise = useMemo(() => createClient().use(someAsyncPlugin()), []);
return (
<Suspense fallback={<Splash />}>
<ClientProvider client={clientPromise}>
<Shell />
</ClientProvider>
</Suspense>
);
}
```
Comment thread
mcintyre94 marked this conversation as resolved.

### `useClient<TClient>()`

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<ClientWithRpc<GetEpochInfoApi>>();
return <button onClick={() => client.rpc.getEpochInfo().send()}>Fetch</button>;
}
```

### `useClientCapability<TClient>(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<ClientWithRpc<GetEpochInfoApi>>({
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)`
Expand Down
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
77 changes: 77 additions & 0 deletions packages/react/src/ClientProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { Client } from '@solana/plugin-core';
import React from 'react';

import { usePromise } from './usePromise';

const ClientContext = /*#__PURE__*/ React.createContext<Client<object> | 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<object> | Promise<Client<object>>;
}>;

/**
* 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 `<Suspense>` 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 (
* <ClientProvider client={client}>
* <MyApp />
* </ClientProvider>
* );
* }
* ```
*
* @example Async client (Suspense)
* ```tsx
* const clientPromise = useMemo(
* () => createClient().use(someAsyncPlugin()),
* [],
* );
*
* <Suspense fallback={<Splash />}>
* <ClientProvider client={clientPromise}>
* <Shell />
* </ClientProvider>
* </Suspense>
* ```
*
* @see {@link useClient}
*/
export function ClientProvider({ children, client }: ClientProviderProps): React.ReactElement {
const resolved = usePromise(client);
return <ClientContext.Provider value={resolved}>{children}</ClientContext.Provider>;
}
Comment thread
mcintyre94 marked this conversation as resolved.
41 changes: 41 additions & 0 deletions packages/react/src/__test-utils__/render.tsx
Original file line number Diff line number Diff line change
@@ -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>`.
*
* 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 <StrictMode>{Inner ? <Inner>{children}</Inner> : children}</StrictMode>;
};
}

export function renderHook<TResult, TProps>(
callback: (props: TProps) => TResult,
options?: RenderHookOptions<TProps>,
): ReturnType<typeof baseRenderHook<TResult, TProps>> {
return baseRenderHook(callback, { ...options, wrapper: composeWithStrictMode(options?.wrapper) });
}

export function render(ui: ReactElement, options?: RenderOptions): ReturnType<typeof baseRender> {
return baseRender(ui, { ...options, wrapper: composeWithStrictMode(options?.wrapper) });
}
Loading
Loading