Skip to content

Commit d073150

Browse files
committed
Add ClientProvider, useClient, and useClientCapability
Adds the Kit client context layer for `@solana/react`: a single provider that publishes a caller-owned Kit client to its subtree, plus two hooks — `useClient` (basic accessor with optional generic narrowing) and `useClientCapability` (runtime-checked accessor with a structured missing-capability error). Required by these hooks and by any plugin-specific hook that depends on a client capability; generic primitives like `useAction` (forthcoming) 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. Two new `SolanaError` codes are reserved in the `[9000000-9000999]` range: `SOLANA_ERROR__REACT__MISSING_PROVIDER` (thrown by `useClient` outside a provider) and `SOLANA_ERROR__REACT__MISSING_CAPABILITY` (thrown by `useClientCapability` with `hookName` + `providerHint` context, listing every missing capability when an array is passed). Additive; no impact on the existing wallet-account hooks. Verified with browser + node unit tests and TS-only typetests across both `@solana/react` and `@solana/errors`.
1 parent da868aa commit d073150

17 files changed

Lines changed: 713 additions & 1 deletion

.changeset/icy-loops-show.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@solana/react': minor
3+
'@solana/errors': minor
4+
---
5+
6+
Add `ClientProvider`, `useClient`, and `useClientCapability` — the Kit client context layer for React.
7+
8+
`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.
9+
10+
`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.
11+
12+
`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`.
13+
14+
Two new error codes (`SOLANA_ERROR__REACT__MISSING_PROVIDER`, `SOLANA_ERROR__REACT__MISSING_CAPABILITY`) are reserved in the `[9000000-9000999]` range.

packages/errors/src/codes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,11 @@ export const SOLANA_ERROR__WALLET__NOT_CONNECTED = 8900000;
394394
export const SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED = 8900001;
395395
export const SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE = 8900002;
396396

397+
// React-binding errors.
398+
// Reserve error codes in the range [9000000-9000999].
399+
export const SOLANA_ERROR__REACT__MISSING_PROVIDER = 9000000;
400+
export const SOLANA_ERROR__REACT__MISSING_CAPABILITY = 9000001;
401+
397402
// Invariant violation errors.
398403
// Reserve error codes in the range [9900000-9900999].
399404
// These errors should only be thrown when there is a bug with the
@@ -623,6 +628,8 @@ export type SolanaErrorCode =
623628
| typeof SOLANA_ERROR__PROGRAM_CLIENTS__UNEXPECTED_RESOLVED_INSTRUCTION_INPUT_TYPE
624629
| typeof SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_ACCOUNT_TYPE
625630
| typeof SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_INSTRUCTION_TYPE
631+
| typeof SOLANA_ERROR__REACT__MISSING_CAPABILITY
632+
| typeof SOLANA_ERROR__REACT__MISSING_PROVIDER
626633
| typeof SOLANA_ERROR__RPC__API_PLAN_MISSING_FOR_RPC_METHOD
627634
| typeof SOLANA_ERROR__RPC__INTEGER_OVERFLOW
628635
| typeof SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR

packages/errors/src/context.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ import {
177177
SOLANA_ERROR__PROGRAM_CLIENTS__UNEXPECTED_RESOLVED_INSTRUCTION_INPUT_TYPE,
178178
SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_ACCOUNT_TYPE,
179179
SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_INSTRUCTION_TYPE,
180+
SOLANA_ERROR__REACT__MISSING_CAPABILITY,
181+
SOLANA_ERROR__REACT__MISSING_PROVIDER,
180182
SOLANA_ERROR__RPC__API_PLAN_MISSING_FOR_RPC_METHOD,
181183
SOLANA_ERROR__RPC__INTEGER_OVERFLOW,
182184
SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR,
@@ -784,6 +786,14 @@ export type SolanaErrorContext = ReadonlyContextValue<
784786
instructionType: number | string;
785787
programName: string;
786788
};
789+
[SOLANA_ERROR__REACT__MISSING_CAPABILITY]: {
790+
capabilities: readonly string[];
791+
hookName: string;
792+
providerHint: string;
793+
};
794+
[SOLANA_ERROR__REACT__MISSING_PROVIDER]: {
795+
hookName: string;
796+
};
787797
[SOLANA_ERROR__RPC_SUBSCRIPTIONS__CANNOT_CREATE_SUBSCRIPTION_PLAN]: {
788798
notificationName: string;
789799
};

