-
Notifications
You must be signed in to change notification settings - Fork 7
Add @solana/kit-react core package
#205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
315dd72
c4d4cae
f88d040
34d4aaf
c808726
9bde17a
f84c448
7422723
9b73e76
5ea85af
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@solana/kit-react': minor | ||
| --- | ||
|
|
||
| Add `@solana/kit-react` — React bindings for Kit. A single `KitClientProvider` distributes a caller-built Kit client to the subtree; plugin composition happens in plain Kit via `createClient().use(...)` so any Kit plugin works without a React-specific wrapper. The provider accepts `Client | Promise<Client>`, suspending via the nearest `<Suspense>` boundary when given a promise (`React.use(promise)` on 19, a thrown-promise shim on 18). Ships live-data hooks (`useBalance`, `useAccount`, `useTransactionConfirmation`), generic data hooks (`useLiveData`, `useSubscription`, `useRequest`), action hooks (`useAction`, `useSendTransaction(s)`, `usePlanTransaction(s)`), signer hooks (`usePayer`, `useIdentity`), and the `useClient` / `useChain` / `useClientCapability` escape hatches. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ node_modules | |
| # misc | ||
| .DS_Store | ||
| *.pem | ||
| dist | ||
|
|
||
| # debug | ||
| npm-debug.log* | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| dist/ | ||
| test-ledger/ | ||
| target/ | ||
| CHANGELOG.md |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| { | ||
| "name": "@solana/kit-react", | ||
| "version": "0.1.0", | ||
| "description": "React bindings for Kit: a client-first provider plus live on-chain data, RPC, and transaction hooks", | ||
| "exports": { | ||
| "types": "./dist/types/index.d.ts", | ||
| "react-native": "./dist/index.react-native.mjs", | ||
| "browser": { | ||
| "import": "./dist/index.browser.mjs", | ||
| "require": "./dist/index.browser.cjs" | ||
| }, | ||
| "node": { | ||
| "import": "./dist/index.node.mjs", | ||
| "require": "./dist/index.node.cjs" | ||
| } | ||
| }, | ||
| "browser": { | ||
| "./dist/index.node.cjs": "./dist/index.browser.cjs", | ||
| "./dist/index.node.mjs": "./dist/index.browser.mjs" | ||
| }, | ||
| "main": "./dist/index.node.cjs", | ||
| "module": "./dist/index.node.mjs", | ||
| "react-native": "./dist/index.react-native.mjs", | ||
| "types": "./dist/types/index.d.ts", | ||
| "type": "commonjs", | ||
| "files": [ | ||
| "./dist/types", | ||
| "./dist/index.*", | ||
| "./src/" | ||
| ], | ||
| "sideEffects": false, | ||
| "keywords": [ | ||
| "solana", | ||
| "kit", | ||
| "react", | ||
| "hooks" | ||
| ], | ||
| "scripts": { | ||
| "build": "rimraf dist && tsup && tsc -p ./tsconfig.declarations.json", | ||
| "dev": "vitest --project node", | ||
| "lint": "eslint . && prettier --check .", | ||
| "lint:fix": "eslint --fix . && prettier --write .", | ||
| "test": "pnpm test:types && pnpm test:unit", | ||
| "test:types": "tsc --noEmit", | ||
| "test:unit": "vitest run" | ||
| }, | ||
| "peerDependencies": { | ||
| "@solana/kit": "^6.8.0", | ||
| "react": "^18.0.0 || ^19.0.0" | ||
| }, | ||
| "dependencies": { | ||
| "@solana/subscribable": "^6.8.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@testing-library/react": "^16.1.0", | ||
| "@types/react": "^19.0.0", | ||
| "@types/react-dom": "^19.0.0", | ||
| "react": "^19.0.0", | ||
| "react-dom": "^19.0.0" | ||
| }, | ||
| "license": "MIT", | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "https://github.com/anza-xyz/kit-plugins" | ||
| }, | ||
| "bugs": { | ||
| "url": "https://github.com/anza-xyz/kit-plugins/issues" | ||
| }, | ||
| "browserslist": [ | ||
| "supports bigint and not dead", | ||
| "maintained node versions" | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import type { Client } from '@solana/kit'; | ||
|
|
||
| import { useClient } from './client-context'; | ||
| import { throwMissingCapability } from './errors'; | ||
|
|
||
| /** | ||
| * Options for {@link useClientCapability}. | ||
| */ | ||
| export type UseClientCapabilityOptions = Readonly<{ | ||
| /** | ||
| * The capability name, or an ordered list of capability names, that | ||
| * must be present on the client. Each is checked via `key in client`. | ||
| */ | ||
| capability: string | readonly string[]; | ||
| /** Hook name shown as the subject of the error. */ | ||
| hookName: string; | ||
| /** Free-text "how to fix" hint appended to the error. */ | ||
| providerHint: string; | ||
| }>; | ||
|
|
||
| /** | ||
| * Reads the Kit client from context and asserts that the listed capability | ||
| * (or capabilities) are installed. | ||
| * | ||
| * The TypeScript narrowing is caller-declared: the hook does not infer | ||
| * `TClient` from the capability names. Mismatched casts are caller errors, | ||
| * same as with {@link useClient}. Hook authors who want a loud | ||
| * missing-provider failure at mount should prefer this over | ||
| * `useClient<T>()`. | ||
| * | ||
| * @typeParam TClient - The client shape the caller expects after narrowing. | ||
| * @param options - The capability name(s), hook name, and provider hint. | ||
| * @return The Kit client cast to `Client<TClient>`. | ||
| * @throws An `Error` describing the missing capability when any listed key | ||
| * is not present on the client. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import type { ClientWithRpc, GetEpochInfoApi } from '@solana/kit'; | ||
| * | ||
| * function useEpochInfo() { | ||
| * const client = useClientCapability<ClientWithRpc<GetEpochInfoApi>>({ | ||
| * capability: 'rpc', | ||
| * hookName: 'useEpochInfo', | ||
| * providerHint: 'Install `solanaRpc()` or `solanaRpcConnection()` on the client.', | ||
| * }); | ||
| * return useRequest(() => client.rpc.getEpochInfo(), [client]); | ||
| * } | ||
| * ``` | ||
| * | ||
| * @see {@link useClient} | ||
| */ | ||
| export function useClientCapability<TClient extends object>(options: UseClientCapabilityOptions): Client<TClient> { | ||
| const client = useClient<TClient>(); | ||
| const keys: readonly string[] = Array.isArray(options.capability) | ||
| ? options.capability | ||
| : [options.capability as string]; | ||
| for (const key of keys) { | ||
| if (!(key in (client as object))) { | ||
| const formatKey = (k: string): string => '`client.' + k + '`'; | ||
| const description: string = | ||
| keys.length === 1 ? formatKey(key) : 'all of ' + keys.map((k: string) => formatKey(k)).join(', '); | ||
| throwMissingCapability(options.hookName, description, options.providerHint); | ||
| } | ||
| } | ||
| return client; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| import type { Client } from '@solana/kit'; | ||
| import { createContext, type ReactNode, useContext } from 'react'; | ||
|
|
||
| import { usePromise } from './internal/use-promise'; | ||
|
|
||
| /** | ||
| * Known Solana chain identifiers following the wallet-standard / CAIP-2 | ||
| * `namespace:reference` convention. Prefer one of these literals so that | ||
| * TypeScript autocompletes the correct value at call sites. | ||
| */ | ||
| export type SolanaChain = 'solana:mainnet' | 'solana:devnet' | 'solana:testnet'; | ||
|
|
||
| /** | ||
| * Wallet-standard / CAIP-2 namespaced chain identifier | ||
| * (`namespace:reference`, e.g. `'solana:mainnet'`, `'eip155:1'`). Inlined | ||
| * here so this package doesn't need a runtime dep on | ||
| * `@wallet-standard/base` for a type-only import. | ||
| */ | ||
| type ChainIdentifierString = `${string}:${string}`; | ||
|
|
||
| /** | ||
| * Chain identifier accepted by {@link KitClientProvider}. Solana literals | ||
| * autocomplete; the intersection with `ChainIdentifierString & {}` | ||
| * preserves those literals while still accepting any `${string}:${string}` | ||
| * identifier for custom or non-Solana chains. | ||
| */ | ||
| export type ChainIdentifier = SolanaChain | (ChainIdentifierString & {}); | ||
|
|
||
| /** | ||
| * React context carrying the Kit client. | ||
| * | ||
| * Exposed so advanced consumers can compose on top of an existing context. | ||
| * Most callers should reach for {@link useClient} or a named hook instead. | ||
| * | ||
| * @see {@link useClient} | ||
| */ | ||
| export const ClientContext = createContext<Client<object> | null>(null); | ||
| ClientContext.displayName = 'KitClientContext'; | ||
|
|
||
| /** | ||
| * React context carrying the chain identifier configured on the nearest | ||
| * {@link KitClientProvider}. | ||
| * | ||
| * @see {@link useChain} | ||
| */ | ||
| export const ChainContext = createContext<ChainIdentifier | null>(null); | ||
| ChainContext.displayName = 'KitChainContext'; | ||
|
|
||
| /** | ||
| * Props for {@link KitClientProvider}. | ||
| */ | ||
| export type KitClientProviderProps = Readonly<{ | ||
| /** | ||
| * A Kit client, or a promise resolving to one. When a promise is | ||
| * passed, the provider suspends via the nearest `<Suspense>` boundary | ||
| * until it resolves (React 19's `use(promise)` on 19, a thrown-promise | ||
| * shim on 18). The promise must be stable across renders — wrap it in | ||
| * `useMemo` or hoist it to module scope. | ||
| */ | ||
| client: Client<object> | Promise<Client<object>>; | ||
| /** | ||
| * Optional chain identifier to publish to the subtree. Wallet-aware | ||
| * descendants read it via {@link useChain}. | ||
| */ | ||
| chain?: ChainIdentifier; | ||
| children?: ReactNode; | ||
| }>; | ||
|
|
||
| /** | ||
| * Root provider for `@solana/kit-react`. | ||
| * | ||
| * Publishes a caller-owned Kit client to the subtree. Every hook in this | ||
| * library requires a {@link KitClientProvider} ancestor. The client is | ||
| * built outside React via `createClient().use(...)`; pass the result (or a | ||
| * `Promise` of one when any installed plugin is async) as the `client` | ||
| * prop. The provider itself does no composition, lifecycle management, or | ||
| * disposal — that all belongs to the caller. | ||
| * | ||
| * When `client` is a promise, the provider suspends until it resolves, so | ||
| * a `<Suspense>` boundary must be mounted above. | ||
| * | ||
| * @param props - The Kit client (sync or async), optional chain, and children. | ||
| * @return A React element that publishes the Kit client to its subtree. | ||
| * | ||
| * @example | ||
| * ```tsx | ||
| * import { createClient } from '@solana/kit'; | ||
| * import { solanaMainnetRpc } from '@solana/kit-plugin-rpc'; | ||
| * import { walletSigner } from '@solana/kit-plugin-wallet'; | ||
| * import { KitClientProvider } from '@solana/kit-react'; | ||
| * | ||
| * const client = createClient() | ||
| * .use(walletSigner({ chain: 'solana:mainnet' })) | ||
| * .use(solanaMainnetRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' })); | ||
| * | ||
| * export function App() { | ||
| * return ( | ||
| * <KitClientProvider client={client} chain="solana:mainnet"> | ||
| * <Shell /> | ||
| * </KitClientProvider> | ||
| * ); | ||
| * } | ||
| * ``` | ||
| * | ||
| * @see {@link useClient} | ||
| * @see {@link useChain} | ||
| */ | ||
| export function KitClientProvider({ chain, children, client }: KitClientProviderProps) { | ||
| const resolved = isPromiseLike(client) ? usePromise(client) : client; | ||
| const tree = <ClientContext.Provider value={resolved}>{children}</ClientContext.Provider>; | ||
| return chain ? <ChainContext.Provider value={chain}>{tree}</ChainContext.Provider> : tree; | ||
| } | ||
|
|
||
| function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> { | ||
| if (value instanceof Promise) return true; | ||
| // Handle realm-crossing promises (a `Promise` instantiated in a different | ||
| // global, e.g. an iframe or a test runner that swaps globalThis) by | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider firing Something like: useIdentityChurnWarning(client, {
argName: 'client',
hookName: 'KitClientProvider',
remedy: 'Build the client at module scope or wrap it in `useMemo`. The provider does not own client lifecycle.',
});Skip when |
||
| // duck-typing the `.then` method as a fallback. A plain Kit client is an | ||
| // object built via `extendClient`, which never installs `then`, so this | ||
| // is unambiguous in practice. | ||
| return typeof (value as { then?: unknown }).then === 'function'; | ||
| } | ||
|
|
||
| /** | ||
| * Reads the Kit client from context. | ||
| * | ||
| * Defaults to the base `Client<object>` shape. Callers who know specific | ||
| * plugins are installed can widen via the generic argument, matching the | ||
| * escape-hatch pattern used by {@link useChain}. | ||
| * | ||
| * @typeParam TClient - The client shape to widen the return to. Defaults to `object`. | ||
| * @return The Kit client cast to `Client<TClient>`. | ||
| * @throws An `Error` if no {@link KitClientProvider} ancestor is present. | ||
| * | ||
| * @example | ||
| * ```tsx | ||
| * const client = useClient<ClientWithRpc<SolanaRpcApi>>(); | ||
| * const epoch = await client.rpc.getEpochInfo().send(); | ||
| * ``` | ||
| * | ||
| * @see {@link useClientCapability} | ||
| * @see {@link useChain} | ||
| */ | ||
| export function useClient<TClient extends object = object>(): Client<TClient> { | ||
| const client = useContext(ClientContext); | ||
| if (client === null) { | ||
| throw new Error('useClient() must be called inside a <KitClientProvider>.'); | ||
| } | ||
| return client as Client<TClient>; | ||
| } | ||
|
|
||
| /** | ||
| * Reads the chain identifier from context. | ||
| * | ||
| * The chain is published by the nearest {@link KitClientProvider} via its | ||
| * `chain` prop. Wallet-aware code reaches for this; plain read paths that | ||
| * don't care about cluster can ignore it. | ||
| * | ||
| * @typeParam T - Widen the return type when your provider is configured | ||
| * with a custom chain identifier. Defaults to the narrow | ||
| * {@link SolanaChain} union so callers get autocomplete. | ||
| * @return The chain identifier published by {@link KitClientProvider}. | ||
| * @throws An `Error` if no chain has been published. | ||
| * | ||
| * @example | ||
| * ```tsx | ||
| * const chain = useChain(); | ||
| * // chain is 'solana:mainnet' | 'solana:devnet' | 'solana:testnet' | ||
| * ``` | ||
| */ | ||
| export function useChain<T extends ChainIdentifierString = SolanaChain>(): T { | ||
| const chain = useContext(ChainContext); | ||
| if (chain === null) { | ||
| throw new Error('useChain() requires a chain to be set via the `chain` prop on <KitClientProvider>.'); | ||
| } | ||
| return chain as T; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| /** | ||
| * Throws a consistently-formatted error describing a missing client | ||
| * capability. | ||
| * | ||
| * Used by {@link useClientCapability} and the hook-local assertions in | ||
| * {@link usePayer}, {@link useIdentity}, etc. | ||
| * | ||
| * @param hookName - Name of the hook raising the error, used as the subject. | ||
| * @param capabilityDescription - Human-readable description of the missing capability. | ||
| * @param providerHint - Free-text "how to fix" hint appended to the error. | ||
| * @throws Always throws an `Error` with the formatted message. | ||
| * | ||
| * @internal | ||
| */ | ||
| export function throwMissingCapability(hookName: string, capabilityDescription: string, providerHint: string): never { | ||
| throw new Error(`${hookName}() requires ${capabilityDescription} on the Kit client. ${providerHint}`); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor:
treeallocates a new<ClientContext.Provider>element on every render even when neitherresolvednorchildrenchanged. Context bails on equalvalue, so the perf cost is bounded to acreateElementcall per render — but if you ever wanted to add the same JSX pattern in a hot path, this is the kind of place to reach foruseMemo. Not worth changing here; just flagging the pattern.