packages/errors/src/messages.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ import {
207207
SOLANA_ERROR__PROGRAM_CLIENTS__UNEXPECTED_RESOLVED_INSTRUCTION_INPUT_TYPE,
208208
SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_ACCOUNT_TYPE,
209209
SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_INSTRUCTION_TYPE,
210+
SOLANA_ERROR__REACT__MISSING_CAPABILITY,
211+
SOLANA_ERROR__REACT__MISSING_PROVIDER,
210212
SOLANA_ERROR__RPC__API_PLAN_MISSING_FOR_RPC_METHOD,
211213
SOLANA_ERROR__RPC__INTEGER_OVERFLOW,
212214
SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR,
@@ -866,6 +868,10 @@ export const SolanaErrorMessages: Readonly<{
866868
'Transaction has $actualCount instructions but the maximum allowed is $maxAllowed',
867869
[SOLANA_ERROR__TRANSACTION__TOO_MANY_ACCOUNTS_IN_INSTRUCTION]:
868870
'The instruction at index $instructionIndex has $actualCount account references but the maximum allowed is $maxAllowed',
871+
[SOLANA_ERROR__REACT__MISSING_CAPABILITY]:
872+
'`$hookName` requires the following capabilities to be installed on the client: [$capabilities]. $providerHint',
873+
[SOLANA_ERROR__REACT__MISSING_PROVIDER]:
874+
'`$hookName` was called outside of a `ClientProvider`. Mount a `<ClientProvider client={client}>` in the ancestor tree.',
869875
[SOLANA_ERROR__WALLET__NOT_CONNECTED]: 'Cannot $operation: no wallet connected',
870876
[SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED]: 'No signing wallet connected (status: $status)',
871877
[SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE]: 'Connected wallet does not support signing',

packages/react/README.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,85 @@
1313

1414
This package contains React hooks for building Solana apps.
1515

16+
## Kit client bindings
17+
18+
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.
19+
20+
### `ClientProvider`
21+
22+
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.
23+
24+
```tsx
25+
import { createClient } from '@solana/kit';
26+
import { ClientProvider } from '@solana/react';
27+
28+
const client = createClient(); // .use(...) plugins as needed
29+
30+
function App() {
31+
return (
32+
<ClientProvider client={client}>
33+
<Shell />
34+
</ClientProvider>
35+
);
36+
}
37+
```
38+
39+
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).
40+
41+
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.
42+
43+
```tsx
44+
import { Suspense, useMemo } from 'react';
45+
46+
function Root() {
47+
const clientPromise = useMemo(() => createClient().use(someAsyncPlugin()), []);
48+
return (
49+
<Suspense fallback={<Splash />}>
50+
<ClientProvider client={clientPromise}>
51+
<Shell />
52+
</ClientProvider>
53+
</Suspense>
54+
);
55+
}
56+
```
57+
58+
### `useClient<TClient>()`
59+
60+
Reads the Kit client published by the nearest `ClientProvider`. Throws a `SolanaError` with code `SOLANA_ERROR__REACT__MISSING_PROVIDER` if no provider is mounted.
61+
62+
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`.
63+
64+
```tsx
65+
import { ClientWithRpc, GetEpochInfoApi } from '@solana/kit';
66+
import { useClient } from '@solana/react';
67+
68+
function ManualSend() {
69+
const client = useClient<ClientWithRpc<GetEpochInfoApi>>();
70+
return <button onClick={() => client.rpc.getEpochInfo().send()}>Fetch</button>;
71+
}
72+
```
73+
74+
### `useClientCapability<TClient>(config)`
75+
76+
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.
77+
78+
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.
79+
80+
```tsx
81+
import { ClientWithRpc, GetEpochInfoApi } from '@solana/kit';
82+
import { useClientCapability } from '@solana/react';
83+
84+
function useRpc() {
85+
return useClientCapability<ClientWithRpc<GetEpochInfoApi>>({
86+
capability: 'rpc',
87+
hookName: 'useRpc',
88+
providerHint: 'Install `solanaRpc()` on the client.',
89+
});
90+
}
91+
```
92+
93+
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.
94+
1695
## Hooks
1796

1897
### `useSignIn(uiWalletAccount, chain)`

packages/react/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
"@solana/addresses": "workspace:*",
7878
"@solana/errors": "workspace:*",
7979
"@solana/keys": "workspace:*",
80+
"@solana/plugin-core": "workspace:*",
8081
"@solana/promises": "workspace:*",
8182
"@solana/signers": "workspace:*",
8283
"@solana/transaction-messages": "workspace:*",
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { Client } from '@solana/plugin-core';
2+
import React from 'react';
3+
4+
import { usePromise } from './usePromise';
5+
6+
const ClientContext = /*#__PURE__*/ React.createContext<Client<object> | null>(null);
7+
8+
/**
9+
* The React context that holds the Kit client published by the nearest {@link ClientProvider}.
10+
* Exported for advanced cases such as third-party providers that wrap and extend the client; most
11+
* consumers should reach for {@link useClient} or one of the higher-level hooks instead.
12+
*/
13+
export { ClientContext };
14+
15+
/**
16+
* Props accepted by {@link ClientProvider}.
17+
*/
18+
export type ClientProviderProps = Readonly<{
19+
children?: React.ReactNode;
20+
/**
21+
* The Kit client to publish to descendants, or a promise resolving to one (e.g. when the
22+
* client has async plugins). The reference must be stable across renders — build it at
23+
* module scope or memoise it with `useMemo` when its config is reactive.
24+
*/
25+
client: Client<object> | Promise<Client<object>>;
26+
}>;
27+
28+
/**
29+
* Publishes a caller-owned Kit client to its subtree. Required for `useClient`,
30+
* `useClientCapability`, and any plugin-specific hook that depends on a client capability.
31+
*
32+
* Plugin composition belongs in plain Kit — the provider does no composition, lifecycle
33+
* management, or disposal; it is a value channel, not a lifecycle channel. When config changes at
34+
* runtime (e.g. cluster toggle), rebuild the client in `useMemo` and pass the new reference; the
35+
* subtree resubscribes against the new client identity.
36+
*
37+
* Async client support: when `client` is a promise (e.g. `createClient().use(asyncPlugin())`),
38+
* the provider suspends the subtree via the nearest `<Suspense>` boundary until the promise
39+
* resolves. On React 19 this delegates to `React.use(promise)`; on React 18 a thrown-promise shim
40+
* keyed by promise identity preserves the same contract.
41+
*
42+
* @example Sync client
43+
* ```tsx
44+
* import { createClient } from '@solana/kit';
45+
* import { ClientProvider } from '@solana/react';
46+
*
47+
* const client = createClient(); // .use(...) plugins as needed
48+
*
49+
* function App() {
50+
* return (
51+
* <ClientProvider client={client}>
52+
* <MyApp />
53+
* </ClientProvider>
54+
* );
55+
* }
56+
* ```
57+
*
58+
* @example Async client (Suspense)
59+
* ```tsx
60+
* const clientPromise = useMemo(
61+
* () => createClient().use(someAsyncPlugin()),
62+
* [],
63+
* );
64+
*
65+
* <Suspense fallback={<Splash />}>
66+
* <ClientProvider client={clientPromise}>
67+
* <Shell />
68+
* </ClientProvider>
69+
* </Suspense>
70+
* ```
71+
*
72+
* @see {@link useClient}
73+
*/
74+
export function ClientProvider({ children, client }: ClientProviderProps): React.ReactElement {
75+
const resolved = usePromise(client);
76+
return <ClientContext.Provider value={resolved}>{children}</ClientContext.Provider>;
77+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { isSolanaError, SOLANA_ERROR__REACT__MISSING_PROVIDER } from '@solana/errors';
2+
import { Client, createClient } from '@solana/plugin-core';
3+
import { act, render, renderHook } from '@testing-library/react';
4+
import React, { Suspense } from 'react';
5+
import { ErrorBoundary } from 'react-error-boundary';
6+
7+
import { ClientProvider } from '../ClientProvider';
8+
import { useClient } from '../useClient';
9+
10+
describe('ClientProvider + useClient', () => {
11+
it('publishes the client to descendants and returns the same reference across renders', () => {
12+
const client = createClient();
13+
const wrapper = ({ children }: { children: React.ReactNode }) => (
14+
<ClientProvider client={client}>{children}</ClientProvider>
15+
);
16+
const { result, rerender } = renderHook(() => useClient(), { wrapper });
17+
expect(result.current).toBe(client);
18+
rerender();
19+
expect(result.current).toBe(client);
20+
});
21+
22+
it('throws SolanaError MISSING_PROVIDER when `useClient` is called outside a provider', () => {
23+
const { result } = renderHook(() => {
24+
try {
25+
return useClient();
26+
} catch (err) {
27+
return err;
28+
}
29+
});
30+
expect(isSolanaError(result.current, SOLANA_ERROR__REACT__MISSING_PROVIDER)).toBe(true);
31+
expect((result.current as { context: { hookName: string } }).context.hookName).toBe('useClient');
32+
});
33+
34+
it('lets the nearest provider win for nested mounts', () => {
35+
const outer = createClient();
36+
const inner = createClient();
37+
const onRender = jest.fn();
38+
function Probe() {
39+
onRender(useClient());
40+
return null;
41+
}
42+
render(
43+
<ClientProvider client={outer}>
44+
<Probe />
45+
<ClientProvider client={inner}>
46+
<Probe />
47+
</ClientProvider>
48+
</ClientProvider>,
49+
);
50+
expect(onRender).toHaveBeenNthCalledWith(1, outer);
51+
expect(onRender).toHaveBeenNthCalledWith(2, inner);
52+
});
53+
54+
describe('async client', () => {
55+
it('renders the children once the client promise has resolved', async () => {
56+
const client = createClient();
57+
const clientPromise = Promise.resolve(client);
58+
const onRender = jest.fn();
59+
function Probe() {
60+
onRender(useClient());
61+
return <div data-testid="probe">ready</div>;
62+
}
63+
let queryByTestId!: ReturnType<typeof render>['queryByTestId'];
64+
await act(async () => {
65+
({ queryByTestId } = render(
66+
<Suspense fallback={<div data-testid="fallback">loading</div>}>
67+
<ClientProvider client={clientPromise}>
68+
<Probe />
69+
</ClientProvider>
70+
</Suspense>,
71+
));
72+
});
73+
expect(queryByTestId('fallback')).toBeNull();
74+
expect(queryByTestId('probe')).not.toBeNull();
75+
expect(onRender).toHaveBeenLastCalledWith(client);
76+
});
77+
78+
it('suspends while the promise is pending', () => {
79+
const clientPromise = new Promise<Client<object>>(() => {
80+
/* never resolves */
81+
});
82+
function Probe() {
83+
useClient();
84+
return <div data-testid="probe">ready</div>;
85+
}
86+
const { queryByTestId } = render(
87+
<Suspense fallback={<div data-testid="fallback">loading</div>}>
88+
<ClientProvider client={clientPromise}>
89+
<Probe />
90+
</ClientProvider>
91+
</Suspense>,
92+
);
93+
expect(queryByTestId('fallback')).not.toBeNull();
94+
expect(queryByTestId('probe')).toBeNull();
95+
});
96+
97+
it('lets a rejected client promise propagate to the nearest error boundary', async () => {
98+
const boom = new Error('boom');
99+
const clientPromise = Promise.reject<Client<object>>(boom);
100+
// Pre-attach a catch so the rejection isn't flagged as unhandled before React's
101+
// error-boundary subscription runs.
102+
clientPromise.catch(() => {});
103+
const onError = jest.fn();
104+
function Probe() {
105+
useClient();
106+
return <div data-testid="probe">ready</div>;
107+
}
108+
let queryByTestId!: ReturnType<typeof render>['queryByTestId'];
109+
await act(async () => {
110+
({ queryByTestId } = render(
111+
<ErrorBoundary
112+
fallbackRender={({ error }) => {
113+
onError(error);
114+
return <div data-testid="caught">{(error as Error).message}</div>;
115+
}}
116+
>
117+
<Suspense fallback={<div data-testid="fallback">loading</div>}>
118+
<ClientProvider client={clientPromise}>
119+
<Probe />
120+
</ClientProvider>
121+
</Suspense>
122+
</ErrorBoundary>,
123+
));
124+
});
125+
expect(queryByTestId('caught')).not.toBeNull();
126+
expect(queryByTestId('caught')!.textContent).toBe('boom');
127+
expect(queryByTestId('probe')).toBeNull();
128+
expect(onError).toHaveBeenCalledWith(boom);
129+
});
130+
});
131+
});

0 commit comments

Comments
 (0)