diff --git a/.changeset/flat-ants-peel.md b/.changeset/flat-ants-peel.md new file mode 100644 index 0000000..9e23ab5 --- /dev/null +++ b/.changeset/flat-ants-peel.md @@ -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`, suspending via the nearest `` 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. \ No newline at end of file diff --git a/.gitignore b/.gitignore index b7b0053..d71b001 100755 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ node_modules # misc .DS_Store *.pem +dist # debug npm-debug.log* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5ef38da..388122e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,7 @@ This is a monorepo managed with [pnpm](https://pnpm.io/) and [Turborepo](https:/ | [`@solana/kit-plugin-litesvm`](./packages/kit-plugin-litesvm) | LiteSVM support plugin. | | [`@solana/kit-plugin-instruction-plan`](./packages/kit-plugin-instruction-plan) | Transaction planning and execution plugins. | | [`@solana/kit-plugin-wallet`](./packages/kit-plugin-wallet) | Browser wallet support plugins. | +| [`@solana/kit-react`](./packages/kit-react) | React bindings for Solana Kit — providers and hooks. | The umbrella package (`@solana/kit-plugins`) is deprecated. It re-exports everything from the individual plugin packages via `export *` statements for backward compatibility, but consumers should import from the individual `kit-plugin-*` packages directly. diff --git a/README.md b/README.md index a4b00e3..3710b4b 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,33 @@ This repo provides the following individual plugin packages. You can learn more | [`@solana/kit-plugin-instruction-plan`](./packages/kit-plugin-instruction-plan) | [![npm](https://img.shields.io/npm/v/@solana/kit-plugin-instruction-plan.svg?style=flat)](https://www.npmjs.com/package/@solana/kit-plugin-instruction-plan) | Transaction planning and execution | `transactionPlanner`, `transactionPlanExecutor`, `planAndSendTransactions` | | [`@solana/kit-plugin-wallet`](./packages/kit-plugin-wallet) | [![npm](https://img.shields.io/npm/v/@solana/kit-plugin-wallet.svg?style=flat)](https://www.npmjs.com/package/@solana/kit-plugin-wallet) | Browser wallet support | `walletSigner`, `walletIdentity`, `walletPayer`, `walletWithoutSigner` | +## React Bindings + +For React applications, [`@solana/kit-react`](./packages/kit-react) provides a thin layer over Kit — providers for composing a client and hooks like `useBalance`, `useAccount`, `useSendTransaction`, `useSubscription`, and `useRequest`. See the [package README](./packages/kit-react/README.md) for the full surface. + +```sh +pnpm install @solana/kit @solana/kit-react @solana/kit-plugin-rpc +``` + +```tsx +import { KitClientProvider, SolanaMainnetRpcProvider, useBalance } from '@solana/kit-react'; + +function BalanceCard({ owner }) { + const { data, status } = useBalance(owner); + return status === 'loading' ? : ; +} + +export function App() { + return ( + + + + + + ); +} +``` + ## Community Plugins | Package | Description | Plugins | Maintainers | diff --git a/packages/kit-react/.prettierignore b/packages/kit-react/.prettierignore new file mode 100644 index 0000000..c52dcf5 --- /dev/null +++ b/packages/kit-react/.prettierignore @@ -0,0 +1,4 @@ +dist/ +test-ledger/ +target/ +CHANGELOG.md diff --git a/packages/kit-react/README.md b/packages/kit-react/README.md new file mode 100644 index 0000000..f157da5 --- /dev/null +++ b/packages/kit-react/README.md @@ -0,0 +1,458 @@ +# `@solana/kit-react` + +React bindings for [Solana Kit](https://github.com/anza-xyz/kit). You build a Kit client with `.use()` the way you already do, hand it to a single provider, and pick up hooks that expose live on-chain data, one-shot RPC reads, and transaction-sending flows through the same `{ data, error, status, ... }` vocabulary consumers already expect from `react-query` or `swr`. + +The library is a thin bridge over Kit's reactive primitives — it does not reimplement state machines, fetch policies, or abort semantics. Every lifecycle concern (retry, supersede, stale-while-revalidate, slot dedup) lives in Kit and surfaces through `useSyncExternalStore`. + +- **Client-first.** Compose your Kit client with `createClient().use(...)` outside React; `KitClientProvider` distributes it to the subtree. Any Kit plugin works without a React wrapper. +- **Subscription-backed live data.** Reactive hooks combine an RPC fetch with a matching subscription and dedupe by slot — no polling, no stale-data flicker. +- **Async-ready.** Pass a `Promise` to handle async plugins; the provider suspends via `` using React 19's `use(promise)` (or a built-in shim on React 18). +- **Cache-library agnostic.** Core hooks work on their own; follow-up packages will bridge into SWR and TanStack Query for apps that want shared caches. +- **Headless.** No UI. Wallet pickers, modals, and connect buttons belong in higher-level libraries. +- **SSR-safe.** Every hook returns a hydration-stable "not yet available" snapshot during server render; the live store kicks in on the client. + +## Contents + +- [Installation](#installation) + - [ESLint — `react-hooks/exhaustive-deps`](#eslint--react-hooksexhaustive-deps) +- [Quick start](#quick-start) +- [`KitClientProvider`](#kitclientprovider) + - [Dynamic clients](#dynamic-clients) + - [Async plugins (Suspense)](#async-plugins-suspense) +- [Hooks](#hooks) + - [One-shot reads](#one-shot-reads) + - [Subscriptions](#subscriptions) + - [Actions and transactions](#actions-and-transactions) + - [Live data](#live-data) + - [Signers](#signers) + - [Client access](#client-access) +- [Return shapes](#return-shapes) +- [Common patterns](#common-patterns) + +## Installation + +```shell +pnpm add @solana/kit-react @solana/kit react +``` + +Install whichever Kit plugins your client needs as regular direct dependencies: + +```shell +pnpm add @solana/kit-plugin-rpc # client.rpc / client.rpcSubscriptions / transaction sending +pnpm add @solana/kit-plugin-wallet # wallet discovery + connection (browser wallets) +pnpm add @solana/kit-plugin-signer # static payer / identity / signer (relayers, test keypairs) +pnpm add @solana/kit-plugin-litesvm # in-process SVM for tests / local dev +``` + +The library targets React 18 and 19, and ships Node, browser, and React Native bundles. + +### ESLint — `react-hooks/exhaustive-deps` + +`useLiveData`, `useSubscription`, `useRequest`, and `useAction` each take a `deps` argument that matches the convention of React's built-in hooks, but ESLint's `react-hooks/exhaustive-deps` rule only lints its own. Add them to `additionalHooks` so the rule verifies your deps lists too: + +```json +{ + "rules": { + "react-hooks/exhaustive-deps": [ + "warn", + { "additionalHooks": "(useLiveData|useSubscription|useRequest|useAction)" } + ] + } +} +``` + +## Quick start + +A wallet-enabled mainnet dApp — build the client once at module scope, hand it to the provider: + +```tsx +import { createClient } from '@solana/kit'; +import { solanaMainnetRpc } from '@solana/kit-plugin-rpc'; +import { walletSigner } from '@solana/kit-plugin-wallet'; +import { KitClientProvider, useBalance } from '@solana/kit-react'; + +const client = createClient() + .use(walletSigner({ chain: 'solana:mainnet' })) + .use(solanaMainnetRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' })); + +function BalanceCard({ owner }: { owner: Address }) { + const { data, status, error, retry } = useBalance(owner); + if (status === 'loading') return ; + if (status === 'error') return ; + return ; +} + +export function App() { + return ( + + + + ); +} +``` + +A read-only dashboard — just an RPC connection, no wallet: + +```tsx +import { createClient } from '@solana/kit'; +import { solanaRpcConnection } from '@solana/kit-plugin-rpc'; +import { KitClientProvider } from '@solana/kit-react'; + +const client = createClient().use(solanaRpcConnection({ rpcUrl: 'https://api.mainnet-beta.solana.com' })); + +export function App() { + return ( + + + + ); +} +``` + +A relayer flow — static payer with a wallet-backed identity: + +```tsx +import { createClient } from '@solana/kit'; +import { payer } from '@solana/kit-plugin-signer'; +import { solanaMainnetRpc } from '@solana/kit-plugin-rpc'; +import { walletIdentity } from '@solana/kit-plugin-wallet'; + +const client = createClient() + .use(payer(relayerKeypair)) + .use(walletIdentity({ chain: 'solana:mainnet' })) + .use(solanaMainnetRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' })); +``` + +## `KitClientProvider` + +Root provider. Publishes a caller-owned Kit client to the subtree. Every hook in this library needs one as an ancestor. + +```tsx + + + +``` + +Props: + +- `client` — a Kit client (`Client`) or a promise that resolves to one. When a promise is passed, the provider suspends via the nearest `` boundary until it resolves. The client (or promise) must be stable across renders — build it at module scope or wrap it in `useMemo`. +- `chain` _(optional)_ — a wallet-standard `ChainIdentifier` (e.g. `"solana:mainnet"`, `"solana:devnet"`, or any `${namespace}:${network}`). `useChain()` reads this from context. + +The provider does **not** create, extend, or dispose clients. Composition (`.use(solanaRpc(...))`, `.use(walletSigner(...))`, etc.) happens in plain Kit; the provider distributes the result. + +### Disposal + +Module-scope clients in long-running SPAs typically don't need explicit disposal — the client lives for the lifetime of the page. But Kit clients implement `Symbol.dispose`, so when you do need cleanup (per-request clients in SSR, integration tests that mount/unmount, dynamic clients you're replacing in `useMemo`), call it after the subtree no longer uses the client: + +```tsx +useEffect(() => { + return () => { + client[Symbol.dispose](); + }; +}, [client]); +``` + +This matters most for plugins with subscriptions or storage listeners (e.g. `walletSigner`'s wallet-standard registry handlers, `walletSigner`'s `localStorage` writes). For purely RPC-based clients there's nothing to release. + +### Dynamic clients + +When a config changes at runtime (chain toggle, RPC URL change, relayer rotation), rebuild the client in `useMemo` and pass the new reference: + +```tsx +function App() { + const [chain, setChain] = useState('solana:mainnet'); + + const client = useMemo(() => { + const rpcUrl = + chain === 'solana:mainnet' ? 'https://api.mainnet-beta.solana.com' : 'https://api.devnet.solana.com'; + return createClient().use(walletSigner({ chain })).use(solanaRpc({ rpcUrl })).use(planAndSendTransactions()); + }, [chain]); + + return ( + + + + + ); +} +``` + +Wallet state is **not** one of these cases. The wallet plugins keep the client identity stable as wallets connect, disconnect, and switch accounts — internal state updates, and `usePayer` / `useIdentity` / any wallet hook subscribes to that state via `useSyncExternalStore`. Don't rebuild the client on wallet events. + +### Async plugins (Suspense) + +When a plugin's `.use()` returns a promise, the whole `createClient().use(...)` chain returns `Promise`. Hand it straight to `KitClientProvider` and mount `` above: + +```tsx +import { Suspense, useMemo } from 'react'; + +function Root() { + const clientPromise = useMemo( + () => + createClient() + .use( + someAsyncPlugin({ + /* … */ + }), + ) + .use(solanaMainnetRpc({ rpcUrl })), + [], + ); + return ( + + + + ); +} + +export function App() { + return ( + }> + + + ); +} +``` + +On React 19 this uses the built-in `React.use(promise)`; on React 18 a tiny thrown-promise shim inside the provider gives the same contract. The promise must be stable across renders — pass a `useMemo`'d or module-scope value, never `new Promise(...)` inline. + +## Hooks + +Every hook falls into one of five return shapes. Knowing the category tells you how to consume it without reading the signature — see [Return shapes](#return-shapes). + +### One-shot reads + +`useRequest` covers RPC calls without a subscription counterpart — `getEpochInfo`, `getLatestBlockhash`, `getTokenSupply`, etc. — or one-shot reads of values you don't need to subscribe to. + +```tsx +const { data: epoch, error, refresh, isLoading } = useRequest(() => client.rpc.getEpochInfo(), [client]); + +// Deps-driven: refetches when the address changes. +const { data: supply } = useRequest(() => client.rpc.getTokenSupply(mintAddress), [client, mintAddress]); + +// Null to disable — no RPC fires, `status` reports `'disabled'`. +const { data } = useRequest(() => (address ? client.rpc.getAccountInfo(address) : null), [client, address]); +``` + +Auto-dispatches on mount and whenever `deps` change; `refresh()` re-fires manually with the current deps. During a re-fire `status` transitions to `retrying` with `data` still holding the prior value (stale-while-revalidate). + +For imperative one-offs outside the render path, call the RPC directly via `useClient()`: + +```tsx +const epoch = await client.rpc.getEpochInfo().send(); +``` + +### Subscriptions + +`useSubscription` exposes any subscription-only stream as `{ data, error, status, isLoading, retry, slot }` — the reactive live-value shape used throughout this library. Typically the factory returns a `PendingRpcSubscriptionsRequest`, but any object with a matching `.reactiveStore({ abortSignal })` method plugs in. + +```tsx +// Slot feed — raw-value subscription. +const { data: slot, error, retry } = useSubscription(() => client.rpcSubscriptions.slotNotifications(), [client]); + +// Logs feed, gated on a flag — null disables it, no WebSocket opens. +const { data } = useSubscription( + () => (enabled ? client.rpcSubscriptions.logsNotifications(programId) : null), + [client, programId, enabled], +); +``` + +`useSubscription` unwraps `SolanaRpcResponse`-shaped notifications automatically: for account / program / signature feeds, `data` is the inner value and `slot` comes from `context.slot`; for raw-value feeds (slot, logs, roots) `data` is the notification as-is and `slot` is `undefined`. The return type reflects the unwrap at compile time. + +### Actions and transactions + +Every user-triggered async operation returns the same `ActionResult` shape: a fire-and-forget `send(...)` for UI event handlers, a promise-returning `sendAsync(...)` for imperative flows, plus `status` / `data` / `error` / `reset` to track progress. A second dispatch while the first is in flight aborts the first by firing its `AbortSignal` — the common "click twice, only the second submits" UX — so consumers don't have to debounce. + +```tsx +// Send a single transaction. Input accepts instructions, instruction plans, +// pre-built transaction messages, or a SingleTransactionPlan. +const { send, status, isRunning, data, error, reset } = useSendTransaction(); +// Fire-and-forget from an event handler — no promise to handle. +; + +// Multi-transaction variant. +const { send: sendMany } = useSendTransactions(); + +// Plan a transaction without sending it — for preview-then-send UX. +const { sendAsync: plan } = usePlanTransaction(); +const message = await plan(instructions); + +// Generic async action — the building block behind the others. +const { send } = useAction(async (signal, payload: Uint8Array) => signer.signMessage(payload), [signer]); +``` + +The wrapped function receives an `AbortSignal` as its first argument — thread it into your `fetch` / RPC / wallet call so a supersede actually cancels the in-flight work. `reset()` returns the hook to `idle` and aborts any in-flight call. + +`send(...)` is fire-and-forget: returns `undefined`, never throws, and failures surface on `error` / `status`. Prefer it from event handlers. Use `sendAsync(...)` when you need the resolved value or want to react to errors in control flow — filter supersede rejections with `isAbortError`: + +```tsx +import { isAbortError } from '@solana/kit-react'; + +try { + const result = await sendAsync(input); + navigate(`/tx/${result.context.signature}`); +} catch (err) { + if (isAbortError(err)) return; // superseded — the store already reflects the newer call + throw err; +} +``` + +### Live data + +Named hooks for the common RPC + subscription pairings. They return the same shape as [`useSubscription`](#subscriptions). Both a one-shot RPC fetch and an ongoing subscription feed into the same store; whichever emits first provides the initial value, and slot-based comparison ensures only the newest data wins regardless of arrival order — no stale-data flicker, no polling. + +All three accept `null` to disable — the hook returns `status: 'disabled'`, `isLoading: false`, fires no RPC traffic, and closes any active subscription. Use this when the dependency the hook needs isn't ready yet (e.g. wallet not connected). + +```tsx +// SOL balance — live. +const { data, status, error, retry, slot } = useBalance(address); + +// Account data — raw encoded bytes or decoded. The `decoder` participates in +// the internal dep list, so memoize it (module-level constant or `useMemo`) +// rather than calling `getMintDecoder()` inline on every render — a dev-only +// warning fires after a few re-renders if you don't. +const MINT_DECODER = getMintDecoder(); +const { data: raw } = useAccount(mintAddress); +const { data: decoded } = useAccount(mintAddress, MINT_DECODER); + +// Transaction confirmation status — combines getSignatureStatuses + signatureNotifications. +const { data, status } = useTransactionConfirmation(signature, { commitment: 'confirmed' }); +``` + +`data` is undefined while loading or when disabled; `retry()` re-opens the stream after an error (stable reference, safe as `onClick`); `slot` exposes the slot the current `data` was observed at — useful for "as of slot X" indicators and for coordinating a refetch with a just-sent transaction's slot. + +For custom RPC + subscription pairs the named hooks don't cover, `useLiveData` returns the same shape for any spec you build: + +```tsx +const { data } = useLiveData( + () => ({ + rpcRequest: client.rpc.getAccountInfo(gameAddress), + rpcSubscriptionRequest: client.rpcSubscriptions.accountNotifications(gameAddress), + rpcValueMapper: v => parseGameState(v.value), + rpcSubscriptionValueMapper: v => parseGameState(v), + }), + [client, gameAddress], +); +``` + +### Signers + +```tsx +const payer = usePayer(); // TransactionSigner | null +const identity = useIdentity(); // TransactionSigner | null +``` + +Both return `null` when no signer is currently available (e.g. a wallet-backed payer with no wallet connected). Both throw if no payer/identity plugin is installed on the client at all — that's a mount-time configuration bug, so it surfaces loudly. + +These hooks are reactive if the installing plugin is — wallet plugins (`walletSigner`, `walletPayer`, `walletIdentity`) publish a `subscribeToPayer` / `subscribeToIdentity` sibling that the hooks subscribe to, so the returned signer tracks connect / disconnect / account switch without any client rebuild. Static plugins (`payer()`, `identity()`, `signer()` from `@solana/kit-plugin-signer`) install a fixed signer; the hook simply returns it. + +### Client access + +Escape hatches for imperative code or for hooks that aren't covered by the named surface. + +```tsx +const client = useClient(); // raw Kit client — Client +const client = useClient(); // caller-declared widening; pure cast, no runtime check +const chain = useChain(); // ChainIdentifier +``` + +`useClientCapability` is the same pattern with a runtime-checked missing-plugin error. Prefer it over `useClient()` when writing hooks whose users should see an explicit "this hook needs `client.rpc`. Install `solanaRpc()` on the client." message rather than a cryptic call-site crash: + +```tsx +import type { ClientWithRpc, GetEpochInfoApi } from '@solana/kit'; +import { useClientCapability, useRequest } from '@solana/kit-react'; + +export function useEpochInfo() { + const client = useClientCapability>({ + capability: 'rpc', + hookName: 'useEpochInfo', + providerHint: 'Install `solanaRpc()` or `solanaRpcConnection()` on the client.', + }); + return useRequest(() => client.rpc.getEpochInfo(), [client]); +} +``` + +## Return shapes + +Every hook falls into one of these categories. They share vocabulary intentionally — `status`, `error`, `data` mean the same thing everywhere. + +| Category | Return shape | Hooks | +| -------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| One-shot read | `{ data, error, status, isLoading, refresh }` | `useRequest` | +| Live data | `{ data, error, status, isLoading, retry, slot }` | `useSubscription`, `useBalance`, `useAccount`, `useTransactionConfirmation`, `useLiveData` | +| Tracked action | `{ send, sendAsync, status, isIdle, isRunning, isSuccess, isError, data, error, reset }` | `useSendTransaction[s]`, `usePlanTransaction[s]`, `useAction` | +| Reactive value | `TransactionSigner \| null` | `usePayer`, `useIdentity` | +| Context value | Stable client / chain | `useClient`, `useChain` | + +**Live-data `status`** — `loading` (no data yet) / `loaded` / `error` (failed, `data` still holds last known) / `retrying` (re-opening after error, `data` preserved) / `disabled` (null arg; no traffic). `isLoading` is `true` only in `loading` — `retrying` and `disabled` both report `false`, matching `react-query` / `swr` semantics. + +**One-shot `status`** — same five values. `retrying` is the "re-dispatch after success" case, so UIs can show stale data with a "refreshing" overlay while `refresh()` is in flight. + +**Action `status`** — `idle` / `running` / `success` / `error`. `send(...)` transitions to `running`, resolution transitions to `success` or `error`. `reset()` goes back to `idle`. + +## Common patterns + +### Disabling a query + +Every data hook accepts `null` for the primary dependency as a "not ready yet" signal: + +```tsx +const wallet = useConnectedWallet(); +const { data: balance } = useBalance(wallet?.account.address ?? null); +// balance is undefined; status is 'disabled'; no RPC traffic fires. +``` + +Mirror this in your own hooks built on `useLiveData` / `useSubscription` / `useRequest` — return `null` from the factory to disable. It matches the cache-library convention where a `null` key means "skip". + +### Retry and refresh + +`retry()` (live data) and `refresh()` (one-shot) are stable-identity functions on the return value. Pass them straight to an event handler — no `useCallback` wrapper needed: + +```tsx +const { error, retry } = useBalance(address); +if (error) return ; + +const { error, refresh } = useRequest(() => client.rpc.getEpochInfo(), [client]); +if (error) return ; +``` + +Retry on live data is end-to-end: Kit's store tears down the broken subscription, optionally re-runs the initial fetch, transitions through `retrying` with `data` preserved, and settles to `loaded` or `error`. + +### Abort and supersede + +For action hooks, the wrapped function's first argument is an `AbortSignal`. Thread it into `fetch`, RPC calls, or any async work that supports cancellation so a second `send` actually cancels the first: + +```tsx +const { send } = useAction( + async (signal, order: Order) => { + const signed = await signer.signTransactions([order.tx]); + return fetch(order.submitUrl, { body: signed, signal }).then(r => r.json()); + }, + [signer], +); +``` + +A superseded call's promise rejects with an `AbortError`. Use `isAbortError(err)` to distinguish it from real failures when you `await sendAsync(...)` — the fire-and-forget `send(...)` swallows them. + +### Errors + +Kit throws `SolanaError` with stable codes; hooks surface those errors through `error` as `unknown`. Narrow in render branches: + +```tsx +import { isSolanaError, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR } from '@solana/kit'; + +const { error, retry } = useBalance(address); +if (isSolanaError(error, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR)) { + return ( +
+ RPC unreachable. +
+ ); +} +``` + +Errors pass through unchanged — no re-wrapping — so the same narrowing code works from UI down to lower layers. + +### SSR + +Reactive hooks return `{ status: 'loading', data: undefined, isLoading: true }` on the server: no HTTP, no WebSocket, no auto-dispatch. The live store kicks in on first client render and hydration matches cleanly. The library does not prefetch on the server — on-chain state moves fast enough that any prefetched value usually mismatches the first client snapshot. diff --git a/packages/kit-react/package.json b/packages/kit-react/package.json new file mode 100644 index 0000000..c87b875 --- /dev/null +++ b/packages/kit-react/package.json @@ -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": "http://github.com/anza-xyz/kit-plugins/issues" + }, + "browserslist": [ + "supports bigint and not dead", + "maintained node versions" + ] +} diff --git a/packages/kit-react/src/client-capability.ts b/packages/kit-react/src/client-capability.ts new file mode 100644 index 0000000..469a5f1 --- /dev/null +++ b/packages/kit-react/src/client-capability.ts @@ -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()`. + * + * @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`. + * @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>({ + * capability: 'rpc', + * hookName: 'useEpochInfo', + * providerHint: 'Install `solanaRpc()` or `solanaRpcConnection()` on the client.', + * }); + * return useRequest(() => client.rpc.getEpochInfo(), [client]); + * } + * ``` + * + * @see {@link useClient} + */ +export function useClientCapability(options: UseClientCapabilityOptions): Client { + const client = useClient(); + 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; +} diff --git a/packages/kit-react/src/client-context.tsx b/packages/kit-react/src/client-context.tsx new file mode 100644 index 0000000..e18fc5f --- /dev/null +++ b/packages/kit-react/src/client-context.tsx @@ -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 | null>(null); +ClientContext.displayName = 'KitClientContext'; + +/** + * React context carrying the chain identifier configured on the nearest + * {@link KitClientProvider}. + * + * @see {@link useChain} + */ +export const ChainContext = createContext(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 `` 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 | Promise>; + /** + * 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 `` 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 ( + * + * + * + * ); + * } + * ``` + * + * @see {@link useClient} + * @see {@link useChain} + */ +export function KitClientProvider({ chain, children, client }: KitClientProviderProps) { + const resolved = isPromiseLike(client) ? usePromise(client) : client; + const tree = {children}; + return chain ? {tree} : tree; +} + +function isPromiseLike(value: T | Promise): value is Promise { + 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 + // 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` 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`. + * @throws An `Error` if no {@link KitClientProvider} ancestor is present. + * + * @example + * ```tsx + * const client = useClient>(); + * const epoch = await client.rpc.getEpochInfo().send(); + * ``` + * + * @see {@link useClientCapability} + * @see {@link useChain} + */ +export function useClient(): Client { + const client = useContext(ClientContext); + if (client === null) { + throw new Error('useClient() must be called inside a .'); + } + return client as Client; +} + +/** + * 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 { + const chain = useContext(ChainContext); + if (chain === null) { + throw new Error('useChain() requires a chain to be set via the `chain` prop on .'); + } + return chain as T; +} diff --git a/packages/kit-react/src/errors.ts b/packages/kit-react/src/errors.ts new file mode 100644 index 0000000..e685081 --- /dev/null +++ b/packages/kit-react/src/errors.ts @@ -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}`); +} diff --git a/packages/kit-react/src/hooks/signers.ts b/packages/kit-react/src/hooks/signers.ts new file mode 100644 index 0000000..2041f55 --- /dev/null +++ b/packages/kit-react/src/hooks/signers.ts @@ -0,0 +1,114 @@ +import type { TransactionSigner } from '@solana/kit'; +import { useSyncExternalStore } from 'react'; + +import { useClientCapability } from '../client-capability'; + +/** + * Duck-type a reactive payer publisher. Plugins (e.g. wallet plugins) that + * reassign `client.payer` over time install a sibling `subscribeToPayer` + * so wallet-agnostic hooks can stay in sync without naming the producing + * plugin. Structural-only; kept local so `kit-react` does not require a + * runtime dep on any specific plugin. + */ +type ClientWithSubscribeToPayer = { + readonly subscribeToPayer: (listener: () => void) => () => void; +}; + +/** + * Duck-type a reactive identity publisher. See {@link ClientWithSubscribeToPayer} + * for the rationale. + */ +type ClientWithSubscribeToIdentity = { + readonly subscribeToIdentity: (listener: () => void) => () => void; +}; + +const NOOP_SUBSCRIBE: (listener: () => void) => () => void = () => () => {}; + +/** + * Coerces a throwing getter into a nullable read. Wallet-backed + * `client.payer` / `client.identity` getters throw when no wallet is + * connected; `useSyncExternalStore` propagates snapshot exceptions by + * unmounting the subtree, so the hook cannot read the getter directly. + * + * @internal + */ +function readOptional(read: () => T): NonNullable | null { + try { + return read() ?? null; + } catch { + return null; + } +} + +/** + * Returns the current fee payer (`client.payer`), or `null` when a + * reactive payer plugin is installed but no signer is currently + * available (e.g. wallet not yet connected). + * + * Re-renders reactively when the installing plugin advertises the + * `subscribeToPayer` convention (wallet plugins do this). Static plugins + * such as `payer()` or `signer()` from `@solana/kit-plugin-signer` + * install a fixed signer; this hook simply returns it. + * + * @return The current `TransactionSigner` on `client.payer`, or `null` + * when unavailable. + * @throws An `Error` if no payer plugin is installed at all (no `payer` + * key on the client). That's a mount-time configuration bug, not + * a runtime state. + * + * @example + * ```tsx + * const payer = usePayer(); + * if (!payer) return ; + * return
; + * ``` + * + * @see {@link useIdentity} + */ +export function usePayer(): TransactionSigner | null { + const client = useClientCapability & { payer: TransactionSigner }>({ + capability: 'payer', + hookName: 'usePayer', + providerHint: 'Install `payer()`, `signer()`, or a wallet plugin (e.g. `walletSigner()`) on the client.', + }); + const subscribe = client.subscribeToPayer ?? NOOP_SUBSCRIBE; + const getSnapshot = () => readOptional(() => client.payer); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** + * Returns the current identity signer (`client.identity`), or `null` when + * a reactive identity plugin is installed but no signer is currently + * available (e.g. wallet not yet connected). + * + * Re-renders reactively when the installing plugin advertises the + * `subscribeToIdentity` convention (wallet plugins do this). Static + * plugins such as `identity()` or `signer()` from + * `@solana/kit-plugin-signer` install a fixed signer; this hook simply + * returns it. + * + * @return The current `TransactionSigner` on `client.identity`, or `null` + * when unavailable. + * @throws An `Error` if no identity plugin is installed at all (no + * `identity` key on the client). That's a mount-time configuration + * bug, not a runtime state. + * + * @example + * ```tsx + * const identity = useIdentity(); + * if (!identity) return ; + * return
; + * ``` + * + * @see {@link usePayer} + */ +export function useIdentity(): TransactionSigner | null { + const client = useClientCapability & { identity: TransactionSigner }>({ + capability: 'identity', + hookName: 'useIdentity', + providerHint: 'Install `identity()`, `signer()`, or a wallet plugin (e.g. `walletSigner()`) on the client.', + }); + const subscribe = client.subscribeToIdentity ?? NOOP_SUBSCRIBE; + const getSnapshot = () => readOptional(() => client.identity); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/packages/kit-react/src/hooks/use-account.ts b/packages/kit-react/src/hooks/use-account.ts new file mode 100644 index 0000000..7761d4b --- /dev/null +++ b/packages/kit-react/src/hooks/use-account.ts @@ -0,0 +1,119 @@ +import type { + AccountNotificationsApi, + Address, + ClientWithRpc, + ClientWithRpcSubscriptions, + Decoder, + GetAccountInfoApi, + MaybeAccount, + MaybeEncodedAccount, +} from '@solana/kit'; +import { decodeAccount, parseBase64RpcAccount } from '@solana/kit'; + +import { useClientCapability } from '../client-capability'; +import { useIdentityChurnWarning } from '../internal/dev-warnings'; +import type { LiveQueryResult } from '../internal/live-query-result'; +import { createLiveDataSpec, useLiveData } from './use-live-data'; + +type AccountClient = ClientWithRpc & ClientWithRpcSubscriptions; + +/** + * Live-data builder for an on-chain account. + * + * When a `decoder` is supplied the data is decoded and returned as + * `MaybeAccount`; otherwise the raw `MaybeEncodedAccount` is + * returned. + * + * @typeParam TData - The decoded account shape when `decoder` is provided. + * @param client - A Kit client with `rpc` and `rpcSubscriptions` installed. + * @param address - The account address to observe. + * @param decoder - Optional decoder used to type the account's `data` field. + * @return A live-data spec pairing `getAccountInfo` with + * `accountNotifications`. + */ +export function createAccountLiveData( + client: AccountClient, + address: Address, + decoder?: Decoder, +) { + const mapValue = ( + value: Parameters[1], + ): MaybeAccount | MaybeEncodedAccount => { + const encoded = parseBase64RpcAccount(address, value); + return decoder ? decodeAccount(encoded, decoder) : encoded; + }; + return createLiveDataSpec({ + rpcRequest: client.rpc.getAccountInfo(address, { encoding: 'base64' }), + rpcSubscriptionRequest: client.rpcSubscriptions.accountNotifications(address, { encoding: 'base64' }), + rpcSubscriptionValueMapper: mapValue, + rpcValueMapper: mapValue, + }); +} + +/** + * Live account data for an address, returned in base64-encoded form. + * + * Combines `getAccountInfo` with `accountNotifications`, slot-tracked to + * prevent out-of-order updates. + * + * @param address - Account to observe, or `null` to disable the query. + * @return A {@link LiveQueryResult} carrying a `MaybeEncodedAccount`. + * @throws An `Error` at mount if no RPC provider is installed. + * + * @example + * ```tsx + * const { data: account, status } = useAccount(address); + * if (status === 'loaded' && account.exists) { + * console.log(account.data); // raw bytes + * } + * ``` + * + * @see {@link createAccountLiveData} + */ +export function useAccount(address: Address | null): LiveQueryResult; +/** + * Live account data for an address, decoded via the supplied `Decoder`. + * + * Combines `getAccountInfo` with `accountNotifications`, slot-tracked to + * prevent out-of-order updates. + * + * @typeParam TData - The decoded account shape. + * @param address - Account to observe, or `null` to disable the query. + * @param decoder - `Decoder` used to parse the account's raw data bytes. + * @return A {@link LiveQueryResult} carrying a typed `MaybeAccount`. + * @throws An `Error` at mount if no RPC provider is installed. + * + * @example + * ```tsx + * const { data: mint, status } = useAccount(mintAddress, getMintDecoder()); + * if (status === 'loaded' && mint.exists) { + * console.log(mint.data.supply); + * } + * ``` + */ +export function useAccount( + address: Address | null, + decoder: Decoder, +): LiveQueryResult>; +export function useAccount( + address: Address | null, + decoder?: Decoder, +): LiveQueryResult | MaybeEncodedAccount> { + const client = useClientCapability({ + capability: ['rpc', 'rpcSubscriptions'], + hookName: 'useAccount', + providerHint: 'Install `solanaRpc()` or `solanaRpcConnection()` on the client.', + }); + // `decoder` feeds into the `useLiveData` dep list, so an inline + // `getMintDecoder()` on every render would rebuild the store each time. + // Fire the dev-only identity-churn warning to flag that footgun. + useIdentityChurnWarning(decoder, { + argName: 'decoder', + hookName: 'useAccount', + remedy: 'Memoize the decoder at the module level (e.g. `const MINT_DECODER = getMintDecoder()`) or wrap in `useMemo`.', + }); + return useLiveData( + () => (address ? createAccountLiveData(client, address, decoder) : null), + [client, address, decoder], + ); +} diff --git a/packages/kit-react/src/hooks/use-action.ts b/packages/kit-react/src/hooks/use-action.ts new file mode 100644 index 0000000..d11aa58 --- /dev/null +++ b/packages/kit-react/src/hooks/use-action.ts @@ -0,0 +1,112 @@ +import { type DependencyList, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; + +import { createReactiveActionStore, type ReactiveActionState } from '../kit-prereqs'; + +/** + * State tracked by {@link useAction}. + * + * Also returned by the transaction hooks. Extends Kit's + * `ReactiveActionState` (which carries `data`, `error`, and + * `status`) with React-ergonomic additions: a stable `send(...)` function + * that maps to the underlying store's `dispatch`, a stable `reset()`, and + * `is*` booleans derived from `status` so consumers can pick whichever + * reads better at the call site. + * + * @typeParam TArgs - Tuple of argument types accepted by `send`. + * @typeParam TResult - Result type produced by each successful dispatch. + */ +export type ActionResult = ReactiveActionState & { + /** `true` when `status === 'error'`. */ + isError: boolean; + /** `true` when `status === 'idle'`. */ + isIdle: boolean; + /** `true` when `status === 'running'` — a call is in flight. */ + isRunning: boolean; + /** `true` when `status === 'success'`. */ + isSuccess: boolean; + /** Reset back to `idle`, aborting any in-flight call. Stable reference. */ + reset: () => void; + /** + * Fire-and-forget dispatch. Returns `undefined` synchronously and + * never throws — failures surface on `status` / `error`, and + * superseded or `reset()`-aborted calls produce no state update. Use + * from UI event handlers; there's no promise to handle or `.catch()`. + * Stable reference. + * + * @see {@link ActionResult.sendAsync} when you need the resolved + * value or propagated errors. + */ + send: (...args: TArgs) => void; + /** + * Promise-returning dispatch for imperative flows (e.g. navigate on + * success, post signed bytes to an API). Resolves with the operation's + * result; rejects on failure or with `AbortError` on supersede — filter + * the latter with {@link isAbortError}. Stable reference. + */ + sendAsync: (...args: TArgs) => Promise; +}; + +/** + * Tracks the async state of a user-triggered action. + * + * Backed by {@link createReactiveActionStore}: the state machine, + * abort-on-supersede, and stale-while-revalidate semantics live in the + * Kit primitive. The wrapped function receives an `AbortSignal` as its + * first argument so HTTP / RPC / wallet calls can be cancelled when + * superseded by a fresh `send` or by `reset()`. + * + * @typeParam TArgs - Tuple of argument types accepted by `send`. + * @typeParam TResult - Result type produced by each successful call. + * @param fn - The async operation to invoke on each `send`. Receives an + * `AbortSignal` plus the caller's args. + * @param deps - Dependency list controlling when the underlying store is + * rebuilt. Matches React's hook-dep conventions. + * @return An {@link ActionResult} with `send`, `sendAsync`, `reset`, + * `status`, `data`, `error`, and `is*` booleans. + * + * @example + * ```tsx + * const signMessage = useAction( + * async (signal, message: Uint8Array) => signer.signMessage(message, { abortSignal: signal }), + * [signer], + * ); + * const signature = await signMessage.sendAsync(new TextEncoder().encode('hello')); + * ``` + * + * @see {@link ActionResult} + * @see {@link isAbortError} + */ +export function useAction( + fn: (signal: AbortSignal, ...args: TArgs) => Promise, + deps: DependencyList = [], +): ActionResult { + // Latest-ref pattern: the store below is built once per `deps` change, but + // the operation it wraps must always call the freshest `fn` closure so it + // sees up-to-date values captured from the caller's render. Storing `fn` + // in a ref and updating it after every render keeps the store stable + // across renders without stale closures. + const fnRef = useRef(fn); + useEffect(() => { + fnRef.current = fn; + }); + const store = useMemo( + () => createReactiveActionStore((signal, ...args) => fnRef.current(signal, ...args)), + deps, + ); + const snapshot = useSyncExternalStore(store.subscribe, store.getState, store.getState); + return useMemo( + () => ({ + data: snapshot.data, + error: snapshot.error, + isError: snapshot.status === 'error', + isIdle: snapshot.status === 'idle', + isRunning: snapshot.status === 'running', + isSuccess: snapshot.status === 'success', + reset: store.reset, + send: store.dispatch, + sendAsync: store.dispatchAsync, + status: snapshot.status, + }), + [snapshot, store], + ); +} diff --git a/packages/kit-react/src/hooks/use-balance.ts b/packages/kit-react/src/hooks/use-balance.ts new file mode 100644 index 0000000..ac61c79 --- /dev/null +++ b/packages/kit-react/src/hooks/use-balance.ts @@ -0,0 +1,65 @@ +import type { + AccountNotificationsApi, + Address, + ClientWithRpc, + ClientWithRpcSubscriptions, + GetBalanceApi, + Lamports, +} from '@solana/kit'; + +import { useClientCapability } from '../client-capability'; +import type { LiveQueryResult } from '../internal/live-query-result'; +import { createLiveDataSpec, useLiveData } from './use-live-data'; + +type BalanceClient = ClientWithRpc & ClientWithRpcSubscriptions; + +/** + * Live-data builder for the SOL balance of an address. + * + * The returned spec is cache-library agnostic: it works with + * {@link useLiveData} and, in the adapter packages, with `useLiveSwr` / + * `useLiveQuery`. + * + * @param client - A Kit client with `rpc` and `rpcSubscriptions` installed. + * @param address - The account whose SOL balance should be observed. + * @return A live-data spec pairing `getBalance` with + * `accountNotifications`. + */ +export function createBalanceLiveData(client: BalanceClient, address: Address) { + return createLiveDataSpec({ + rpcRequest: client.rpc.getBalance(address), + rpcSubscriptionRequest: client.rpcSubscriptions.accountNotifications(address), + rpcSubscriptionValueMapper: notification => notification.lamports, + rpcValueMapper: value => value, + }); +} + +/** + * Live SOL balance for an address. + * + * Combines `getBalance` with slot-tracked `accountNotifications` so later + * updates supersede earlier ones regardless of arrival order. + * + * @param address - Account to observe, or `null` to disable the query. + * @return A {@link LiveQueryResult} carrying the current balance, status, + * error, slot, and a `retry` affordance. + * @throws An `Error` at mount if no RPC provider is installed. + * + * @example + * ```tsx + * const { data, status, retry } = useBalance(address); + * if (status === 'loading') return ; + * if (status === 'error') return ; + * return ; + * ``` + * + * @see {@link createBalanceLiveData} + */ +export function useBalance(address: Address | null): LiveQueryResult { + const client = useClientCapability({ + capability: ['rpc', 'rpcSubscriptions'], + hookName: 'useBalance', + providerHint: 'Install `solanaRpc()` or `solanaRpcConnection()` on the client.', + }); + return useLiveData(() => (address ? createBalanceLiveData(client, address) : null), [client, address]); +} diff --git a/packages/kit-react/src/hooks/use-live-data.ts b/packages/kit-react/src/hooks/use-live-data.ts new file mode 100644 index 0000000..59273e5 --- /dev/null +++ b/packages/kit-react/src/hooks/use-live-data.ts @@ -0,0 +1,124 @@ +import type { PendingRpcRequest, PendingRpcSubscriptionsRequest, SolanaRpcResponse } from '@solana/kit'; +import { createReactiveStoreWithInitialValueAndSlotTracking } from '@solana/kit'; +import type { DependencyList } from 'react'; + +import { liftToStreamStore } from '../internal/lift-to-stream-store'; +import { type LiveQueryResult, useLiveQueryResult } from '../internal/live-query-result'; +import { disabledLiveStore, nullLiveStore, useLiveStore } from '../internal/live-store'; +import type { ReactiveStreamStore } from '../kit-prereqs'; + +/** + * Cache-library-agnostic spec for an RPC + subscription paired data source. + * + * Consumed by {@link useLiveData} and (in the adapter packages) by the + * cache-library bridges. Plugin authors ship + * `createLiveData(client, ...args)` builders returning this + * shape so the same data definition works with core, SWR, and TanStack. + * + * @typeParam T - The unified value type stored and emitted by the live query. + * @typeParam TRpcValue - The raw value returned by the RPC fetch. Defaults + * to `unknown` when the mapper does the typing. + * @typeParam TSubscriptionValue - The raw value emitted by the subscription. + * Defaults to `unknown`. + */ +export type LiveDataSpec = Readonly<{ + /** Pending RPC request used to seed the store with an initial value. */ + rpcRequest: PendingRpcRequest>; + /** Pending subscription request used to keep the store up to date. */ + rpcSubscriptionRequest: PendingRpcSubscriptionsRequest>; + /** Maps a subscription notification's value to the unified `T`. */ + rpcSubscriptionValueMapper: (value: TSubscriptionValue) => T; + /** Maps the RPC response's value to the unified `T`. */ + rpcValueMapper: (value: TRpcValue) => T; +}>; + +/** + * Identity helper that constructs a {@link LiveDataSpec} with `TRpcValue` + * and `TSubscriptionValue` inferred from the provided RPC and + * subscription requests. + * + * Prefer this over the bare object literal when writing a + * `createLiveData` builder: inline mapper parameters get typed + * from the actual RPC / subscription return types, instead of falling + * back to `unknown` when `LiveDataSpec` is used with defaulted + * generics. + * + * @typeParam TRpcValue - Inferred from `rpcRequest`. + * @typeParam TSubscriptionValue - Inferred from `rpcSubscriptionRequest`. + * @typeParam T - Inferred from the mapper return types. + * @param spec - The live-data spec. + * @return The same spec, with all three generics threaded through. + * + * @example + * ```ts + * export function createBalanceLiveData(client: BalanceClient, address: Address) { + * return createLiveDataSpec({ + * rpcRequest: client.rpc.getBalance(address), + * rpcSubscriptionRequest: client.rpcSubscriptions.accountNotifications(address), + * rpcValueMapper: (value) => value, // value: Lamports + * rpcSubscriptionValueMapper: ({ lamports }) => lamports, // typed from notification + * }); + * } + * ``` + */ +export function createLiveDataSpec( + spec: LiveDataSpec, +): LiveDataSpec { + return spec; +} + +/** + * Generic live-data hook for any RPC + subscription pair not covered by + * a named hook. + * + * Handles store creation, slot-based dedup, abort, and cleanup. Return + * `null` from `buildSpec` to disable the query. + * + * @typeParam T - The unified value type returned as `data`. + * @typeParam TRpcValue - The raw RPC value type. Defaults to `unknown`. + * @typeParam TSubscriptionValue - The raw subscription value type. Defaults + * to `unknown`. + * @param buildSpec - Factory returning a {@link LiveDataSpec}, or `null` + * to disable. Runs when `deps` change. + * @param deps - Dependency list matching React's hook-dep conventions. + * @return A {@link LiveQueryResult} carrying the current value, status, + * error, slot, and a `retry` affordance. + * + * @example + * ```tsx + * const { data } = useLiveData( + * () => ({ + * rpcRequest: client.rpc.getAccountInfo(gameAddress), + * rpcSubscriptionRequest: client.rpcSubscriptions.accountNotifications(gameAddress), + * rpcValueMapper: (v) => parseGameState(v.value), + * rpcSubscriptionValueMapper: (v) => parseGameState(v), + * }), + * [client, gameAddress], + * ); + * ``` + * + * @see {@link LiveDataSpec} + * @see {@link useSubscription} + */ +export function useLiveData( + buildSpec: () => LiveDataSpec | null, + deps: DependencyList, +): LiveQueryResult { + const store = useLiveStore>>( + signal => { + const spec = buildSpec(); + if (spec === null) return disabledLiveStore(); + const inner = createReactiveStoreWithInitialValueAndSlotTracking({ + abortSignal: signal, + rpcRequest: spec.rpcRequest, + rpcSubscriptionRequest: spec.rpcSubscriptionRequest, + rpcSubscriptionValueMapper: spec.rpcSubscriptionValueMapper, + rpcValueMapper: spec.rpcValueMapper, + }); + return liftToStreamStore(inner); + }, + () => nullLiveStore>(), + deps, + ); + return useLiveQueryResult(store); +} diff --git a/packages/kit-react/src/hooks/use-plan-transaction.ts b/packages/kit-react/src/hooks/use-plan-transaction.ts new file mode 100644 index 0000000..0924fc4 --- /dev/null +++ b/packages/kit-react/src/hooks/use-plan-transaction.ts @@ -0,0 +1,76 @@ +import type { ClientWithTransactionPlanning } from '@solana/kit'; + +import { useClientCapability } from '../client-capability'; +import { type ActionResult, useAction } from './use-action'; + +type PlanTransactionArgs = Parameters; +type PlanTransactionResult = Awaited>; +type PlanTransactionsArgs = Parameters; +type PlanTransactionsResult = Awaited>; + +/** + * Plans a single transaction without sending it. + * + * Useful for preview-then-send UX (confirmation modal showing fees or + * writable accounts). The planned message can be consumed from `data` + * (declarative render flow) or by awaiting `sendAsync` (imperative + * flow), then handed off to {@link useSendTransaction} once confirmed. + * + * @return An {@link ActionResult} whose `send` / `sendAsync` forward to + * `client.planTransaction` and produce the planned message. + * @throws An `Error` at mount if no provider installing + * `client.planTransaction` is present. + * + * @example + * ```tsx + * const { sendAsync: plan } = usePlanTransaction(); + * const message = await plan(instructions); + * ``` + * + * @see {@link usePlanTransactions} + * @see {@link useSendTransaction} + */ +export function usePlanTransaction(): ActionResult { + const client = useClientCapability({ + capability: 'planTransaction', + hookName: 'usePlanTransaction', + providerHint: 'Install `solanaRpc()` or `litesvm()` on the client.', + }); + return useAction( + (signal, input, config) => client.planTransaction(input, { ...config, abortSignal: signal }), + [client], + ); +} + +/** + * Plans one or more transactions without sending them. + * + * Multi-transaction counterpart to {@link usePlanTransaction}. Produces + * the full `TransactionPlan` covering every prepared transaction, which + * can then be sent via {@link useSendTransactions}. + * + * @return An {@link ActionResult} whose `send` / `sendAsync` forward to + * `client.planTransactions`. + * @throws An `Error` at mount if no provider installing + * `client.planTransactions` is present. + * + * @example + * ```tsx + * const { sendAsync: planMany } = usePlanTransactions(); + * const plan = await planMany(largeInstructionPlan); + * ``` + * + * @see {@link usePlanTransaction} + * @see {@link useSendTransactions} + */ +export function usePlanTransactions(): ActionResult { + const client = useClientCapability({ + capability: 'planTransactions', + hookName: 'usePlanTransactions', + providerHint: 'Install `solanaRpc()` or `litesvm()` on the client.', + }); + return useAction( + (signal, input, config) => client.planTransactions(input, { ...config, abortSignal: signal }), + [client], + ); +} diff --git a/packages/kit-react/src/hooks/use-request.ts b/packages/kit-react/src/hooks/use-request.ts new file mode 100644 index 0000000..3f832f6 --- /dev/null +++ b/packages/kit-react/src/hooks/use-request.ts @@ -0,0 +1,73 @@ +import type { PendingRpcRequest } from '@solana/kit'; +import type { DependencyList } from 'react'; + +import { useLiveStore } from '../internal/live-store'; +import { disabledActionStore, type RequestResult, useRequestResult } from '../internal/request-result'; +import { type ReactiveActionStore, reactiveStoreFromPendingRequest } from '../kit-prereqs'; + +/** + * Anything that produces a `ReactiveActionStore<[], T>` via `.reactiveStore()`, + * or a `PendingRpcRequest` that the hook wraps internally. + * + * Accepting both shapes means `useRequest(() => client.rpc.getEpochInfo())` + * works today — `PendingRpcRequest` is adapted via an internal shim + * (see `kit-prereqs/pending-rpc-request.ts`). Plugin-authored pending + * objects that expose `.reactiveStore()` plug in without wrapping. Once + * Kit ships a native `.reactiveStore()` on `PendingRpcRequest`, the union + * collapses to the second branch — the general `{ reactiveStore() }` + * shape that any source (Kit, plugin-authored, or user-built) satisfies. + * + * @typeParam T - The value resolved by each dispatch. + */ +export type ReactiveActionSource = + | PendingRpcRequest + | { + reactiveStore(): ReactiveActionStore<[], T>; + }; + +/** + * Fires a one-shot request on mount and whenever `deps` change. + * + * Returns reactive state tracking the call's lifecycle. Stale-while- + * revalidate: during a re-dispatch after a prior success, `data` still + * holds the old value and `status` reports `'retrying'`. Return `null` + * from `factory` to disable: no request fires, and `status` reports + * `'disabled'`. + * + * @typeParam T - The value resolved by the request. + * @param factory - Factory returning a {@link ReactiveActionSource}, or + * `null` to disable. Runs when `deps` change. + * @param deps - Dependency list matching React's hook-dep conventions. + * @return A {@link RequestResult} carrying `data`, `error`, `status`, + * `isLoading`, and a stable `refresh` function. + * + * @example + * ```tsx + * const { data: epoch, refresh } = useRequest( + * () => client.rpc.getEpochInfo(), + * [client], + * ); + * ``` + * + * @see {@link useLiveData} + * @see {@link useAction} + */ +export function useRequest( + factory: (signal: AbortSignal) => ReactiveActionSource | null, + deps: DependencyList, +): RequestResult { + const store = useLiveStore>( + signal => { + const pending = factory(signal); + if (pending === null) return disabledActionStore(); + // TODO(kit#1555): once Kit ships `.reactiveStore()` on + // `PendingRpcRequest`, collapse this branch — both shapes of + // `ReactiveActionSource` will satisfy the native-method arm. + if ('reactiveStore' in pending) return pending.reactiveStore(); + return reactiveStoreFromPendingRequest(pending); + }, + () => disabledActionStore(), + deps, + ); + return useRequestResult(store); +} diff --git a/packages/kit-react/src/hooks/use-send-transaction.ts b/packages/kit-react/src/hooks/use-send-transaction.ts new file mode 100644 index 0000000..3d10b63 --- /dev/null +++ b/packages/kit-react/src/hooks/use-send-transaction.ts @@ -0,0 +1,81 @@ +import type { ClientWithTransactionSending } from '@solana/kit'; + +import { useClientCapability } from '../client-capability'; +import { type ActionResult, useAction } from './use-action'; + +type SendTransactionArgs = Parameters; +type SendTransactionResult = Awaited>; +type SendTransactionsArgs = Parameters; +type SendTransactionsResult = Awaited>; + +/** + * Sends a single transaction with abort and state tracking. + * + * Accepts any input supported by `client.sendTransaction` (instructions, + * instruction plans, pre-built transaction messages, or single-transaction + * plans). Double-click supersedes: a second `send(...)` while the first + * is in flight aborts the first via its per-dispatch `AbortSignal`. + * + * @return An {@link ActionResult} whose `send` forwards to + * `client.sendTransaction` and whose state tracks the send. + * @throws An `Error` at mount if no provider installing + * `client.sendTransaction` is present. + * + * @example + * ```tsx + * const { send, isRunning, data, error } = useSendTransaction(); + * // Fire-and-forget from an event handler: + * + * ``` + * + * @see {@link useSendTransactions} + * @see {@link usePlanTransaction} + */ +export function useSendTransaction(): ActionResult { + const client = useClientCapability({ + capability: 'sendTransaction', + hookName: 'useSendTransaction', + providerHint: + 'Install `solanaRpc()` or `litesvm()` on the client (or another plugin that installs transaction execution).', + }); + return useAction( + (signal, input, config) => client.sendTransaction(input, { ...config, abortSignal: signal }), + [client], + ); +} + +/** + * Sends one or more transactions, batching as the plan requires. + * + * Multi-transaction counterpart to {@link useSendTransaction}. Accepts the + * same flexible input shape but resolves to the full `TransactionPlanResult` + * covering every submitted transaction. + * + * @return An {@link ActionResult} whose `send` forwards to + * `client.sendTransactions` and whose state tracks the send. + * @throws An `Error` at mount if no provider installing + * `client.sendTransactions` is present. + * + * @example + * ```tsx + * const { send, isRunning } = useSendTransactions(); + * + * ``` + * + * @see {@link useSendTransaction} + */ +export function useSendTransactions(): ActionResult { + const client = useClientCapability({ + capability: 'sendTransactions', + hookName: 'useSendTransactions', + providerHint: 'Install `solanaRpc()` or `litesvm()` on the client.', + }); + return useAction( + (signal, input, config) => client.sendTransactions(input, { ...config, abortSignal: signal }), + [client], + ); +} diff --git a/packages/kit-react/src/hooks/use-subscription.ts b/packages/kit-react/src/hooks/use-subscription.ts new file mode 100644 index 0000000..6ef46ea --- /dev/null +++ b/packages/kit-react/src/hooks/use-subscription.ts @@ -0,0 +1,79 @@ +import type { PendingRpcSubscriptionsRequest } from '@solana/kit'; +import type { DependencyList } from 'react'; + +import type { LiveQueryResult } from '../internal/live-query-result'; +import { disabledLiveStore, nullLiveStore, useLiveStore } from '../internal/live-store'; +import { type UnwrapRpcResponse, useSubscriptionResult } from '../internal/subscription-result'; +import { reactiveStoreFromPendingSubscriptionsRequest, type ReactiveStreamStore } from '../kit-prereqs'; + +/** + * Anything that produces a `ReactiveStreamStore` via + * `.reactiveStore({ abortSignal })`, or a `PendingRpcSubscriptionsRequest` + * that the hook wraps internally. + * + * Accepting both shapes means + * `useSubscription(() => client.rpcSubscriptions.slotNotifications())` + * works today — `PendingRpcSubscriptionsRequest` is adapted via an + * internal shim (see `kit-prereqs/pending-rpc-subs-request.ts`). + * Plugin-authored streaming objects that already expose `.reactiveStore()` + * plug in without wrapping. Once Kit ships a native sync + * `.reactiveStore()` on `PendingRpcSubscriptionsRequest`, the union + * collapses to the second branch — the general + * `{ reactiveStore({ abortSignal }) }` shape that any source (Kit, + * plugin-authored, or user-built) satisfies. + * + * @typeParam T - The notification value type emitted by the stream. + */ +export type ReactiveStreamSource = + | PendingRpcSubscriptionsRequest + | { + reactiveStore(options: { abortSignal: AbortSignal }): ReactiveStreamStore; + }; + +/** + * Subscribes to a stream and surfaces the latest notification in the same + * `LiveQueryResult` shape as the named hooks. + * + * Accepts any object with `.reactiveStore({ abortSignal })` (typically a + * `PendingRpcSubscriptionsRequest`). Return `null` from `factory` to + * disable: no RPC traffic fires, and `status` reports `'disabled'`. + * Subscriptions that emit `SolanaRpcResponse`-shaped notifications + * (account, program, signature) are unwrapped so `data` is the inner + * value and `slot` is populated. Raw-value subscriptions pass through + * with `slot: undefined`. + * + * @typeParam T - The notification value type emitted by the source. + * @param factory - Factory returning a {@link ReactiveStreamSource}, or + * `null` to disable. Runs when `deps` change. + * @param deps - Dependency list matching React's hook-dep conventions. + * @return A {@link LiveQueryResult} whose `data` is `T` unwrapped of any + * `SolanaRpcResponse` envelope. + * + * @example + * ```tsx + * const { data: slot, error, retry } = useSubscription( + * () => client.rpcSubscriptions.slotNotifications(), + * [client], + * ); + * ``` + * + * @see {@link useLiveData} + */ +export function useSubscription( + factory: (signal: AbortSignal) => ReactiveStreamSource | null, + deps: DependencyList, +): LiveQueryResult> { + const store = useLiveStore>( + signal => { + const pending = factory(signal); + if (pending === null) return disabledLiveStore(); + // TODO(kit#1553): once Kit ships a sync `.reactiveStore()` on + // `PendingRpcSubscriptionsRequest`, collapse this branch. + if ('reactiveStore' in pending) return pending.reactiveStore({ abortSignal: signal }); + return reactiveStoreFromPendingSubscriptionsRequest(pending, { abortSignal: signal }); + }, + () => nullLiveStore(), + deps, + ); + return useSubscriptionResult(store); +} diff --git a/packages/kit-react/src/hooks/use-transaction-confirmation.ts b/packages/kit-react/src/hooks/use-transaction-confirmation.ts new file mode 100644 index 0000000..21e87af --- /dev/null +++ b/packages/kit-react/src/hooks/use-transaction-confirmation.ts @@ -0,0 +1,99 @@ +import type { + ClientWithRpc, + ClientWithRpcSubscriptions, + Commitment, + GetSignatureStatusesApi, + Signature, + SignatureNotificationsApi, + TransactionError, +} from '@solana/kit'; + +import { useClientCapability } from '../client-capability'; +import type { LiveQueryResult } from '../internal/live-query-result'; +import { createLiveDataSpec, useLiveData } from './use-live-data'; + +type ConfirmationClient = ClientWithRpc & + ClientWithRpcSubscriptions; + +/** Snapshot of a transaction's confirmation state. */ +export type TransactionConfirmationStatus = { + confirmationStatus: Commitment | null; + err: TransactionError | null; +}; + +/** Options for {@link useTransactionConfirmation}. */ +export type UseTransactionConfirmationOptions = Readonly<{ + /** Target commitment; defaults to `'confirmed'`. */ + commitment?: Commitment; +}>; + +/** + * Live-data builder for the confirmation status of a single signature. + * + * @param client - A Kit client with `rpc` and `rpcSubscriptions` installed. + * @param signature - Transaction signature to observe. + * @param commitment - Commitment level for the underlying subscription. + * @return A live-data spec pairing `getSignatureStatuses` with + * `signatureNotifications`. + */ +export function createTransactionConfirmationLiveData( + client: ConfirmationClient, + signature: Signature, + commitment: Commitment, +) { + return createLiveDataSpec({ + rpcRequest: client.rpc.getSignatureStatuses([signature]), + rpcSubscriptionRequest: client.rpcSubscriptions.signatureNotifications(signature, { commitment }), + rpcSubscriptionValueMapper: (notification): TransactionConfirmationStatus => ({ + confirmationStatus: commitment, + err: notification.err, + }), + rpcValueMapper: (statuses): TransactionConfirmationStatus => { + const status = statuses[0]; + if (!status) return { confirmationStatus: null, err: null }; + return { + confirmationStatus: status.confirmationStatus, + err: status.err, + }; + }, + }); +} + +/** + * Live confirmation status for a transaction signature. + * + * Combines `getSignatureStatuses` with `signatureNotifications` so later + * updates supersede earlier ones regardless of arrival order. + * + * @param signature - Signature to observe, or `null` to disable the query + * (e.g. before a transaction is sent). + * @param options - Optional overrides, such as the target `commitment`. + * @return A {@link LiveQueryResult} carrying the current confirmation + * snapshot, status, error, slot, and a `retry` affordance. + * @throws An `Error` at mount if no RPC provider is installed. + * + * @example + * ```tsx + * const { data, status } = useTransactionConfirmation(signature, { + * commitment: 'finalized', + * }); + * if (data?.confirmationStatus === 'finalized') { ... } + * ``` + * + * @see {@link createTransactionConfirmationLiveData} + */ +export function useTransactionConfirmation( + signature: Signature | null, + options?: UseTransactionConfirmationOptions, +): LiveQueryResult { + const client = useClientCapability({ + capability: ['rpc', 'rpcSubscriptions'], + hookName: 'useTransactionConfirmation', + providerHint: 'Install `solanaRpc()` or `solanaRpcConnection()` on the client.', + }); + const commitment = options?.commitment ?? 'confirmed'; + return useLiveData( + () => (signature ? createTransactionConfirmationLiveData(client, signature, commitment) : null), + [client, signature, commitment], + ); +} diff --git a/packages/kit-react/src/index.ts b/packages/kit-react/src/index.ts new file mode 100644 index 0000000..7339092 --- /dev/null +++ b/packages/kit-react/src/index.ts @@ -0,0 +1,41 @@ +// Client + chain context +export { + ChainContext, + ClientContext, + KitClientProvider, + useChain, + useClient, + type ChainIdentifier, + type KitClientProviderProps, + type SolanaChain, +} from './client-context'; + +// Runtime-checked capability narrowing +export { useClientCapability, type UseClientCapabilityOptions } from './client-capability'; + +// Errors +export { isAbortError } from './kit-prereqs'; + +// Signer hooks +export { useIdentity, usePayer } from './hooks/signers'; + +// Live data +export { useBalance, createBalanceLiveData } from './hooks/use-balance'; +export { useAccount, createAccountLiveData } from './hooks/use-account'; +export { + createTransactionConfirmationLiveData, + useTransactionConfirmation, + type TransactionConfirmationStatus, + type UseTransactionConfirmationOptions, +} from './hooks/use-transaction-confirmation'; +export { createLiveDataSpec, useLiveData, type LiveDataSpec } from './hooks/use-live-data'; +export { useSubscription, type ReactiveStreamSource } from './hooks/use-subscription'; +export { useRequest, type ReactiveActionSource } from './hooks/use-request'; +export type { LiveQueryResult } from './internal/live-query-result'; +export type { UnwrapRpcResponse } from './internal/subscription-result'; +export type { RequestResult } from './internal/request-result'; + +// Actions +export { useAction, type ActionResult } from './hooks/use-action'; +export { useSendTransaction, useSendTransactions } from './hooks/use-send-transaction'; +export { usePlanTransaction, usePlanTransactions } from './hooks/use-plan-transaction'; diff --git a/packages/kit-react/src/internal/dev-warnings.ts b/packages/kit-react/src/internal/dev-warnings.ts new file mode 100644 index 0000000..d45a8c2 --- /dev/null +++ b/packages/kit-react/src/internal/dev-warnings.ts @@ -0,0 +1,41 @@ +import { useEffect, useRef } from 'react'; + +export type UseIdentityChurnWarningOptions = Readonly<{ + /** Short name of the argument being watched (e.g. `"decoder"`). */ + argName: string; + /** Name of the surrounding hook (e.g. `"useAccount"`). */ + hookName: string; + /** Free-text remedy shown after the churn warning. */ + remedy?: string; +}>; + +/** + * Dev-only warning that fires when a value's reference identity changes on + * every render. Used to surface accidental inline allocations in hook + * arguments (e.g. an inline `getMintDecoder()` call passed to + * `useAccount`) that would force the underlying reactive store to rebuild + * each render. Silent in production builds. + * + * @internal + */ +export function useIdentityChurnWarning( + value: T, + { argName, hookName, remedy }: UseIdentityChurnWarningOptions, +): void { + const previousRef = useRef(value); + const changeCountRef = useRef(0); + useEffect(() => { + if (process.env.NODE_ENV === 'production') return; + if (previousRef.current !== value) { + previousRef.current = value; + changeCountRef.current += 1; + if (changeCountRef.current >= 3) { + const base = `${hookName}: the \`${argName}\` argument has changed identity ${changeCountRef.current} times.`; + const hint = + remedy ?? + `Wrap the value in \`useMemo\` (or hoist to module scope) so the hook only rebuilds when it meaningfully changes.`; + console.warn(`${base} ${hint}`); + } + } + }, [value, argName, hookName, remedy]); +} diff --git a/packages/kit-react/src/internal/lift-to-stream-store.ts b/packages/kit-react/src/internal/lift-to-stream-store.ts new file mode 100644 index 0000000..355baf9 --- /dev/null +++ b/packages/kit-react/src/internal/lift-to-stream-store.ts @@ -0,0 +1,56 @@ +import type { ReactiveStore } from '@solana/subscribable'; + +import type { ReactiveStreamState, ReactiveStreamStore } from '../kit-prereqs'; + +/** + * Adapts Kit's current `{ subscribe, getState, getError }` + * {@link ReactiveStore} into a {@link ReactiveStreamStore} by synthesising + * `getUnifiedState` from `getState` + `getError` and accepting an + * externally-provided `retry`. + * + * This helper is not destined for Kit. When kit#1552 lands, + * `createReactiveStoreWithInitialValueAndSlotTracking` will return the + * full {@link ReactiveStreamStore} shape directly; this whole adapter + * gets deleted at that point. + * + * @typeParam T - The value type emitted by the underlying store. + * @param store - Kit's current stream-shaped reactive store. + * @param retry - Optional `retry` implementation. Defaults to a no-op + * because Kit's current store has no retry affordance; + * callers that control the factory can pass their own. + * @return A {@link ReactiveStreamStore} wrapping the input. + * + * @internal + */ +export function liftToStreamStore(store: ReactiveStore, retry: () => void = () => {}): ReactiveStreamStore { + // Cache the last returned snapshot so repeated reads (as `useSyncExternalStore` does each render) + // return a reference-equal object when nothing has changed. Without this, React treats every read + // as a new value and re-renders in a loop. + let cachedData: T | undefined; + let cachedError: unknown; + let cachedState: ReactiveStreamState | undefined; + const getUnifiedState = (): ReactiveStreamState => { + const error = store.getError(); + const data = store.getState(); + if (cachedState && cachedData === data && cachedError === error) { + return cachedState; + } + cachedData = data; + cachedError = error; + if (error !== undefined) { + cachedState = { data, error, status: 'error' }; + } else if (data === undefined) { + cachedState = { data: undefined, error: undefined, status: 'loading' }; + } else { + cachedState = { data, error: undefined, status: 'loaded' }; + } + return cachedState; + }; + return { + getError: () => store.getError(), + getState: () => store.getState(), + getUnifiedState, + retry, + subscribe: (listener: () => void) => store.subscribe(listener), + }; +} diff --git a/packages/kit-react/src/internal/live-query-result.ts b/packages/kit-react/src/internal/live-query-result.ts new file mode 100644 index 0000000..2505166 --- /dev/null +++ b/packages/kit-react/src/internal/live-query-result.ts @@ -0,0 +1,73 @@ +import type { Slot, SolanaRpcResponse } from '@solana/kit'; +import { useMemo, useSyncExternalStore } from 'react'; + +import type { ReactiveStreamState, ReactiveStreamStore } from '../kit-prereqs'; +import { isDisabledStore, NOOP_RETRY } from './live-store'; + +/** + * Shape returned by the named live-data hooks and by + * {@link useLiveData}. Reactive, read-only snapshot drawn from the + * underlying {@link ReactiveStreamStore}. + */ +export type LiveQueryResult = { + /** + * The current value. `undefined` while loading or when disabled. On + * `error` and `retrying` holds the last known value (if any) so UIs + * can show stale data with an overlay rather than flashing to blank. + */ + data: T | undefined; + /** Error from the fetch or subscription, or `undefined`. */ + error: unknown; + /** + * Convenience shorthand for `status === 'loading'`. A disabled query + * reports `false`, matching react-query / SWR semantics when the key + * is `null`. `retrying` also reports `false` because `data` still + * holds the last known value. + */ + isLoading: boolean; + /** Re-open the stream after an error. Stable reference. */ + retry: () => void; + /** + * The slot `data` was observed at. `undefined` while loading, when + * disabled, on server render, or when only an error has arrived. + */ + slot: Slot | undefined; + /** Lifecycle status drawn from `ReactiveStreamStore` plus a `'disabled'` kit-react variant. */ + status: 'disabled' | 'error' | 'loaded' | 'loading' | 'retrying'; +}; + +/** + * Bridge for named hooks whose stores come from + * `createReactiveStoreWithInitialValueAndSlotTracking` — `data` is a + * `SolanaRpcResponse` envelope (value + slot context). + * + * @internal + */ +export function useLiveQueryResult(store: ReactiveStreamStore>): LiveQueryResult { + const state: ReactiveStreamState> = useSyncExternalStore( + store.subscribe, + store.getUnifiedState, + store.getUnifiedState, + ); + const disabled = isDisabledStore(store); + return useMemo(() => { + if (disabled) { + return { + data: undefined, + error: undefined, + isLoading: false, + retry: NOOP_RETRY, + slot: undefined, + status: 'disabled', + }; + } + return { + data: state.data?.value, + error: state.error, + isLoading: state.status === 'loading', + retry: store.retry, + slot: state.data?.context.slot, + status: state.status, + }; + }, [state, disabled, store]); +} diff --git a/packages/kit-react/src/internal/live-store.ts b/packages/kit-react/src/internal/live-store.ts new file mode 100644 index 0000000..de77ee1 --- /dev/null +++ b/packages/kit-react/src/internal/live-store.ts @@ -0,0 +1,94 @@ +import { type DependencyList, useEffect, useMemo, useRef } from 'react'; + +import type { ReactiveStreamState, ReactiveStreamStore } from '../kit-prereqs'; +import { IS_LIVE_CLIENT } from './ssr'; + +/** + * Sentinel symbol marking a store as "intentionally off" (the null-gate + * pattern used by `useBalance(null)`, `useAccount(null)`, etc.). The + * bridges ({@link useLiveQueryResult}, {@link useSubscriptionResult}) detect + * the tag and report `status: 'disabled'` instead of `'loading'`. + * + * @internal + */ +export const DISABLED = Symbol('DisabledLiveStore'); + +/** @internal */ +export const NOOP_RETRY = () => {}; +/** @internal */ +export const NOOP_UNSUBSCRIBE = () => {}; + +const LOADING_STATE: ReactiveStreamState = Object.freeze({ + data: undefined, + error: undefined, + status: 'loading', +}); + +/** + * Static store that never emits. Bridges surface its `loading` status so + * SSR matches the first client render. The real store kicks in on the + * client once `useLiveStore` re-runs its factory. + * + * @internal + */ +export function nullLiveStore(): ReactiveStreamStore { + return { + getError: () => undefined, + getState: () => undefined, + getUnifiedState: () => LOADING_STATE, + retry: NOOP_RETRY, + subscribe: () => NOOP_UNSUBSCRIBE, + }; +} + +/** + * Static store tagged as "disabled". Bridges map the tag to + * `status: 'disabled'` — the `null`-arg story for `useBalance` etc. + * + * @internal + */ +export function disabledLiveStore(): ReactiveStreamStore & { readonly [DISABLED]: true } { + return { + [DISABLED]: true, + getError: () => undefined, + getState: () => undefined, + getUnifiedState: () => LOADING_STATE, + retry: NOOP_RETRY, + subscribe: () => NOOP_UNSUBSCRIBE, + }; +} + +/** + * Returns `true` if a store was produced by {@link disabledLiveStore}. + * + * @internal + */ +export function isDisabledStore(store: object): boolean { + return (store as { [DISABLED]?: boolean })[DISABLED] === true; +} + +/** + * Manages the lifecycle of a reactive store: builds it on mount or when + * `deps` change, aborts the previous controller on rebuild, and aborts on + * unmount. On non-live targets (server builds), returns a permanently + * loading static store so SSR matches the first client render. + * + * @internal + */ +export function useLiveStore void) => () => void }>( + factory: (signal: AbortSignal) => TStore, + ssrFallback: () => TStore, + deps: DependencyList, +): TStore { + const abortRef = useRef(null); + const store = useMemo(() => { + if (!IS_LIVE_CLIENT) return ssrFallback(); + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + return factory(controller.signal); + // Caller-supplied dep list — factory / ssrFallback stability is the caller's responsibility. + }, deps); + useEffect(() => () => abortRef.current?.abort(), []); + return store; +} diff --git a/packages/kit-react/src/internal/request-result.ts b/packages/kit-react/src/internal/request-result.ts new file mode 100644 index 0000000..50dc72d --- /dev/null +++ b/packages/kit-react/src/internal/request-result.ts @@ -0,0 +1,97 @@ +import { useMemo, useSyncExternalStore } from 'react'; + +import type { ReactiveActionState, ReactiveActionStore } from '../kit-prereqs'; + +/** + * Tag used by {@link disabledActionStore} so {@link useRequestResult} can + * map its presence to `status: 'disabled'`. + * + * @internal + */ +export const DISABLED_ACTION = Symbol('DisabledActionStore'); + +const DISABLED_STATE: ReactiveActionState = Object.freeze({ + data: undefined, + error: undefined, + status: 'idle', +}); + +/** + * Static action store used when `useRequest`'s factory returns `null`. + * Parallels `disabledLiveStore` for the stream-store side. `getState` + * returns a frozen module-scope snapshot so `useSyncExternalStore` sees a + * stable reference across renders. + * + * @internal + */ +export function disabledActionStore(): ReactiveActionStore<[], T> & { readonly [DISABLED_ACTION]: true } { + return { + [DISABLED_ACTION]: true, + dispatch: () => {}, + dispatchAsync: () => new Promise(() => {}), + getState: () => DISABLED_STATE as ReactiveActionState, + reset: () => {}, + subscribe: () => () => {}, + }; +} + +/** State returned by {@link useRequest}. */ +export type RequestResult = { + data: T | undefined; + error: unknown; + /** Convenience shorthand for `status === 'loading'`. */ + isLoading: boolean; + /** Re-fire the request with the current deps. Stable reference. */ + refresh: () => void; + status: 'disabled' | 'error' | 'loaded' | 'loading' | 'retrying'; +}; + +const NOOP_REFRESH = () => {}; + +/** + * Bridge for `useRequest`. Maps action-store states + * `{ idle, running, success, error }` onto the read shape + * `{ loading, loaded, error, retrying, disabled }`. + * + * @internal + */ +export function useRequestResult(store: ReactiveActionStore<[], T>): RequestResult { + const snapshot = useSyncExternalStore(store.subscribe, store.getState, store.getState); + const disabled = (store as { [DISABLED_ACTION]?: boolean })[DISABLED_ACTION] === true; + return useMemo(() => { + if (disabled) { + return { + data: undefined, + error: undefined, + isLoading: false, + refresh: NOOP_REFRESH, + status: 'disabled', + }; + } + const hadPrior = snapshot.data !== undefined; + let status: RequestResult['status']; + switch (snapshot.status) { + case 'idle': + // `.reactiveStore()` auto-dispatches, so `idle` never reaches + // consumers. Map to `loading` as a defensive fallback. + status = 'loading'; + break; + case 'running': + status = hadPrior ? 'retrying' : 'loading'; + break; + case 'success': + status = 'loaded'; + break; + case 'error': + status = 'error'; + break; + } + return { + data: snapshot.data, + error: snapshot.error, + isLoading: status === 'loading', + refresh: store.dispatch, + status, + }; + }, [snapshot, disabled, store]); +} diff --git a/packages/kit-react/src/internal/ssr.ts b/packages/kit-react/src/internal/ssr.ts new file mode 100644 index 0000000..f8c799a --- /dev/null +++ b/packages/kit-react/src/internal/ssr.ts @@ -0,0 +1,13 @@ +/** + * `true` when the current bundle target is a live client (browser or + * React Native). On server targets returns `false` so SSR paths can + * return static "not yet available" snapshots without firing HTTP or + * opening WebSockets. + * + * The flags `__BROWSER__` / `__REACTNATIVE__` are defined at build time + * by tsup / vitest — see `tsup.config.base.ts` and + * `vitest.config.base.mts` at the repo root. + * + * @internal + */ +export const IS_LIVE_CLIENT: boolean = __BROWSER__ || __REACTNATIVE__; diff --git a/packages/kit-react/src/internal/subscription-result.ts b/packages/kit-react/src/internal/subscription-result.ts new file mode 100644 index 0000000..50069d8 --- /dev/null +++ b/packages/kit-react/src/internal/subscription-result.ts @@ -0,0 +1,77 @@ +import type { Slot, SolanaRpcResponse } from '@solana/kit'; +import { useMemo, useSyncExternalStore } from 'react'; + +import type { ReactiveStreamState, ReactiveStreamStore } from '../kit-prereqs'; +import type { LiveQueryResult } from './live-query-result'; +import { isDisabledStore, NOOP_RETRY } from './live-store'; + +/** + * Unwraps `SolanaRpcResponse` → `U` at the type level, so subscriptions + * that emit slot-stamped notifications surface `U` as `data` (the slot + * moves to the top-level `slot` field). Non-envelope subscriptions pass + * through unchanged. + */ +export type UnwrapRpcResponse = T extends SolanaRpcResponse ? U : T; + +/** + * Runtime duck-type for the `SolanaRpcResponse` envelope. Splits + * `{ context: { slot }, value }` into `{ data, slot }`; anything else + * passes through. + * + * @internal + */ +export function splitRpcResponse(notification: T | undefined): { + data: UnwrapRpcResponse | undefined; + slot: Slot | undefined; +} { + if ( + notification != null && + typeof notification === 'object' && + 'context' in notification && + 'value' in notification + ) { + const envelope = notification as SolanaRpcResponse; + return { + data: envelope.value as UnwrapRpcResponse, + slot: envelope.context.slot, + }; + } + return { data: notification as UnwrapRpcResponse | undefined, slot: undefined }; +} + +/** + * Bridge for `useSubscription`. The store comes straight from + * `.reactiveStore()` on a pending subscription — each notification is + * duck-typed for the `SolanaRpcResponse` envelope. + * + * @internal + */ +export function useSubscriptionResult(store: ReactiveStreamStore): LiveQueryResult> { + const state: ReactiveStreamState = useSyncExternalStore( + store.subscribe, + store.getUnifiedState, + store.getUnifiedState, + ); + const disabled = isDisabledStore(store); + return useMemo(() => { + if (disabled) { + return { + data: undefined, + error: undefined, + isLoading: false, + retry: NOOP_RETRY, + slot: undefined, + status: 'disabled', + }; + } + const { data, slot } = splitRpcResponse(state.data); + return { + data, + error: state.error, + isLoading: state.status === 'loading', + retry: store.retry, + slot, + status: state.status, + }; + }, [state, disabled, store]); +} diff --git a/packages/kit-react/src/internal/use-promise.ts b/packages/kit-react/src/internal/use-promise.ts new file mode 100644 index 0000000..fa7d1ce --- /dev/null +++ b/packages/kit-react/src/internal/use-promise.ts @@ -0,0 +1,68 @@ +import * as React from 'react'; + +/** + * Caller-shaped view of the internal state we track per pending promise. + * A `WeakMap` keyed on the promise itself means callers must pass the same + * reference across renders (typically via `useMemo` or a module-scope + * hoist), otherwise each render allocates a new pending state and + * Suspense never settles. + */ +type PromiseState = + | { reason: unknown; status: 'rejected' } + | { status: 'fulfilled'; value: T } + | { status: 'pending' }; + +const cache = new WeakMap, PromiseState>(); + +/** + * React 18 shim for `use(promise)`: throws the promise while pending so a + * `` boundary catches it, returns the resolved value once + * fulfilled, re-throws the rejection once rejected. Per-promise state is + * memoized via a `WeakMap` keyed on promise identity. + * + * Exported solely for unit tests — consumers should call {@link usePromise}. + * + * @internal + */ +export function shimUsePromise(promise: Promise): T { + let state = cache.get(promise) as PromiseState | undefined; + if (!state) { + state = { status: 'pending' }; + cache.set(promise, state as PromiseState); + promise.then( + value => { + cache.set(promise, { status: 'fulfilled', value }); + }, + reason => { + cache.set(promise, { reason, status: 'rejected' }); + }, + ); + } + // Throwing the promise itself is Suspense's wire protocol — React attaches + // its own `.then` to retry render once the promise settles. Not an Error. + // eslint-disable-next-line @typescript-eslint/only-throw-error + if (state.status === 'pending') throw promise; + if (state.status === 'rejected') throw state.reason; + return state.value; +} + +/** + * Unwraps a promise at render time, surfacing the pending state to the + * nearest `` boundary via a thrown promise. + * + * Uses React 19's native `use(promise)` when available, falls back to a + * tiny shim under React 18 that follows the same thrown-promise contract. + * The promise must be stable across renders — pass a `useMemo`'d or + * module-scope promise, never an inline `new Promise(...)`. + * + * @typeParam T - The resolved value type. + * @param promise - A stable-reference promise. + * @return The resolved value once fulfilled. + * @throws The promise itself while pending; the rejection reason once rejected. + * + * @internal + */ +export const usePromise: (promise: Promise) => T = + 'use' in React && typeof (React as { use?: unknown }).use === 'function' + ? ((React as unknown as { use: (p: Promise) => T }).use as (p: Promise) => T) + : shimUsePromise; diff --git a/packages/kit-react/src/kit-prereqs/README.md b/packages/kit-react/src/kit-prereqs/README.md new file mode 100644 index 0000000..d60a1dd --- /dev/null +++ b/packages/kit-react/src/kit-prereqs/README.md @@ -0,0 +1,19 @@ +# `kit-prereqs/` — temporary shims + +Every file in this folder is a **temporary** shim for a Kit or kit-plugins +primitive that this library depends on but which has not yet shipped upstream. +Each file is marked `TODO(move-to-kit)` or `TODO(move-to-kit-plugins)` and +links to the upstream PR. + +When the upstream PR lands, delete the file from this folder and flip the +corresponding import in `../` to the upstream package. The public surface of +`@solana/kit-react` is spec-native, so consumers of this library never see the +shim and no breaking change is required on deletion. + +| Shim | Upstream | +| ----------------------------- | ----------------------------------------------------- | +| `reactive-stream-store.ts` | [kit#1552](https://github.com/anza-xyz/kit/pull/1552) | +| `reactive-action-store.ts` | [kit#1550](https://github.com/anza-xyz/kit/pull/1550) | +| `pending-rpc-subs-request.ts` | [kit#1553](https://github.com/anza-xyz/kit/pull/1553) | +| `pending-rpc-request.ts` | [kit#1555](https://github.com/anza-xyz/kit/pull/1555) | +| `is-abort-error.ts` | Merged into Kit; awaiting release | diff --git a/packages/kit-react/src/kit-prereqs/index.ts b/packages/kit-react/src/kit-prereqs/index.ts new file mode 100644 index 0000000..e65a1fe --- /dev/null +++ b/packages/kit-react/src/kit-prereqs/index.ts @@ -0,0 +1,16 @@ +// TODO(move-to-kit): Delete this module entirely when the upstream Kit PRs +// listed in ./README.md land. Consumer imports currently route through here. + +export { type ReactiveStreamState, type ReactiveStreamStatus, type ReactiveStreamStore } from './reactive-stream-store'; + +export { + createReactiveActionStore, + type ReactiveActionState, + type ReactiveActionStatus, + type ReactiveActionStore, +} from './reactive-action-store'; + +export { reactiveStoreFromPendingRequest } from './pending-rpc-request'; +export { reactiveStoreFromPendingSubscriptionsRequest } from './pending-rpc-subs-request'; + +export { isAbortError } from './is-abort-error'; diff --git a/packages/kit-react/src/kit-prereqs/is-abort-error.ts b/packages/kit-react/src/kit-prereqs/is-abort-error.ts new file mode 100644 index 0000000..01d4c70 --- /dev/null +++ b/packages/kit-react/src/kit-prereqs/is-abort-error.ts @@ -0,0 +1,30 @@ +/** + * TODO(move-to-kit): `isAbortError` has been merged into Kit but not yet + * released in the `@solana/kit` version this repo pins. Delete this file + * and re-export from `@solana/kit` directly once the Kit release lands. + */ + +/** + * Returns `true` if the given value is an `AbortError`, thrown by an + * `AbortSignal` whose `abort()` was called. + * + * Use this to filter supersede rejections when you `await` the result of + * an {@link ActionResult.send} call. + * + * @param error - The value to test. + * @return `true` if `error` is an abort-originated `Error`, `false` otherwise. + * + * @example + * ```tsx + * try { + * const result = await send(instruction); + * navigate(`/tx/${result.context.signature}`); + * } catch (err) { + * if (isAbortError(err)) return; // superseded, the store already reflects the newer call + * throw err; + * } + * ``` + */ +export function isAbortError(error: unknown): boolean { + return error instanceof Error && (error.name === 'AbortError' || (error as { code?: string }).code === 'ABORT_ERR'); +} diff --git a/packages/kit-react/src/kit-prereqs/pending-rpc-request.ts b/packages/kit-react/src/kit-prereqs/pending-rpc-request.ts new file mode 100644 index 0000000..027373c --- /dev/null +++ b/packages/kit-react/src/kit-prereqs/pending-rpc-request.ts @@ -0,0 +1,30 @@ +/** + * TODO(move-to-kit): Replace with a native + * `PendingRpcRequest.reactiveStore(): ReactiveActionStore<[], T>` method + * once Kit ships it (see the spec's "Prerequisites" section — change listed + * as TODO on `@solana/rpc-spec`). When released, delete this file and call + * `pending.reactiveStore()` directly at the call sites. + */ +import type { PendingRpcRequest } from '@solana/kit'; + +import { createReactiveActionStore, type ReactiveActionStore } from './reactive-action-store'; + +/** + * Wraps a {@link PendingRpcRequest} as an eagerly-dispatched + * {@link ReactiveActionStore}. Matches the semantics the spec calls out + * for `PendingRpcRequest.reactiveStore()`: construction auto-dispatches + * the request, subsequent `dispatch` calls fire a fresh RPC call. + * + * The per-dispatch abort signal is passed into `pending.send({ abortSignal })`, + * so a supersede aborts the in-flight HTTP request cleanly. + * + * @internal + */ +export function reactiveStoreFromPendingRequest(pending: PendingRpcRequest): ReactiveActionStore<[], T> { + const store = createReactiveActionStore<[], T>(signal => pending.send({ abortSignal: signal })); + // Eager auto-dispatch: `.reactiveStore()` commits to "live now". The + // fire-and-forget `dispatch` captures failures on state without + // surfacing an unhandled rejection. + store.dispatch(); + return store; +} diff --git a/packages/kit-react/src/kit-prereqs/pending-rpc-subs-request.ts b/packages/kit-react/src/kit-prereqs/pending-rpc-subs-request.ts new file mode 100644 index 0000000..b0dcff8 --- /dev/null +++ b/packages/kit-react/src/kit-prereqs/pending-rpc-subs-request.ts @@ -0,0 +1,106 @@ +/** + * TODO(move-to-kit): Replace with a native sync + * `PendingRpcSubscriptionsRequest.reactiveStore({ abortSignal })` method + * once https://github.com/anza-xyz/kit/pull/1553 lands. The existing async + * `.reactive()` returns `Promise>`, which forces consumers + * to invent a second state machine on top of the store's own lifecycle. + * The sync form returns a store immediately with `status: 'loading'` and + * transitions once the transport resolves. + * + * This shim bridges today's async `.reactive()` into a synchronously- + * returned {@link ReactiveStreamStore} with a `status: 'loading'` prelude. + */ +import type { PendingRpcSubscriptionsRequest } from '@solana/kit'; +import type { ReactiveStore } from '@solana/subscribable'; + +import type { ReactiveStreamState, ReactiveStreamStore } from './reactive-stream-store'; + +const LOADING_STATE: ReactiveStreamState = Object.freeze({ + data: undefined, + error: undefined, + status: 'loading', +}); + +/** + * Sync counterpart to the existing async `.reactive()` on + * {@link PendingRpcSubscriptionsRequest}. Returns immediately with a + * `status: 'loading'` snapshot; switches to the underlying store as soon + * as transport setup resolves. Transport-setup failures surface as + * `status: 'error'`, recoverable via `retry()`. + * + * @internal + */ +export function reactiveStoreFromPendingSubscriptionsRequest( + pending: PendingRpcSubscriptionsRequest, + options: { abortSignal: AbortSignal }, +): ReactiveStreamStore { + const listeners = new Set<() => void>(); + let unifiedState: ReactiveStreamState = LOADING_STATE; + let underlying: ReactiveStore | undefined; + let underlyingUnsub: (() => void) | undefined; + + const notify = () => { + for (const listener of [...listeners]) listener(); + }; + + const refreshUnifiedState = () => { + if (!underlying) return; + const error = underlying.getError(); + const data = underlying.getState(); + if (error !== undefined) { + unifiedState = { data, error, status: 'error' }; + } else if (data === undefined) { + unifiedState = LOADING_STATE; + } else { + unifiedState = { data, error: undefined, status: 'loaded' }; + } + notify(); + }; + + const setErrorState = (error: unknown) => { + unifiedState = { data: unifiedState.data, error, status: 'error' }; + notify(); + }; + + pending + .reactive({ abortSignal: options.abortSignal }) + .then((store: ReactiveStore) => { + if (options.abortSignal.aborted) return; + underlying = store; + underlyingUnsub = store.subscribe(() => { + refreshUnifiedState(); + }); + refreshUnifiedState(); + }) + .catch((error: unknown) => { + if (options.abortSignal.aborted) return; + setErrorState(error); + }); + + options.abortSignal.addEventListener('abort', () => { + underlyingUnsub?.(); + underlyingUnsub = undefined; + }); + + return { + getError: () => unifiedState.error, + getState: () => unifiedState.data, + getUnifiedState: () => unifiedState, + retry: () => { + // The shim can only rebind to the existing store; true + // end-to-end retry belongs to the upstream sync API. For now, + // callers should rebuild via the factory at the useLiveStore + // layer on retry. + refreshUnifiedState(); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + let unsubscribed = false; + return () => { + if (unsubscribed) return; + unsubscribed = true; + listeners.delete(listener); + }; + }, + }; +} diff --git a/packages/kit-react/src/kit-prereqs/reactive-action-store.ts b/packages/kit-react/src/kit-prereqs/reactive-action-store.ts new file mode 100644 index 0000000..20101eb --- /dev/null +++ b/packages/kit-react/src/kit-prereqs/reactive-action-store.ts @@ -0,0 +1,191 @@ +/** + * TODO(move-to-kit): Replace with the upstream implementation once + * https://github.com/anza-xyz/kit/pull/1550 lands. The upstream PR adds + * `ReactiveActionStore` and `createReactiveActionStore` to + * `@solana/subscribable`. When released, delete this file and re-export + * from `@solana/kit` (or `@solana/subscribable`) directly. + */ + +/** + * Lifecycle of a {@link ReactiveActionStore}. + * + * - `idle`: no invocation has been dispatched yet. + * - `running`: an invocation is in flight. + * - `success`: the most recent invocation resolved. + * - `error`: the most recent invocation rejected. + * + * A fresh dispatch while another is in flight supersedes it (aborts the + * prior signal), so only the newest invocation's outcome is ever visible. + * The store is stale-while-revalidate: during `running` after a prior + * `success`, `data` still holds the previous value so consumers can render + * stale data with an overlay. + */ +export type ReactiveActionStatus = 'error' | 'idle' | 'running' | 'success'; + +/** + * A snapshot of a {@link ReactiveActionStore}'s state. + * + * @typeParam T - The value type resolved by successful dispatches. + */ +export type ReactiveActionState = Readonly<{ + /** Value of the most recent successful dispatch, or `undefined`. */ + data: T | undefined; + /** Error of the most recent failed dispatch, or `undefined`. */ + error: unknown; + /** Current lifecycle status. */ + status: ReactiveActionStatus; +}>; + +/** + * Reactive container for invocation-based async operations (user-triggered + * actions, one-shot RPC reads). + * + * Compatible with `useSyncExternalStore` via + * {@link ReactiveActionStore.subscribe} + + * {@link ReactiveActionStore.getState}. All four methods are annotated + * with `this: void` so callers can pass them as unbound references without + * tripping the `unbound-method` lint. + * + * @typeParam TArgs - Tuple of argument types accepted by each invocation. + * @typeParam T - Result type produced by each invocation. + */ +export type ReactiveActionStore = { + /** + * Fire-and-forget dispatch. Returns `undefined` synchronously and + * never throws — failures surface on state as `{ status: 'error' }`, + * and superseded or `reset()`-aborted calls produce no state update. + * Use from UI event handlers; there's no promise to handle or + * `.catch()`. + * + * @see {@link ReactiveActionStore.dispatchAsync} when you need the + * resolved value or propagated errors. + */ + dispatch: (this: void, ...args: TArgs) => void; + /** + * Promise-returning dispatch for imperative callers. Resolves with the + * wrapped function's result on success. Rejects with the thrown error + * on failure, and with an `AbortError` when the call is superseded or + * `reset()` is invoked — filter those with {@link isAbortError}. + */ + dispatchAsync: (this: void, ...args: TArgs) => Promise; + /** The current `{ status, data, error }` snapshot. Reference-stable per update. */ + getState: (this: void) => ReactiveActionState; + /** Reset to `idle`, clearing `data` and `error`. Aborts any in-flight call. */ + reset: (this: void) => void; + /** + * Subscribe to state changes. Returns an idempotent unsubscribe. Safe + * to call multiple times with the same callback. + */ + subscribe: (this: void, listener: () => void) => () => void; +}; + +const IDLE_STATE: ReactiveActionState = Object.freeze({ + data: undefined, + error: undefined, + status: 'idle', +}); + +/** + * Creates a {@link ReactiveActionStore} around the given async operation. + * + * The operation receives an {@link AbortSignal} as its first argument so + * it can cancel HTTP / RPC / wallet calls when superseded. A second + * `dispatch` while a prior one is in flight aborts the first signal, + * giving the common "click twice, only the second submits" UX. The store + * is neutral on initiation: nothing fires until the first `dispatch` + * call. Callers who want eager auto-dispatch (such as + * `PendingRpcRequest.reactiveStore()`) dispatch once themselves after + * construction. + * + * @typeParam TArgs - Tuple of argument types accepted by `dispatch`. + * @typeParam T - Result type produced by each successful dispatch. + * @param operation - The async operation to invoke on each dispatch. + * @return A fresh {@link ReactiveActionStore} in `idle` state. + * + * @example + * ```ts + * const store = createReactiveActionStore<[number], number>( + * async (_signal, n) => n * 2, + * ); + * await store.dispatchAsync(21); // resolves to 42 + * store.getState(); // { status: 'success', data: 42, error: undefined } + * ``` + */ +export function createReactiveActionStore( + operation: (signal: AbortSignal, ...args: TArgs) => Promise, +): ReactiveActionStore { + const listeners = new Set<() => void>(); + let state: ReactiveActionState = IDLE_STATE; + let activeController: AbortController | undefined; + + const notify = () => { + for (const listener of [...listeners]) listener(); + }; + + const setState = (next: ReactiveActionState) => { + if (next === state) return; + state = next; + notify(); + }; + + const abortActive = () => { + const controller = activeController; + if (!controller) return; + activeController = undefined; + controller.abort(); + }; + + const dispatchAsync = (...args: TArgs): Promise => { + abortActive(); + const controller = new AbortController(); + activeController = controller; + setState({ + data: state.data, + error: undefined, + status: 'running', + }); + const { signal } = controller; + const promise = operation(signal, ...args); + promise.then( + value => { + if (activeController !== controller) return; + activeController = undefined; + setState({ data: value, error: undefined, status: 'success' }); + }, + error => { + if (activeController !== controller) return; + activeController = undefined; + setState({ data: state.data, error, status: 'error' }); + }, + ); + return promise; + }; + + const store: ReactiveActionStore = { + dispatch(...args) { + // Swallow the rejection so the fire-and-forget contract holds + // (state still captures it via the `.then(..., error => ...)` + // branch inside `dispatchAsync`). + dispatchAsync(...args).catch(() => {}); + }, + dispatchAsync, + getState() { + return state; + }, + reset() { + abortActive(); + setState(IDLE_STATE); + }, + subscribe(listener) { + listeners.add(listener); + let unsubscribed = false; + return () => { + if (unsubscribed) return; + unsubscribed = true; + listeners.delete(listener); + }; + }, + }; + + return store; +} diff --git a/packages/kit-react/src/kit-prereqs/reactive-stream-store.ts b/packages/kit-react/src/kit-prereqs/reactive-stream-store.ts new file mode 100644 index 0000000..99df6e4 --- /dev/null +++ b/packages/kit-react/src/kit-prereqs/reactive-stream-store.ts @@ -0,0 +1,84 @@ +/** + * TODO(move-to-kit): Replace these types with the canonical versions once + * https://github.com/anza-xyz/kit/pull/1552 lands. The upstream PR renames + * `ReactiveStore` to `ReactiveStreamStore`, adds `retry()` + a unified + * `{ status, data, error }` snapshot via `getUnifiedState()`, and extends + * `createReactiveStoreWithInitialValueAndSlotTracking` to return the new + * shape. When the PR is released, delete this file and re-export the shape + * from `@solana/kit` directly. + */ + +/** + * Lifecycle of a {@link ReactiveStreamStore}. + * + * - `loading`: no data has arrived yet. + * - `loaded`: the most recent update was successful. + * - `error`: the stream errored and no live connection is active. + * - `retrying`: a caller invoked {@link ReactiveStreamStore.retry} after an + * error; the stream is trying to re-establish. `data` continues to hold + * the last known value (if any) during this phase. + */ +export type ReactiveStreamStatus = 'error' | 'loaded' | 'loading' | 'retrying'; + +/** + * A snapshot of a {@link ReactiveStreamStore}'s state. + * + * The three fields are mutually consistent with `status`: `loaded` implies + * `data` is the latest value and `error` is undefined; `error` implies + * `error` is set; `loading` implies `data` is undefined (modulo the initial + * pre-emit state); `retrying` preserves the stale `data` from before the + * error so UIs can render a stale value with a retry overlay. + * + * @typeParam T - The value type emitted onto the store. + */ +export type ReactiveStreamState = Readonly<{ + /** The most recent value, or `undefined` before the first emit. */ + data: T | undefined; + /** The most recent error, or `undefined` when healthy. */ + error: unknown; + /** Current lifecycle status. */ + status: ReactiveStreamStatus; +}>; + +/** + * Reactive container for long-lived stream-shaped data (RPC subscriptions, + * RPC-fetch-plus-subscription hybrids). Compatible with + * `useSyncExternalStore` via {@link ReactiveStreamStore.subscribe} + + * {@link ReactiveStreamStore.getUnifiedState}. + * + * The asymmetric accessor surface (`getState` deprecated, `getUnifiedState` + * preferred) is intentional while Kit ships the rename; see the spec's + * "Prerequisites" section. + * + * @typeParam T - Value type emitted onto the store. + */ +export type ReactiveStreamStore = { + /** + * Returns the last error emitted on the stream, or `undefined` when + * healthy. Once set, the error is preserved across subsequent + * notifications until a fresh value arrives. + */ + getError: (this: void) => unknown; + /** + * Returns the most recent value emitted on the stream, or `undefined` + * before the first emit. + */ + getState: (this: void) => T | undefined; + /** + * The full `{ status, data, error }` snapshot. Reference-stable per + * update so that `useSyncExternalStore` can detect changes cheaply. + */ + getUnifiedState: (this: void) => ReactiveStreamState; + /** + * Re-opens the stream after an error. No-op unless + * `getUnifiedState().status === 'error'`. While re-establishing, the + * store transitions through `status: 'retrying'` keeping the last + * known `data` value, then settles to `loaded` or `error`. + */ + retry: (this: void) => void; + /** + * Registers a listener for state changes. Returns an idempotent + * unsubscribe function. + */ + subscribe: (this: void, listener: () => void) => () => void; +}; diff --git a/packages/kit-react/src/types/global.d.ts b/packages/kit-react/src/types/global.d.ts new file mode 100644 index 0000000..0093a53 --- /dev/null +++ b/packages/kit-react/src/types/global.d.ts @@ -0,0 +1,6 @@ +declare const __BROWSER__: boolean; +declare const __REACTNATIVE__: boolean; +declare const __NODEJS__: boolean; +declare const __TEST__: boolean; +declare const __ESM__: boolean; +declare const __VERSION__: string; diff --git a/packages/kit-react/test/client-capability.test.tsx b/packages/kit-react/test/client-capability.test.tsx new file mode 100644 index 0000000..931071d --- /dev/null +++ b/packages/kit-react/test/client-capability.test.tsx @@ -0,0 +1,62 @@ +/** + * @vitest-environment happy-dom + */ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { useClientCapability } from '../src/client-capability'; +import { expectingError, Providers } from './helpers'; + +describe('useClientCapability', () => { + it('returns the client when the capability is installed', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {} } }}>{children} + ); + const { result } = renderHook( + () => + useClientCapability<{ rpc: { send: () => void } }>({ + capability: 'rpc', + hookName: 'useFakeHook', + providerHint: 'Install `solanaRpc()` on the client.', + }), + { wrapper }, + ); + expect(result.current.rpc).toBeDefined(); + }); + + it('throws a helpful error when the capability is missing', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => {children}; + expectingError(() => { + expect(() => + renderHook( + () => + useClientCapability({ + capability: 'rpc', + hookName: 'useFakeHook', + providerHint: 'Install `solanaRpc()` on the client.', + }), + { wrapper }, + ), + ).toThrow(/useFakeHook.*client\.rpc.*solanaRpc/s); + }); + }); + + it('accepts an array of capabilities and lists them all in the error', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + expectingError(() => { + expect(() => + renderHook( + () => + useClientCapability({ + capability: ['rpc', 'rpcSubscriptions'], + hookName: 'useFakeHook', + providerHint: 'Install both.', + }), + { wrapper }, + ), + ).toThrow(/all of.*client\.rpc.*client\.rpcSubscriptions/s); + }); + }); +}); diff --git a/packages/kit-react/test/client-context.test.tsx b/packages/kit-react/test/client-context.test.tsx new file mode 100644 index 0000000..b7b29bf --- /dev/null +++ b/packages/kit-react/test/client-context.test.tsx @@ -0,0 +1,91 @@ +/** + * @vitest-environment happy-dom + */ +import type { Client } from '@solana/kit'; +import { createClient } from '@solana/kit'; +import { cleanup, render, renderHook } from '@testing-library/react'; +import { type ReactNode, Suspense } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { KitClientProvider, useChain, useClient } from '../src/client-context'; +import { expectingError } from './helpers'; + +function syncWrapper({ children }: { children: ReactNode }) { + const client = createClient(); + return ( + + {children} + + ); +} + +function syncWrapperWithoutChain({ children }: { children: ReactNode }) { + return {children}; +} + +afterEach(() => cleanup()); + +describe('KitClientProvider', () => { + it('publishes the caller-owned client to the subtree', () => { + const client = createClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useClient(), { wrapper }); + expect(result.current).toBe(client); + }); + + it('does not require chain to be set', () => { + const { result } = renderHook(() => useClient(), { wrapper: syncWrapperWithoutChain }); + expect(Object.keys(result.current)).toContain('use'); + }); + + it('throws when useClient() is used outside a provider', () => { + expectingError(() => { + expect(() => renderHook(() => useClient())).toThrow(/must be called inside a /); + }); + }); + + it('renders the Suspense fallback while the client promise is pending', () => { + const clientPromise = new Promise>(() => { + // never resolves + }); + function Probe() { + useClient(); + return ready; + } + const { queryByTestId } = render( + pending}> + + + + , + ); + expect(queryByTestId('fallback')).not.toBeNull(); + expect(queryByTestId('probe')).toBeNull(); + }); + + // Note: resolution-and-retry through Suspense is not exercised here. + // happy-dom + vitest does not flush React 19's Suspense scheduler after + // a promise settles — even native `React.use(promise)` reproduces the + // same hang in this environment. The end-to-end behavior is covered by + // composition: the shim's pending/fulfilled/rejected contract is + // unit-tested in `use-promise.test.ts`, and Suspense's retry against a + // settled promise is React's own contract. We assert here that the + // suspend half of the chain (throw → fallback) is wired correctly. +}); + +describe('useChain', () => { + it('reads the chain set on KitClientProvider', () => { + const { result } = renderHook(() => useChain(), { wrapper: syncWrapper }); + expect(result.current).toBe('solana:devnet'); + }); + + it('throws when no ancestor has published a chain', () => { + expectingError(() => { + expect(() => renderHook(() => useChain(), { wrapper: syncWrapperWithoutChain })).toThrow( + /useChain\(\) requires a chain/, + ); + }); + }); +}); diff --git a/packages/kit-react/test/fake-stores.ts b/packages/kit-react/test/fake-stores.ts new file mode 100644 index 0000000..8c7e7a5 --- /dev/null +++ b/packages/kit-react/test/fake-stores.ts @@ -0,0 +1,86 @@ +import type { + ReactiveActionState, + ReactiveActionStore, + ReactiveStreamState, + ReactiveStreamStore, +} from '../src/kit-prereqs'; + +/** + * Controllable fake of a {@link ReactiveStreamStore} for unit tests. Call + * `emit`/`fail`/`setState` to drive state transitions; subscribers get + * notified synchronously. + */ +export interface FakeStreamStore extends ReactiveStreamStore { + emit: (value: T) => void; + fail: (error: unknown) => void; + setState: (state: ReactiveStreamState) => void; +} + +/** + * Build a fake stream store starting in `loading`. Controllable via + * `emit`, `fail`, `setState`. + */ +export function fakeStreamStore(): FakeStreamStore { + const listeners = new Set<() => void>(); + let state: ReactiveStreamState = { data: undefined, error: undefined, status: 'loading' }; + const notify = () => listeners.forEach(l => l()); + return { + emit(value) { + state = { data: value, error: undefined, status: 'loaded' }; + notify(); + }, + fail(error) { + state = { data: state.data, error, status: 'error' }; + notify(); + }, + getError: () => state.error, + getState: () => state.data, + getUnifiedState: () => state, + retry() { + if (state.status === 'error') { + state = { data: state.data, error: undefined, status: 'retrying' }; + notify(); + } + }, + setState(next) { + state = next; + notify(); + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +/** + * Controllable fake of a {@link ReactiveActionStore}. `setState` drives + * synchronous transitions; `dispatch` / `dispatchAsync` are no-ops unless + * you override them. + */ +export interface FakeActionStore extends ReactiveActionStore<[], T> { + setState: (state: ReactiveActionState) => void; +} + +export function fakeActionStore(): FakeActionStore { + const listeners = new Set<() => void>(); + let state: ReactiveActionState = { data: undefined, error: undefined, status: 'idle' }; + const notify = () => listeners.forEach(l => l()); + return { + dispatch: () => {}, + dispatchAsync: () => new Promise(() => {}), + getState: () => state, + reset: () => { + state = { data: undefined, error: undefined, status: 'idle' }; + notify(); + }, + setState(next) { + state = next; + notify(); + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/packages/kit-react/test/helpers.tsx b/packages/kit-react/test/helpers.tsx new file mode 100644 index 0000000..1771bfa --- /dev/null +++ b/packages/kit-react/test/helpers.tsx @@ -0,0 +1,47 @@ +import type { Client, ClientPlugin } from '@solana/kit'; +import { createClient, extendClient } from '@solana/kit'; +import type { ReactNode } from 'react'; +import { useMemo } from 'react'; + +import { KitClientProvider } from '../src/client-context'; + +/** + * Build a `ClientPlugin` that extends the client with the given properties. + * Useful for installing fake capabilities (`rpc`, `payer`, etc.) in tests + * without reaching for a real transport. + */ +export function mockPlugin(extensions: T): ClientPlugin { + return (client: object) => extendClient(client, extensions); +} + +/** + * Wrap children in a `KitClientProvider` whose client has the supplied + * extensions installed. Each call to {@link Providers} builds a fresh + * client, memoized across re-renders of the same wrapper instance so hooks + * that close over `client` identity stay stable. + */ +export function Providers({ children, extensions }: { children: ReactNode; extensions?: Record }) { + const client = useMemo>( + () => (extensions ? createClient().use(mockPlugin(extensions)) : createClient()), + // The test callers either pass a stable reference or explicitly + // want a fresh client per wrapper mount; either way the memo + // dependency is the raw extensions reference. + // eslint-disable-next-line react-hooks/exhaustive-deps + [extensions], + ); + return {children}; +} + +/** + * Suppress React's error log while running `fn`. Use around assertions for + * `render`/`renderHook` calls that are expected to throw. + */ +export function expectingError(fn: () => T): T { + const original = console.error; + console.error = () => {}; + try { + return fn(); + } finally { + console.error = original; + } +} diff --git a/packages/kit-react/test/kit-prereqs/is-abort-error.test.ts b/packages/kit-react/test/kit-prereqs/is-abort-error.test.ts new file mode 100644 index 0000000..a718b62 --- /dev/null +++ b/packages/kit-react/test/kit-prereqs/is-abort-error.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { isAbortError } from '../../src/kit-prereqs'; + +describe('isAbortError', () => { + it('recognises DOMException("", "AbortError")', () => { + expect(isAbortError(new DOMException('Aborted', 'AbortError'))).toBe(true); + }); + + it('recognises errors whose code is ABORT_ERR', () => { + const err = Object.assign(new Error('aborted'), { code: 'ABORT_ERR' }); + expect(isAbortError(err)).toBe(true); + }); + + it('returns false for other errors', () => { + expect(isAbortError(new Error('boom'))).toBe(false); + expect(isAbortError(null)).toBe(false); + expect(isAbortError(undefined)).toBe(false); + expect(isAbortError('aborted')).toBe(false); + }); +}); diff --git a/packages/kit-react/test/kit-prereqs/reactive-action-store.test.ts b/packages/kit-react/test/kit-prereqs/reactive-action-store.test.ts new file mode 100644 index 0000000..3c2b939 --- /dev/null +++ b/packages/kit-react/test/kit-prereqs/reactive-action-store.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createReactiveActionStore } from '../../src/kit-prereqs'; + +describe('createReactiveActionStore', () => { + it('starts idle and transitions through running to success', async () => { + const store = createReactiveActionStore<[number], number>((_signal, n) => Promise.resolve(n * 2)); + expect(store.getState()).toEqual({ data: undefined, error: undefined, status: 'idle' }); + const promise = store.dispatchAsync(21); + expect(store.getState().status).toBe('running'); + await expect(promise).resolves.toBe(42); + expect(store.getState()).toEqual({ data: 42, error: undefined, status: 'success' }); + }); + + it('captures rejections on the error path', async () => { + const err = new Error('boom'); + const store = createReactiveActionStore<[], never>(() => Promise.reject(err)); + await expect(store.dispatchAsync()).rejects.toBe(err); + expect(store.getState()).toMatchObject({ error: err, status: 'error' }); + }); + + it('fire-and-forget dispatch() never throws but still captures error state', async () => { + const err = new Error('boom'); + const store = createReactiveActionStore<[], never>(() => Promise.reject(err)); + expect(() => store.dispatch()).not.toThrow(); + // Let the microtask queue drain so the rejection settles into state. + await Promise.resolve(); + await Promise.resolve(); + expect(store.getState()).toMatchObject({ error: err, status: 'error' }); + }); + + it('supersedes the prior dispatch by aborting its signal', async () => { + const signals: AbortSignal[] = []; + const store = createReactiveActionStore<[number], number>((signal, delay) => { + signals.push(signal); + return new Promise(resolve => { + const timer = setTimeout(() => resolve(delay), delay); + signal.addEventListener('abort', () => clearTimeout(timer)); + }); + }); + + void store.dispatchAsync(50); + const second = store.dispatchAsync(1); + expect(signals[0]?.aborted).toBe(true); + await expect(second).resolves.toBe(1); + expect(store.getState()).toMatchObject({ data: 1, status: 'success' }); + }); + + it('notifies subscribers on state transitions', async () => { + const store = createReactiveActionStore<[], number>(() => Promise.resolve(7)); + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); + await store.dispatchAsync(); + // at least `running` + `success`. + expect(listener).toHaveBeenCalled(); + unsubscribe(); + }); + + it('reset() aborts in flight and returns to idle', async () => { + let aborted = false; + const store = createReactiveActionStore<[], never>( + signal => + new Promise((_, reject) => { + signal.addEventListener('abort', () => { + aborted = true; + reject(new DOMException('aborted', 'AbortError')); + }); + }), + ); + const p = store.dispatchAsync(); + store.reset(); + await expect(p).rejects.toBeTruthy(); + expect(aborted).toBe(true); + expect(store.getState().status).toBe('idle'); + }); + + it('stale-while-revalidate: a running re-dispatch preserves prior data', async () => { + const pending: Array<(v: number) => void> = []; + const store = createReactiveActionStore<[], number>(() => new Promise(res => pending.push(res))); + const first = store.dispatchAsync(); + pending.shift()!(99); + await first; + expect(store.getState()).toMatchObject({ data: 99, status: 'success' }); + + // Kick a second dispatch — it stays pending; the store should + // keep reporting the prior data while transitioning to running. + const second = store.dispatchAsync(); + expect(store.getState()).toMatchObject({ data: 99, status: 'running' }); + + // Resolve so the test doesn't leak a hanging promise. + pending.shift()!(100); + await second; + expect(store.getState()).toMatchObject({ data: 100, status: 'success' }); + }); +}); diff --git a/packages/kit-react/test/signers.test.tsx b/packages/kit-react/test/signers.test.tsx new file mode 100644 index 0000000..c7a993f --- /dev/null +++ b/packages/kit-react/test/signers.test.tsx @@ -0,0 +1,199 @@ +/** + * @vitest-environment happy-dom + */ +import type { Address, TransactionSigner } from '@solana/kit'; +import { address } from '@solana/kit'; +import { act, renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { useIdentity, usePayer } from '../src/hooks/signers'; +import { expectingError, Providers } from './helpers'; + +const A_ADDRESS: Address = address('11111111111111111111111111111111'); +const B_ADDRESS: Address = address('So11111111111111111111111111111111111111112'); + +function makeSigner(addr: Address): TransactionSigner { + return { + address: addr, + signAndSendTransactions: async () => [], + } as unknown as TransactionSigner; +} + +describe('usePayer', () => { + it('returns the installed static payer', () => { + const mySigner = makeSigner(A_ADDRESS); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => usePayer(), { wrapper }); + expect(result.current).toBe(mySigner); + }); + + it('throws when no payer is installed', () => { + const wrapper = ({ children }: { children: ReactNode }) => {children}; + expectingError(() => { + expect(() => renderHook(() => usePayer(), { wrapper })).toThrow(/usePayer.*client\.payer/s); + }); + }); + + it('returns null when the payer getter throws (wallet not connected)', () => { + // Reactive plugins expose `payer` as a throwing getter. Simulate that + // with Object.defineProperty. + const reactive: object = {}; + Object.defineProperty(reactive, 'payer', { + configurable: true, + enumerable: true, + get: () => { + throw new Error('wallet not connected'); + }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => usePayer(), { wrapper }); + expect(result.current).toBe(null); + }); + + it('re-renders when subscribeToPayer notifies', () => { + let current: TransactionSigner | null = makeSigner(A_ADDRESS); + const listeners = new Set<() => void>(); + const reactive = { + get payer() { + return current; + }, + subscribeToPayer: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => usePayer(), { wrapper }); + expect(result.current?.address).toBe(A_ADDRESS); + act(() => { + current = makeSigner(B_ADDRESS); + listeners.forEach(l => l()); + }); + expect(result.current?.address).toBe(B_ADDRESS); + }); + + it('tracks a full null → value → null cycle (wallet disconnect/reconnect)', () => { + let current: TransactionSigner | null = null; + const listeners = new Set<() => void>(); + const reactive = { + get payer() { + return current; + }, + subscribeToPayer: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => usePayer(), { wrapper }); + expect(result.current).toBe(null); + act(() => { + current = makeSigner(A_ADDRESS); + listeners.forEach(l => l()); + }); + expect(result.current?.address).toBe(A_ADDRESS); + act(() => { + current = null; + listeners.forEach(l => l()); + }); + expect(result.current).toBe(null); + }); +}); + +describe('useIdentity', () => { + it('returns the installed static identity', () => { + const mySigner = makeSigner(A_ADDRESS); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useIdentity(), { wrapper }); + expect(result.current).toBe(mySigner); + }); + + it('throws when no identity is installed', () => { + const wrapper = ({ children }: { children: ReactNode }) => {children}; + expectingError(() => { + expect(() => renderHook(() => useIdentity(), { wrapper })).toThrow(/useIdentity.*client\.identity/s); + }); + }); + + it('returns null when the identity getter throws (wallet not connected)', () => { + // Reactive plugins expose `identity` as a throwing getter when no + // wallet is connected. Simulate that with Object.defineProperty. + const reactive: object = {}; + Object.defineProperty(reactive, 'identity', { + configurable: true, + enumerable: true, + get: () => { + throw new Error('wallet not connected'); + }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useIdentity(), { wrapper }); + expect(result.current).toBe(null); + }); + + it('re-renders when subscribeToIdentity notifies', () => { + let current: TransactionSigner | null = makeSigner(A_ADDRESS); + const listeners = new Set<() => void>(); + const reactive = { + get identity() { + return current; + }, + subscribeToIdentity: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useIdentity(), { wrapper }); + expect(result.current?.address).toBe(A_ADDRESS); + act(() => { + current = makeSigner(B_ADDRESS); + listeners.forEach(l => l()); + }); + expect(result.current?.address).toBe(B_ADDRESS); + }); + + it('tracks a full null → value → null cycle (wallet disconnect/reconnect)', () => { + let current: TransactionSigner | null = null; + const listeners = new Set<() => void>(); + const reactive = { + get identity() { + return current; + }, + subscribeToIdentity: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useIdentity(), { wrapper }); + expect(result.current).toBe(null); + act(() => { + current = makeSigner(A_ADDRESS); + listeners.forEach(l => l()); + }); + expect(result.current?.address).toBe(A_ADDRESS); + act(() => { + current = null; + listeners.forEach(l => l()); + }); + expect(result.current).toBe(null); + }); +}); diff --git a/packages/kit-react/test/transaction-hooks.test.tsx b/packages/kit-react/test/transaction-hooks.test.tsx new file mode 100644 index 0000000..56c5710 --- /dev/null +++ b/packages/kit-react/test/transaction-hooks.test.tsx @@ -0,0 +1,139 @@ +/** + * @vitest-environment happy-dom + */ +import { act, renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { usePlanTransaction, usePlanTransactions } from '../src/hooks/use-plan-transaction'; +import { useSendTransaction, useSendTransactions } from '../src/hooks/use-send-transaction'; +import { expectingError, Providers } from './helpers'; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe('useSendTransaction / useSendTransactions', () => { + it('useSendTransaction throws when sendTransaction is missing', () => { + expectingError(() => { + expect(() => renderHook(() => useSendTransaction(), { wrapper })).toThrow( + /useSendTransaction.*client\.sendTransaction/s, + ); + }); + }); + + it('useSendTransactions throws when sendTransactions is missing', () => { + expectingError(() => { + expect(() => renderHook(() => useSendTransactions(), { wrapper })).toThrow( + /useSendTransactions.*client\.sendTransactions/s, + ); + }); + }); + + it('useSendTransaction delegates to client.sendTransaction with an abort signal', async () => { + const sendTransaction = vi.fn((_input: unknown, _config: unknown) => Promise.resolve('SIGNATURE' as unknown)); + const wrapperWithSend = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useSendTransaction(), { wrapper: wrapperWithSend }); + const out = await act(async () => result.current.sendAsync('INPUT' as never)); + expect(out).toBe('SIGNATURE'); + expect(sendTransaction).toHaveBeenCalledTimes(1); + const [input, config] = sendTransaction.mock.calls[0]!; + expect(input).toBe('INPUT'); + expect((config as { abortSignal: AbortSignal }).abortSignal).toBeInstanceOf(AbortSignal); + }); + + it('useSendTransactions delegates to client.sendTransactions with an abort signal', async () => { + const sendTransactions = vi.fn((_input: unknown, _config: unknown) => + Promise.resolve('PLAN_RESULT' as unknown), + ); + const wrapperWithSend = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useSendTransactions(), { wrapper: wrapperWithSend }); + const out = await act(async () => result.current.sendAsync('INPUT' as never)); + expect(out).toBe('PLAN_RESULT'); + expect(sendTransactions).toHaveBeenCalledTimes(1); + const [input, config] = sendTransactions.mock.calls[0]!; + expect(input).toBe('INPUT'); + expect((config as { abortSignal: AbortSignal }).abortSignal).toBeInstanceOf(AbortSignal); + }); + + it('useSendTransaction aborts the first send when a second is fired (abort-on-supersede)', async () => { + // First invocation hangs until aborted; second invocation resolves. + let firstSignal: AbortSignal | undefined; + let callCount = 0; + const sendTransaction = vi.fn((_input: unknown, config: { abortSignal: AbortSignal }) => { + callCount += 1; + if (callCount === 1) { + firstSignal = config.abortSignal; + return new Promise((_resolve, reject) => { + config.abortSignal.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }); + } + return Promise.resolve('SIG2' as unknown); + }); + const wrapperWithSend = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useSendTransaction(), { wrapper: wrapperWithSend }); + act(() => { + result.current.send('FIRST' as never); + }); + expect(firstSignal?.aborted).toBe(false); + await act(async () => { + await result.current.sendAsync('SECOND' as never); + }); + expect(firstSignal?.aborted).toBe(true); + expect(result.current.data).toBe('SIG2'); + }); +}); + +describe('usePlanTransaction / usePlanTransactions', () => { + it('usePlanTransaction throws when planTransaction is missing', () => { + expectingError(() => { + expect(() => renderHook(() => usePlanTransaction(), { wrapper })).toThrow( + /usePlanTransaction.*client\.planTransaction/s, + ); + }); + }); + + it('usePlanTransactions throws when planTransactions is missing', () => { + expectingError(() => { + expect(() => renderHook(() => usePlanTransactions(), { wrapper })).toThrow( + /usePlanTransactions.*client\.planTransactions/s, + ); + }); + }); + + it('usePlanTransaction delegates to client.planTransaction with an abort signal', async () => { + const planTransaction = vi.fn((_input: unknown, _config: unknown) => Promise.resolve('PLAN' as unknown)); + const wrapperWithPlan = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => usePlanTransaction(), { wrapper: wrapperWithPlan }); + const out = await act(async () => result.current.sendAsync('INPUT' as never)); + expect(out).toBe('PLAN'); + expect(planTransaction).toHaveBeenCalledTimes(1); + const [input, config] = planTransaction.mock.calls[0]!; + expect(input).toBe('INPUT'); + expect((config as { abortSignal: AbortSignal }).abortSignal).toBeInstanceOf(AbortSignal); + }); + + it('usePlanTransactions delegates to client.planTransactions with an abort signal', async () => { + const planTransactions = vi.fn((_input: unknown, _config: unknown) => Promise.resolve('PLANS' as unknown)); + const wrapperWithPlan = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => usePlanTransactions(), { wrapper: wrapperWithPlan }); + const out = await act(async () => result.current.sendAsync('INPUT' as never)); + expect(out).toBe('PLANS'); + expect(planTransactions).toHaveBeenCalledTimes(1); + const [input, config] = planTransactions.mock.calls[0]!; + expect(input).toBe('INPUT'); + expect((config as { abortSignal: AbortSignal }).abortSignal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/packages/kit-react/test/use-action.test.tsx b/packages/kit-react/test/use-action.test.tsx new file mode 100644 index 0000000..bc19e3f --- /dev/null +++ b/packages/kit-react/test/use-action.test.tsx @@ -0,0 +1,117 @@ +/** + * @vitest-environment happy-dom + */ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { Providers } from './helpers'; +import { useAction } from '../src/hooks/use-action'; + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe('useAction', () => { + it('starts in idle state and transitions through running to success', async () => { + const { result } = renderHook(() => useAction<[number], number>(async (_s, n) => n + 1), { wrapper }); + expect(result.current.isIdle).toBe(true); + expect(result.current.data).toBeUndefined(); + await act(async () => { + const value = await result.current.sendAsync(2); + expect(value).toBe(3); + }); + expect(result.current.isSuccess).toBe(true); + expect(result.current.data).toBe(3); + }); + + it('fire-and-forget send() never throws but state captures the error', async () => { + const err = new Error('nope'); + const { result } = renderHook(() => useAction<[], never>(() => Promise.reject(err)), { wrapper }); + await act(async () => { + result.current.send(); + }); + expect(result.current.isError).toBe(true); + expect(result.current.error).toBe(err); + }); + + it('sendAsync() propagates errors', async () => { + const err = new Error('nope'); + const { result } = renderHook(() => useAction<[], never>(() => Promise.reject(err)), { wrapper }); + await act(async () => { + await result.current.sendAsync().catch(() => {}); + }); + expect(result.current.isError).toBe(true); + expect(result.current.error).toBe(err); + }); + + it('reset() returns to idle', async () => { + const { result } = renderHook(() => useAction<[], number>(async () => 1), { wrapper }); + await act(async () => { + await result.current.sendAsync(); + }); + expect(result.current.isSuccess).toBe(true); + await act(async () => { + result.current.reset(); + }); + expect(result.current.isIdle).toBe(true); + }); + + it('a second dispatch aborts the first in-flight signal (abort-on-supersede)', async () => { + // Capture the AbortSignal from each invocation, and the resolver for + // the first one, so we can verify the first signal aborts when the + // second dispatch fires. + const signals: AbortSignal[] = []; + const op = (signal: AbortSignal, n: number) => { + signals.push(signal); + if (n === 1) { + // First dispatch hangs until aborted. + return new Promise(resolve => { + signal.addEventListener('abort', () => resolve(-1)); + }); + } + return Promise.resolve(n); + }; + const { result } = renderHook(() => useAction<[number], number>(op), { wrapper }); + act(() => { + result.current.send(1); + }); + expect(signals).toHaveLength(1); + expect(signals[0]!.aborted).toBe(false); + await act(async () => { + await result.current.sendAsync(2); + }); + expect(signals).toHaveLength(2); + expect(signals[0]!.aborted).toBe(true); + expect(signals[1]!.aborted).toBe(false); + expect(result.current.data).toBe(2); + }); + + it('preserves stale data during retrying (running after prior success)', async () => { + // First dispatch returns 42 immediately. Second dispatch returns a + // hanging promise whose resolver we keep so we can settle it later. + let call = 0; + let resolveSecond!: (n: number) => void; + const op = () => { + call += 1; + if (call === 1) return Promise.resolve(42); + return new Promise(resolve => { + resolveSecond = resolve; + }); + }; + const { result } = renderHook(() => useAction<[], number>(op), { wrapper }); + await act(async () => { + await result.current.sendAsync(); + }); + expect(result.current.data).toBe(42); + expect(result.current.isSuccess).toBe(true); + act(() => { + result.current.send(); + }); + expect(result.current.isRunning).toBe(true); + expect(result.current.data).toBe(42); // stale-while-revalidate + await act(async () => { + resolveSecond(100); + }); + expect(result.current.data).toBe(100); + }); +}); diff --git a/packages/kit-react/test/use-live-data.test.tsx b/packages/kit-react/test/use-live-data.test.tsx new file mode 100644 index 0000000..700960c --- /dev/null +++ b/packages/kit-react/test/use-live-data.test.tsx @@ -0,0 +1,296 @@ +/** + * @vitest-environment happy-dom + */ +import type { Address, Decoder, Slot, SolanaRpcResponse } from '@solana/kit'; +import { address, createReactiveStoreWithInitialValueAndSlotTracking } from '@solana/kit'; +import { act, renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useAccount } from '../src/hooks/use-account'; +import { useBalance } from '../src/hooks/use-balance'; +import { useLiveData } from '../src/hooks/use-live-data'; +import { useTransactionConfirmation } from '../src/hooks/use-transaction-confirmation'; +import { expectingError, Providers } from './helpers'; + +// Mock the single kit function useLiveData builds the store with, so tests +// can drive emissions / errors without real RPC traffic. All other kit +// exports pass through via `importOriginal`. +vi.mock('@solana/kit', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + createReactiveStoreWithInitialValueAndSlotTracking: vi.fn(), + }; +}); + +type ReactiveStore = { + getError: () => unknown; + getState: () => T | undefined; + subscribe: (listener: () => void) => () => void; +}; + +/** + * A controllable fake of kit's `ReactiveStore` (the inner shape that + * `createReactiveStoreWithInitialValueAndSlotTracking` returns, before + * `liftToStreamStore` wraps it). + */ +function fakeKitReactiveStore() { + const listeners = new Set<() => void>(); + let data: T | undefined; + let error: unknown; + const notify = () => listeners.forEach(l => l()); + const store: ReactiveStore = { + getError: () => error, + getState: () => data, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + return { + emit(value: T) { + data = value; + error = undefined; + notify(); + }, + fail(err: unknown) { + error = err; + notify(); + }, + store, + }; +} + +const FAKE_ADDRESS: Address = address('11111111111111111111111111111111'); + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +// Fake RPC / subscriptions shaped to satisfy the `ClientWithRpc*` capability +// checks. Every method returns an object that looks like a pending request +// but never gets read — these tests only exercise the null-gate / capability +// paths, which short-circuit before Kit touches the objects. +const fakeRpc = { + rpc: { + getAccountInfo: () => ({ send: () => Promise.resolve() }), + getBalance: () => ({ send: () => Promise.resolve() }), + getSignatureStatuses: () => ({ send: () => Promise.resolve() }), + }, + rpcSubscriptions: { + accountNotifications: () => ({ subscribe: () => ({}) }), + signatureNotifications: () => ({ subscribe: () => ({}) }), + }, +}; + +function wrapperWithRpc({ children }: { children: ReactNode }) { + return {children}; +} + +// The `disabled` lifecycle is live-client-only — in node, useLiveStore +// returns a permanent "loading" static store so SSR matches first-client +// render. Gate the disabled-state tests accordingly. +const IS_LIVE = __BROWSER__ || __REACTNATIVE__; + +describe.skipIf(!IS_LIVE)('useLiveData (live-client)', () => { + afterEach(() => vi.clearAllMocks()); + + it('reports disabled when factory returns null and does not invoke the factory body', () => { + const buildSpec = vi.fn(() => null); + const { result } = renderHook(() => useLiveData(buildSpec, []), { wrapper }); + expect(result.current.status).toBe('disabled'); + expect(result.current.isLoading).toBe(false); + }); + + it('reports loading before the store has emitted', () => { + const fake = fakeKitReactiveStore>(); + vi.mocked(createReactiveStoreWithInitialValueAndSlotTracking).mockReturnValue(fake.store); + + const { result } = renderHook( + () => + useLiveData( + () => ({ + rpcRequest: {} as never, + rpcSubscriptionRequest: {} as never, + rpcSubscriptionValueMapper: (v: number) => v, + rpcValueMapper: (v: number) => v, + }), + [], + ), + { wrapper }, + ); + expect(result.current.status).toBe('loading'); + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + }); + + it('transitions to loaded with data + slot when the store emits', () => { + const fake = fakeKitReactiveStore>(); + vi.mocked(createReactiveStoreWithInitialValueAndSlotTracking).mockReturnValue(fake.store); + + const { result } = renderHook( + () => + useLiveData( + () => ({ + rpcRequest: {} as never, + rpcSubscriptionRequest: {} as never, + rpcSubscriptionValueMapper: (v: number) => v, + rpcValueMapper: (v: number) => v, + }), + [], + ), + { wrapper }, + ); + act(() => fake.emit({ context: { slot: 5n as Slot }, value: 42 })); + expect(result.current.status).toBe('loaded'); + expect(result.current.data).toBe(42); + expect(result.current.slot).toBe(5n); + }); + + it('surfaces errors from the store', () => { + const fake = fakeKitReactiveStore>(); + vi.mocked(createReactiveStoreWithInitialValueAndSlotTracking).mockReturnValue(fake.store); + + const { result } = renderHook( + () => + useLiveData( + () => ({ + rpcRequest: {} as never, + rpcSubscriptionRequest: {} as never, + rpcSubscriptionValueMapper: (v: number) => v, + rpcValueMapper: (v: number) => v, + }), + [], + ), + { wrapper }, + ); + const err = new Error('boom'); + act(() => fake.fail(err)); + expect(result.current.status).toBe('error'); + expect(result.current.error).toBe(err); + }); +}); + +describe.skipIf(!IS_LIVE)('named live-data hooks (happy path, live-client)', () => { + afterEach(() => vi.clearAllMocks()); + + it('useAccount with a decoder requests base64 encoding and routes data through the decoder', () => { + const fake = fakeKitReactiveStore>(); + vi.mocked(createReactiveStoreWithInitialValueAndSlotTracking).mockReturnValue(fake.store); + + const getAccountInfo = vi.fn(() => ({ send: vi.fn() })); + const accountNotifications = vi.fn(() => ({ subscribe: vi.fn() })); + const wrapperWithAccountRpc = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + // Decoder returns a typed struct from the raw bytes. + const decoder = { decode: vi.fn(() => ({ kind: 'token', supply: 100n })) }; + const { result } = renderHook( + () => + useAccount<{ kind: string; supply: bigint }>( + FAKE_ADDRESS, + decoder as unknown as Decoder<{ kind: string; supply: bigint }>, + ), + { wrapper: wrapperWithAccountRpc }, + ); + expect(result.current.status).toBe('loading'); + + // Verify base64 encoding is requested (load-bearing for the decoder overload). + expect(getAccountInfo).toHaveBeenCalledWith(FAKE_ADDRESS, { encoding: 'base64' }); + expect(accountNotifications).toHaveBeenCalledWith(FAKE_ADDRESS, { encoding: 'base64' }); + + // Directly exercise the mapper from the spec: pass an RPC-shaped base64 + // account value and confirm the mapper parses + decodes it via the + // supplied decoder. + const [config] = vi.mocked(createReactiveStoreWithInitialValueAndSlotTracking).mock.calls[0]!; + const rpcAccount = { + data: ['AQID', 'base64'], // base64 of [1, 2, 3] + executable: false, + lamports: 1_000_000n, + owner: FAKE_ADDRESS, + rentEpoch: 0n, + space: 3n, + }; + const decoded = (config.rpcSubscriptionValueMapper as (v: unknown) => unknown)(rpcAccount); + expect(decoder.decode).toHaveBeenCalled(); + expect(decoded).toMatchObject({ data: { kind: 'token', supply: 100n } }); + }); + + it('useBalance passes the RPC request and subscription to the kit factory', () => { + const fake = fakeKitReactiveStore>(); + vi.mocked(createReactiveStoreWithInitialValueAndSlotTracking).mockReturnValue(fake.store); + + const getBalanceReq = { send: vi.fn() }; + const accountNotifsReq = { subscribe: vi.fn() }; + const wrapperWithBalanceRpc = ({ children }: { children: ReactNode }) => ( + getBalanceReq) }, + rpcSubscriptions: { accountNotifications: vi.fn(() => accountNotifsReq) }, + }} + > + {children} + + ); + const { result } = renderHook(() => useBalance(FAKE_ADDRESS), { wrapper: wrapperWithBalanceRpc }); + expect(result.current.status).toBe('loading'); + + const [config] = vi.mocked(createReactiveStoreWithInitialValueAndSlotTracking).mock.calls[0]!; + expect(config.rpcRequest).toBe(getBalanceReq); + expect(config.rpcSubscriptionRequest).toBe(accountNotifsReq); + + // Emit a balance and confirm it surfaces through the hook. + act(() => fake.emit({ context: { slot: 10n as Slot }, value: 1_000_000n })); + expect(result.current.status).toBe('loaded'); + expect(result.current.data).toBe(1_000_000n); + expect(result.current.slot).toBe(10n); + }); +}); + +describe('named live-data hooks (capability checks run in every env)', () => { + it('useBalance throws when no RPC is installed', () => { + expectingError(() => { + expect(() => renderHook(() => useBalance(FAKE_ADDRESS), { wrapper })).toThrow(/useBalance.*client\.rpc/s); + }); + }); + + it('useAccount throws when no RPC is installed', () => { + expectingError(() => { + expect(() => renderHook(() => useAccount(FAKE_ADDRESS), { wrapper })).toThrow(/useAccount.*client\.rpc/s); + }); + }); + + it('useTransactionConfirmation throws when no RPC is installed', () => { + expectingError(() => { + expect(() => renderHook(() => useTransactionConfirmation('sig' as never), { wrapper })).toThrow( + /useTransactionConfirmation.*client\.rpc/s, + ); + }); + }); +}); + +describe.skipIf(!IS_LIVE)('named live-data hooks (null-gate, live-client)', () => { + it('useBalance reports disabled when address is null', () => { + const { result } = renderHook(() => useBalance(null), { wrapper: wrapperWithRpc }); + expect(result.current.status).toBe('disabled'); + }); + + it('useAccount reports disabled when address is null', () => { + const { result } = renderHook(() => useAccount(null), { wrapper: wrapperWithRpc }); + expect(result.current.status).toBe('disabled'); + }); + + it('useTransactionConfirmation reports disabled when signature is null', () => { + const { result } = renderHook(() => useTransactionConfirmation(null), { wrapper: wrapperWithRpc }); + expect(result.current.status).toBe('disabled'); + }); +}); diff --git a/packages/kit-react/test/use-promise.test.ts b/packages/kit-react/test/use-promise.test.ts new file mode 100644 index 0000000..7b92ba3 --- /dev/null +++ b/packages/kit-react/test/use-promise.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { shimUsePromise, usePromise } from '../src/internal/use-promise'; + +describe('shimUsePromise (React 18 fallback)', () => { + it('throws the promise itself while pending', () => { + const promise = new Promise(() => {}); + let thrown: unknown; + try { + shimUsePromise(promise); + } catch (e) { + thrown = e; + } + expect(thrown).toBe(promise); + }); + + it('returns the resolved value once the promise has fulfilled', async () => { + const promise = Promise.resolve('hello'); + try { + shimUsePromise(promise); + } catch { + /* expected — initial call is before the microtask fires */ + } + await promise; + expect(shimUsePromise(promise)).toBe('hello'); + }); + + it('throws the rejection reason once the promise has rejected', async () => { + const reason = new Error('boom'); + const promise = Promise.reject(reason); + try { + shimUsePromise(promise); + } catch { + /* expected — initial call throws the promise itself */ + } + await promise.catch(() => {}); + expect(() => shimUsePromise(promise)).toThrow(reason); + }); + + it('caches per-promise so re-calls after fulfillment skip the throw', async () => { + const promise = Promise.resolve(42); + try { + shimUsePromise(promise); + } catch { + /* prime the cache */ + } + await promise; + // Multiple subsequent calls should all return the same value + // synchronously without throwing — proves the WeakMap cache is + // doing its job rather than re-throwing on every render. + expect(shimUsePromise(promise)).toBe(42); + expect(shimUsePromise(promise)).toBe(42); + expect(shimUsePromise(promise)).toBe(42); + }); + + it('different promises maintain independent cache entries', async () => { + const p1 = Promise.resolve('a'); + const p2 = Promise.resolve('b'); + try { + shimUsePromise(p1); + } catch { + /* prime */ + } + try { + shimUsePromise(p2); + } catch { + /* prime */ + } + await Promise.all([p1, p2]); + expect(shimUsePromise(p1)).toBe('a'); + expect(shimUsePromise(p2)).toBe('b'); + }); +}); + +describe('usePromise (auto-detected React.use vs shim)', () => { + it('exposes a function regardless of React version', () => { + // Sanity: the runtime detection picks one of two callables. The + // test environment runs React 19 so this is `React.use`; on 18 it + // would be `shimUsePromise`. Either way it is callable. + expect(typeof usePromise).toBe('function'); + }); +}); diff --git a/packages/kit-react/test/use-request.test.tsx b/packages/kit-react/test/use-request.test.tsx new file mode 100644 index 0000000..90e7520 --- /dev/null +++ b/packages/kit-react/test/use-request.test.tsx @@ -0,0 +1,144 @@ +/** + * @vitest-environment happy-dom + */ +import { act, renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useRequest } from '../src/hooks/use-request'; +import type { ReactiveActionStore } from '../src/kit-prereqs'; +import { fakeActionStore } from './fake-stores'; +import { Providers } from './helpers'; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +const IS_LIVE = __BROWSER__ || __REACTNATIVE__; + +describe.skipIf(!IS_LIVE)('useRequest (live-client)', () => { + it('starts in loading until the store emits', () => { + const store = fakeActionStore(); + const { result } = renderHook( + () => useRequest(() => ({ reactiveStore: () => store as unknown as ReactiveActionStore<[], number> }), []), + { wrapper }, + ); + expect(result.current.status).toBe('loading'); + expect(result.current.isLoading).toBe(true); + }); + + it('transitions to loaded on success', () => { + const store = fakeActionStore(); + const { result } = renderHook( + () => useRequest(() => ({ reactiveStore: () => store as unknown as ReactiveActionStore<[], number> }), []), + { wrapper }, + ); + act(() => store.setState({ data: 7, error: undefined, status: 'success' })); + expect(result.current.status).toBe('loaded'); + expect(result.current.data).toBe(7); + }); + + it('reports retrying (with stale data) on re-dispatch after prior success', () => { + const store = fakeActionStore(); + const { result } = renderHook( + () => useRequest(() => ({ reactiveStore: () => store as unknown as ReactiveActionStore<[], number> }), []), + { wrapper }, + ); + act(() => store.setState({ data: 7, error: undefined, status: 'success' })); + act(() => store.setState({ data: 7, error: undefined, status: 'running' })); + expect(result.current.status).toBe('retrying'); + expect(result.current.data).toBe(7); // stale-while-revalidate + }); + + it('surfaces errors', () => { + const store = fakeActionStore(); + const { result } = renderHook( + () => useRequest(() => ({ reactiveStore: () => store as unknown as ReactiveActionStore<[], number> }), []), + { wrapper }, + ); + const err = new Error('nope'); + act(() => store.setState({ data: undefined, error: err, status: 'error' })); + expect(result.current.status).toBe('error'); + expect(result.current.error).toBe(err); + }); + + it('reports status "disabled" when factory returns null', () => { + const factory = vi.fn(() => null); + const { result } = renderHook(() => useRequest(factory, []), { wrapper }); + expect(result.current.status).toBe('disabled'); + expect(result.current.isLoading).toBe(false); + }); + + it('refresh() calls dispatch on the store', () => { + const store = fakeActionStore(); + const dispatch = vi.fn(); + store.dispatch = dispatch; + const { result } = renderHook( + () => useRequest(() => ({ reactiveStore: () => store as unknown as ReactiveActionStore<[], number> }), []), + { wrapper }, + ); + result.current.refresh(); + expect(dispatch).toHaveBeenCalled(); + }); + + it('rebuilds the store when deps change', () => { + const factory = vi.fn(() => ({ reactiveStore: () => fakeActionStore() })); + const { rerender } = renderHook(({ dep }: { dep: number }) => useRequest(factory, [dep]), { + initialProps: { dep: 1 }, + wrapper, + }); + expect(factory).toHaveBeenCalledTimes(1); + rerender({ dep: 2 }); + expect(factory).toHaveBeenCalledTimes(2); + }); + + it('aborts the prior signal when deps change', () => { + const signals: AbortSignal[] = []; + const { rerender } = renderHook( + ({ dep }: { dep: number }) => + useRequest( + signal => { + signals.push(signal); + return { reactiveStore: () => fakeActionStore() }; + }, + [dep], + ), + { initialProps: { dep: 1 }, wrapper }, + ); + expect(signals[0]!.aborted).toBe(false); + rerender({ dep: 2 }); + expect(signals[0]!.aborted).toBe(true); + expect(signals[1]!.aborted).toBe(false); + }); + + // TODO(kit#1555): delete this test when Kit ships `.reactiveStore()` on + // `PendingRpcRequest` and the `reactiveStoreFromPendingRequest` shim is + // removed. It exercises the adapter path specifically; the native + // `{ reactiveStore() }` path is already covered by the tests above that + // use `fakeActionStore`. + it('accepts a raw PendingRpcRequest and wraps it via the shim', async () => { + // Fake PendingRpcRequest — has `.send({ abortSignal })` only, no + // `.reactiveStore()`. The hook should detect the missing method + // and go through `reactiveStoreFromPendingRequest` internally. + const send = vi.fn((_opts?: { abortSignal?: AbortSignal }) => Promise.resolve(42)); + const pending = { send }; + const { result } = renderHook(() => useRequest(() => pending, []), { wrapper }); + // The shim auto-dispatches on construction, so send fires immediately. + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[0]?.abortSignal).toBeInstanceOf(AbortSignal); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.status).toBe('loaded'); + expect(result.current.data).toBe(42); + }); +}); + +describe.skipIf(IS_LIVE)('useRequest (SSR / node)', () => { + it('never runs the factory and reports disabled', () => { + const factory = vi.fn(() => ({ reactiveStore: () => fakeActionStore() })); + const { result } = renderHook(() => useRequest(factory, []), { wrapper }); + expect(factory).not.toHaveBeenCalled(); + expect(result.current.status).toBe('disabled'); + }); +}); diff --git a/packages/kit-react/test/use-subscription.test.tsx b/packages/kit-react/test/use-subscription.test.tsx new file mode 100644 index 0000000..a566385 --- /dev/null +++ b/packages/kit-react/test/use-subscription.test.tsx @@ -0,0 +1,201 @@ +/** + * @vitest-environment happy-dom + */ +import type { Slot, SolanaRpcResponse } from '@solana/kit'; +import { act, renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useSubscription } from '../src/hooks/use-subscription'; +import type { ReactiveStreamStore } from '../src/kit-prereqs'; +import { fakeStreamStore } from './fake-stores'; +import { Providers } from './helpers'; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +// useLiveStore only builds the factory's store in a live-client environment +// (browser / react-native). In node it short-circuits to a permanent loading +// store so SSR matches the first client render — the suite below exercises +// the live path, so skip it in node. The separate `describe` further down +// locks in the SSR behavior explicitly. +const IS_LIVE = __BROWSER__ || __REACTNATIVE__; + +describe.skipIf(!IS_LIVE)('useSubscription (live-client)', () => { + it('reports loading before the first emit', () => { + const store = fakeStreamStore(); + const { result } = renderHook( + () => useSubscription(() => ({ reactiveStore: () => store as unknown as ReactiveStreamStore }), []), + { wrapper }, + ); + expect(result.current.status).toBe('loading'); + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + }); + + it('forwards raw-value emissions with slot undefined', () => { + const store = fakeStreamStore(); + const { result } = renderHook( + () => useSubscription(() => ({ reactiveStore: () => store as unknown as ReactiveStreamStore }), []), + { wrapper }, + ); + act(() => store.emit(42)); + expect(result.current.data).toBe(42); + expect(result.current.slot).toBeUndefined(); + expect(result.current.status).toBe('loaded'); + }); + + it('unwraps SolanaRpcResponse envelopes into data + slot', () => { + const store = fakeStreamStore>(); + const { result } = renderHook( + () => + useSubscription>( + () => ({ reactiveStore: () => store as unknown as ReactiveStreamStore> }), + [], + ), + { wrapper }, + ); + act(() => + store.emit({ + context: { slot: 123n as Slot }, + value: 'hello', + }), + ); + expect(result.current.data).toBe('hello'); + expect(result.current.slot).toBe(123n); + }); + + it('surfaces errors', () => { + const store = fakeStreamStore(); + const { result } = renderHook( + () => useSubscription(() => ({ reactiveStore: () => store as unknown as ReactiveStreamStore }), []), + { wrapper }, + ); + const err = new Error('boom'); + act(() => store.fail(err)); + expect(result.current.status).toBe('error'); + expect(result.current.error).toBe(err); + }); + + it('reports status "disabled" when factory returns null, and does not call reactiveStore', () => { + const reactiveStore = vi.fn(); + const { result } = renderHook(() => useSubscription(() => null, []), { wrapper }); + expect(result.current.status).toBe('disabled'); + expect(result.current.isLoading).toBe(false); + expect(reactiveStore).not.toHaveBeenCalled(); + }); + + it('passes an AbortSignal to the factory and aborts on unmount', () => { + let capturedSignal: AbortSignal | undefined; + const store = fakeStreamStore(); + const { unmount } = renderHook( + () => + useSubscription(signal => { + capturedSignal = signal; + return { reactiveStore: () => store as unknown as ReactiveStreamStore }; + }, []), + { wrapper }, + ); + expect(capturedSignal?.aborted).toBe(false); + unmount(); + expect(capturedSignal?.aborted).toBe(true); + }); + + it('rebuilds the store when deps change', () => { + const factory = vi.fn(() => ({ reactiveStore: () => fakeStreamStore() })); + const { rerender } = renderHook(({ dep }: { dep: number }) => useSubscription(factory, [dep]), { + initialProps: { dep: 1 }, + wrapper, + }); + expect(factory).toHaveBeenCalledTimes(1); + rerender({ dep: 2 }); + expect(factory).toHaveBeenCalledTimes(2); + }); + + it('aborts the prior signal when deps change', () => { + const signals: AbortSignal[] = []; + const { rerender } = renderHook( + ({ dep }: { dep: number }) => + useSubscription( + signal => { + signals.push(signal); + return { reactiveStore: () => fakeStreamStore() }; + }, + [dep], + ), + { initialProps: { dep: 1 }, wrapper }, + ); + expect(signals[0]!.aborted).toBe(false); + rerender({ dep: 2 }); + expect(signals[0]!.aborted).toBe(true); + expect(signals[1]!.aborted).toBe(false); + }); + + it('retry() transitions error status to retrying (and preserves stale data)', () => { + const store = fakeStreamStore(); + const { result } = renderHook( + () => useSubscription(() => ({ reactiveStore: () => store as unknown as ReactiveStreamStore }), []), + { wrapper }, + ); + act(() => store.emit(7)); + act(() => store.fail(new Error('boom'))); + expect(result.current.status).toBe('error'); + act(() => result.current.retry()); + expect(result.current.status).toBe('retrying'); + expect(result.current.data).toBe(7); // stale value preserved + }); + + // TODO(kit#1553): delete this test when Kit ships a sync `.reactiveStore()` + // on `PendingRpcSubscriptionsRequest` and the + // `reactiveStoreFromPendingSubscriptionsRequest` shim is removed. It + // exercises the adapter path specifically; the native + // `{ reactiveStore({ abortSignal }) }` path is already covered by the + // tests above that use `fakeStreamStore`. + it('accepts a raw PendingRpcSubscriptionsRequest and wraps it via the shim', async () => { + // Fake PendingRpcSubscriptionsRequest — has the async `.reactive()` + // method (plus `.subscribe()` for type conformance) but no sync + // `.reactiveStore()`. The hook should detect the missing sync + // method and go through `reactiveStoreFromPendingSubscriptionsRequest`. + const listeners = new Set<() => void>(); + let currentValue: number | undefined; + const innerStore = { + getError: () => undefined, + getState: () => currentValue, + subscribe: (l: () => void) => { + listeners.add(l); + return () => { + listeners.delete(l); + }; + }, + }; + const reactive = vi.fn((_opts: { abortSignal: AbortSignal }) => Promise.resolve(innerStore)); + const pending = { + reactive, + // `subscribe` is part of PendingRpcSubscriptionsRequest's type but + // the shim only ever calls `.reactive()`. + subscribe: vi.fn(), + } as unknown as import('@solana/kit').PendingRpcSubscriptionsRequest; + const { result } = renderHook(() => useSubscription(() => pending, []), { wrapper }); + expect(result.current.status).toBe('loading'); + expect(reactive).toHaveBeenCalledTimes(1); + await act(async () => { + await Promise.resolve(); + }); + act(() => { + currentValue = 99; + listeners.forEach(l => l()); + }); + expect(result.current.status).toBe('loaded'); + expect(result.current.data).toBe(99); + }); +}); + +describe.skipIf(IS_LIVE)('useSubscription (SSR / node)', () => { + it('never runs the factory and reports loading', () => { + const factory = vi.fn(() => ({ reactiveStore: () => fakeStreamStore() })); + const { result } = renderHook(() => useSubscription(factory, []), { wrapper }); + expect(factory).not.toHaveBeenCalled(); + expect(result.current.status).toBe('loading'); + }); +}); diff --git a/packages/kit-react/tsconfig.declarations.json b/packages/kit-react/tsconfig.declarations.json new file mode 100644 index 0000000..dc2d27b --- /dev/null +++ b/packages/kit-react/tsconfig.declarations.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "./dist/types" + }, + "extends": "./tsconfig.json", + "include": ["src/index.ts", "src/types"] +} diff --git a/packages/kit-react/tsconfig.json b/packages/kit-react/tsconfig.json new file mode 100644 index 0000000..26c6f1a --- /dev/null +++ b/packages/kit-react/tsconfig.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["DOM", "DOM.Iterable", "ESNext"] + }, + "display": "@solana/kit-react", + "extends": "../../tsconfig.json", + "include": ["src", "test"] +} diff --git a/packages/kit-react/tsup.config.ts b/packages/kit-react/tsup.config.ts new file mode 100644 index 0000000..55e9945 --- /dev/null +++ b/packages/kit-react/tsup.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'tsup'; + +import { getPackageBuildConfigs } from '../../tsup.config.base'; + +export default defineConfig(getPackageBuildConfigs()); diff --git a/packages/kit-react/vitest.config.mts b/packages/kit-react/vitest.config.mts new file mode 100644 index 0000000..3d742cd --- /dev/null +++ b/packages/kit-react/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +import { getVitestConfig } from '../../vitest.config.base.mts'; + +export default defineConfig({ + test: { + projects: [getVitestConfig('browser'), getVitestConfig('node'), getVitestConfig('react-native')], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 169d5b6..46499c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,6 +239,31 @@ importers: specifier: workspace:* version: link:../kit-plugin-rpc + packages/kit-react: + dependencies: + '@solana/kit': + specifier: ^6.8.0 + version: 6.8.0(typescript@5.9.3) + '@solana/subscribable': + specifier: ^6.8.0 + version: 6.8.0(typescript@5.9.3) + devDependencies: + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.14) + react: + specifier: ^19.0.0 + version: 19.2.5 + react-dom: + specifier: ^19.0.0 + version: 19.2.5(react@19.2.5) + packages: '@babel/code-frame@7.29.0': @@ -1844,6 +1869,25 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@turbo/darwin-64@2.9.6': resolution: {integrity: sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg==} cpu: [x64] @@ -1877,6 +1921,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1923,6 +1970,14 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -2297,6 +2352,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -2484,6 +2542,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} @@ -2511,6 +2572,10 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -2523,6 +2588,9 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dotenv@8.6.0: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} @@ -3205,6 +3273,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3460,6 +3532,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@30.2.0: resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3477,9 +3553,21 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + engines: {node: '>=0.10.0'} + read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} @@ -3538,6 +3626,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -5658,6 +5749,27 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@turbo/darwin-64@2.9.6': optional: true @@ -5681,6 +5793,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 @@ -5738,6 +5852,14 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + '@types/semver@7.7.1': {} '@types/stack-utils@2.0.3': {} @@ -6147,6 +6269,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + array-union@2.1.0: {} assertion-error@2.0.1: {} @@ -6329,6 +6455,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + dataloader@1.4.0: {} debug@4.4.3: @@ -6341,6 +6469,8 @@ snapshots: deepmerge@4.3.1: {} + dequal@2.0.3: {} + detect-indent@6.1.0: {} detect-newline@3.1.0: {} @@ -6349,6 +6479,8 @@ snapshots: dependencies: path-type: 4.0.0 + dom-accessibility-api@0.5.16: {} + dotenv@8.6.0: {} eastasianwidth@0.2.0: {} @@ -7263,6 +7395,8 @@ snapshots: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7471,6 +7605,12 @@ snapshots: prettier@3.8.3: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-format@30.2.0: dependencies: '@jest/schemas': 30.0.5 @@ -7485,8 +7625,17 @@ snapshots: queue-microtask@1.2.3: {} + react-dom@19.2.5(react@19.2.5): + dependencies: + react: 19.2.5 + scheduler: 0.27.0 + + react-is@17.0.2: {} + react-is@18.3.1: {} + react@19.2.5: {} + read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -7584,6 +7733,8 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.27.0: {} + semver@6.3.1: {} semver@7.7.4: {} diff --git a/react-lib-spec.md b/react-lib-spec.md new file mode 100644 index 0000000..a62a2e2 --- /dev/null +++ b/react-lib-spec.md @@ -0,0 +1,2626 @@ +# RFC: `@solana/kit-react` — React Bindings for Kit + +**Status:** Draft +**Package:** `@solana/kit-react` + +## Contents + +- [Summary](#summary) +- [Prerequisites](#prerequisites) + - [In `@solana/subscribable`](#in-solanasubscribable) + - [In `@solana/rpc-subscriptions-spec`](#in-solanarpc-subscriptions-spec) + - [In `@solana/rpc-spec`](#in-solanarpc-spec) + - [In `@solana/kit`](#in-solanakit) + - [In `@solana/kit-plugin-rpc`](#in-solanakit-plugin-rpc) + - [In `@solana/kit-plugin-wallet`](#in-solanakit-plugin-wallet) + - [Errors](#errors) +- [Architecture](#architecture) + - [Principles](#principles) +- [Core Library (`@solana/kit-react`)](#core-library-solanakit-react) + - [Dependencies](#dependencies) + - [`KitClientProvider`](#kitclientprovider) + - [Common case](#common-case) + - [Dynamic clients](#dynamic-clients) + - [Async plugins (Suspense)](#async-plugins-suspense) + - [Advanced examples](#advanced-examples) + - [Hooks](#hooks) + - [Client access](#client-access) + - [Wallet](#wallet) + - [Signer access](#signer-access) + - [Getting a kit signer from a wallet account](#getting-a-kit-signer-from-a-wallet-account) + - [Live data (subscription-backed)](#live-data-subscription-backed) + - [Generic live data (`useLiveData`)](#generic-live-data-uselivedata) + - [Subscriptions (no initial fetch)](#subscriptions-no-initial-fetch) + - [One-shot requests (`useRequest`)](#one-shot-requests-userequest) + - [Sending transactions](#sending-transactions) + - [Generic async action](#generic-async-action) + - [One-shot reads](#one-shot-reads) +- [Third-party extensions](#third-party-extensions) + - [Example: a DAS plugin package](#example-a-das-plugin-package) + - [`useClientCapability` — runtime-checked third-party hooks](#useclientcapability--runtime-checked-third-party-hooks) + - [`useClient()` vs. `useClientCapability()`](#useclientt-vs-useclientcapabilityt) +- [SWR Adapter (`@solana/kit-react/swr`)](#swr-adapter-solanakit-reactswr) + - [Dependencies](#dependencies-1) + - [Naming convention](#naming-convention) + - [Generic bridge](#generic-bridge) + - [Subscription-only bridge](#subscription-only-bridge) + - [Mutation hooks](#mutation-hooks) + - [Generic action bridge](#generic-action-bridge) + - [One-shot reads](#one-shot-reads-1) +- [TanStack Query Adapter (`@solana/kit-react/query`)](#tanstack-query-adapter-solanakit-reactquery) + - [Dependencies](#dependencies-2) + - [Naming convention](#naming-convention-1) + - [Generic bridge](#generic-bridge-1) + - [Subscription-only bridge](#subscription-only-bridge-1) + - [Mutation hooks](#mutation-hooks-1) + - [Generic action bridge](#generic-action-bridge-1) + - [One-shot reads](#one-shot-reads-2) +- [What Each Layer Provides](#what-each-layer-provides) +- [Design Decisions](#design-decisions) +- [Future directions](#future-directions) + - [Promote the `subscribeTo` producer-side helper to kit-core](#promote-the-subscribetocapability-producer-side-helper-to-kit-core) + - [Batched live-query hook](#batched-live-query-hook) + - [Observe external writes to `WalletStorage`](#observe-external-writes-to-walletstorage) +- [Appendix: Comparisons](#appendix-comparisons) + - [framework-kit](#framework-kit) + - [connectorkit](#connectorkit) + - [wallet-ui](#wallet-ui) + - [wallet-adapter](#wallet-adapter) + - [Before and after: Kit example React app](#before-and-after-kit-example-react-app) + +## Summary + +A React library for building Solana dApps using Kit. The library ships as a single package (`@solana/kit-react`) with a wallet-agnostic core entry and three optional subpaths: `@solana/kit-react/wallet` for wallet-specific hooks, `@solana/kit-react/swr` and `@solana/kit-react/query` for cache-library adapters. Each subpath declares its runtime peer dependency as optional, so apps that don't use wallet (or SWR, or TanStack Query) don't pull those packages in. + +Core covers live on-chain data, one-shot RPC reads, transaction sending, and signer access. The wallet subpath adds wallet discovery, connection lifecycle, and wallet-specific hooks. The cache-library adapters provide bridges for apps that want to integrate kit-react's reactive state with SWR or TanStack Query (cache dedupe, persistence, devtools). + +The library is **client-first**: consumers build a Kit client with `createClient().use(...)` outside React and hand it to a single `KitClientProvider`. Plugin composition happens in plain Kit; React distributes the result. Any Kit plugin is usable without a React-specific wrapper. + +This spec assumes a set of Kit and plugin changes have landed, described in [Prerequisites](#prerequisites). Those changes carry the framework-agnostic state machines, abort semantics, and reactive primitives that the React bindings consume. The React layer reduces to `useSyncExternalStore` glue over Kit primitives plus render-ergonomic conveniences — no state machine, fetch policy, or async lifecycle logic lives in kit-react that doesn't belong one layer down. + +## Prerequisites + +These changes must land in Kit and kit-plugins before this spec can be implemented cleanly. They're not React-specific — every reactive UI framework (Vue, Svelte, Solid) benefits from the same primitives — so they belong in Kit rather than in per-framework bindings. + +### In `@solana/subscribable` + +Two reactive store types covering the two categories of async operations. Both expose `subscribe(listener): () => void` plus a snapshot accessor returning the full `{ status, data, error }` state. The accessor name is asymmetric for now: `ReactiveStreamStore` exposes `getUnifiedState()` (its legacy `getState()` returns only the value and is deprecated), while `ReactiveActionStore` exposes `getState()` directly. Both will converge on `getState()` returning the unified snapshot in a later breaking change; the asymmetry is accepted in the meantime to avoid a breaking Kit release. + +**`ReactiveStreamStore`** — for long-lived connections that emit multiple values (RPC subscriptions, and the RPC-fetch + subscription hybrid used by named hooks). Lifecycle `loading | loaded | error | retrying`. `retry()` re-establishes a broken connection, preserving the last known value as stale data during `retrying`. Replaces the prior `ReactiveStore` type — the single generic store splits into the two specialized types below, no backwards-compat alias needed since `ReactiveStore` was not yet widely consumed. + +Note CM: Already have this as `ReactiveStore`. [Open PR](https://github.com/anza-xyz/kit/pull/1552) adds retry + status + getUnifiedState. Will rename to `ReactiveStreamStore` + +**`ReactiveActionStore`** — for invocation-based async operations (user-triggered actions, and also one-shot RPC reads when auto-dispatched by the consumer). Lifecycle `idle | running | success | error`. `dispatch(...args)` invokes the operation; a second dispatch while a first is in flight aborts the first via its `abortSignal` — "click twice, only the second submits." Stale-while-revalidate: during `running` after a previous `success`, `data` retains the old value so consumers can render stale data with an overlay. + +Note CM: [Open PR](https://github.com/anza-xyz/kit/pull/1550), will rename from `ActionStore` + +**`createReactiveActionStore(operation: (signal: AbortSignal, ...args: TArgs) => Promise): ReactiveActionStore`** — factory for action stores. Kit primitive; every framework's action-hook implementation ≈ `useSyncExternalStore(store.subscribe, store.getState)` plus a bridge wrapping `dispatch` / `reset`. + +Note CM: [Open PR](https://github.com/anza-xyz/kit/pull/1550) + +### In `@solana/rpc-subscriptions-spec` + +**`PendingRpcSubscriptionsRequest.reactiveStore({ abortSignal }): ReactiveStreamStore`** — synchronous method returning a ready-to-consume reactive store for a subscription. Internally delegates to `createReactiveStoreFromDataPublisherFactory` so `retry()` can re-open the WebSocket without losing subscribers. The `loading` state covers the transport-setup window; setup failures surface as `status: 'error'`. + +The previous async `.reactive(): Promise>` is either renamed to `.reactiveStore()` as a breaking change, or `.reactiveStore()` is added as the sync replacement with `.reactive()` deprecated. Either is fine; the spec uses the sync name. + +Note CM: [Open PR](https://github.com/anza-xyz/kit/pull/1553) + +### In `@solana/rpc-spec` + +**`PendingRpcRequest.reactiveStore(): ReactiveActionStore<[], T>`** — synchronous method returning an action store that auto-dispatches on creation, then fires a fresh RPC call per subsequent dispatch. Construction semantics parallel `PendingRpcSubscriptionsRequest.reactiveStore()` — calling `.reactiveStore()` means "I want this live now"; callers who want a build-now-dispatch-later store drop to `createReactiveActionStore(signal => this.send({ abortSignal: signal }))` directly. `ReactiveActionStore` itself stays neutral on initiation — only `.reactiveStore()` commits to eager dispatch. + +Precondition: `PendingRpcRequest` must be multi-dispatch — each dispatch re-invokes the transport. (Confirmed already the case.) + +SSR rule: bindings must not call `.reactiveStore()` on the server, same as for `PendingRpcSubscriptionsRequest`. Auto-dispatch means creating the store is a side-effecting operation; server-side prefetch, if wanted, uses the imperative `.send()` path directly. + +No store-level `abortSignal` argument (unlike `PendingRpcSubscriptionsRequest.reactiveStore({ abortSignal })`) — the per-dispatch signal from `createReactiveActionStore`'s operation handles supersede, and an in-flight HTTP request completes on its own without leaking resources. A WebSocket does leak if left open, which is why subscriptions need a lifetime anchor; RPC reads don't. + +Note CM: TODO + +### In `@solana/kit` + +**`createReactiveStoreWithInitialValueAndSlotTracking(...): ReactiveStreamStore>`** — returns the new specialized stream store type with `retry()` support. `retry()` re-runs both the initial RPC fetch and the subscription. No change to API surface needed + +Note CM: Part of [Open PR](https://github.com/anza-xyz/kit/pull/1552) + +### In `@solana/kit-plugin-rpc` + +**`solanaRpcConnection` plugin.** A single plugin that installs both `client.rpc` and `client.rpcSubscriptions`, configured from `{ rpcUrl, rpcSubscriptionsUrl }`. Replaces the previous pairing of `solanaRpcConnection` + `solanaRpcSubscriptionsConnection` for the common case, and supersedes `solanaRpcReadOnly` (which also installed `getMinimumBalance`; that helper is trivially reconstructable via a second `.use(...)` when needed). kit-react consumers call `.use(solanaRpcConnection({ rpcUrl }))` on their client directly. + +Note CM: [Open PR](https://github.com/anza-xyz/kit-plugins/pull/201), known breaking change + +### In `@solana/kit-plugin-wallet` + +**Signal-aware operations.** `client.wallet.connect`, `.disconnect`, `.signMessage`, and `.signIn` accept an `abortSignal` and internally wrap the wallet-standard calls with `getAbortablePromise(promise, signal)`. Reason: kit-react's action-store hooks use double-click-supersede by aborting the in-flight signal; without signal plumbing on the wallet plugin's operations, `useConnectWallet` / `useSignMessage` etc. would silently complete the original call in the background after the store's state had already moved on. + +Caveat: the wallet-standard spec doesn't accept abort signals today, so `getAbortablePromise` cancels the *await* but not the underlying wallet call. Practical consequence: a double-click on Connect may briefly show two wallet popups. Most wallets de-dupe these; documenting the limitation is enough for now. When wallet-standard adds signal support, this becomes end-to-end cancellation automatically. + +Note CM: TODO on wallet-plugin, on [open PR](https://github.com/anza-xyz/kit-plugins/pull/191) + +Note CM: TODO wallet-standard proposal to add signals, backward compatible/optional. Orthogonal to this work, not blocking + +**`subscribeToPayer` / `subscribeToIdentity` publish points.** The `walletSigner` / `walletPayer` / `walletIdentity` plugins install a sibling `subscribeTo(listener): () => void` function alongside each reactive capability they set on the client. kit-react's `usePayer` / `useIdentity` subscribe to these. The wallet plugin is currently the only reactive signer source; if a second reactive plugin appears (e.g. a relayer that rotates `payer`), the convention extends cleanly. + +Note CM: ClientWithSubscribeToPayer/Identity interfaces merged in Kit (not released yet) + +Note CM: TODO add to wallet-plugin, on [open PR](https://github.com/anza-xyz/kit-plugins/pull/191) + +### Errors + +Kit throws `SolanaError` with narrowable codes; the wallet plugin throws `WalletStandardError` with the same pattern. kit-react propagates errors through `LiveQueryResult.error` / `ActionResult.error` as `unknown`; consumers narrow via `isSolanaError(e, SOLANA_ERROR__...)` and `isWalletStandardError(e, ...)` in their render branches. No new Kit work for this — just a documentation pattern in kit-react's error-handling examples. + +**`isAbortError(error: unknown): boolean`** — narrow-type predicate for abort rejections, used by callers who `await` an action-hook's `send(...)` and want to filter out supersede rejections. Lives in Kit (not kit-react) so every reactive-framework binding and every consumer that writes abortable code against Kit primitives can share the one implementation. + +Note CM: Merged into Kit, not yet released — will be imported from `@solana/kit` directly once available. + +--- + +With these in place, kit-react is ~300 lines of bridge code. The rest of this spec describes that bridge — what the single provider looks like, what each hook returns, how the pieces compose. + +## Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ @solana/kit-react/swr (optional subpath) │ +│ @solana/kit-react/query (optional subpath) │ +│ Generic bridges + cache integration │ +├──────────────────────────────────────────────────────┤ +│ @solana/kit-react/wallet (optional subpath) │ +│ Wallet-specific hooks │ +├──────────────────────────────────────────────────────┤ +│ @solana/kit-react (core entry) │ +│ KitClientProvider (the only provider) │ +│ Live-data, RPC-read, action, and signer hooks │ +│ useSyncExternalStore bridge to Kit stores │ +├──────────────────────────────────────────────────────┤ +│ Kit + plugins (framework-agnostic) │ +│ ReactiveStreamStore / ReactiveActionStore │ +│ createReactiveActionStore, .reactiveStore() on pendings, │ +│ walletSigner / walletPayer / walletIdentity / … │ +└──────────────────────────────────────────────────────┘ +``` + +Subpaths keep the core wallet-agnostic: apps that don't use a user wallet (read-only dashboards, keypair-driven bots, server flows) can depend on `@solana/kit-react` alone without installing `@solana/kit-plugin-wallet` — the subpath's peer dependency is declared optional, tree-shaking keeps the wallet code out of the core bundle, and the TypeScript surface only surfaces wallet names to code that imports from `/wallet`. Signer hooks (`usePayer`, `useIdentity`) live in core and stay reactive against wallet-installed signers via the [`subscribeTo` convention](#subscribetocapability-convention) — no type-level dependency on the wallet subpath. + +A single-package layout (rather than a sibling `@solana/kit-react-wallet` package) is intentional. React bindings share a `ClientContext`, a React peer dep, and a shared type surface (`LiveQueryResult`, `ActionResult`, `ChainIdentifier`) — splitting them across packages multiplies the "two copies of the same lib in the dep tree" failure mode without corresponding benefit. Kit's runtime plugins split cleanly because they don't share mutable singletons; React bindings don't, so they follow the ecosystem norm (wagmi, TanStack, Redux, Apollo) and consolidate with subpath exports. + +### Principles + +**Client-first.** Consumers build a Kit client with `createClient().use(...)` and hand it to `KitClientProvider`. Plugin composition belongs in Kit — not in React's tree — so any Kit plugin (sync or async) is usable without a React-specific wrapper, and the same client can be shared between React, workers, SSR, tests, and scripts. The provider does no composition, lifecycle management, or disposal; it distributes a caller-owned value. + +**Reactivity belongs to plugins, not providers.** Wallet connect/disconnect, payer rotation, identity switching — all handled inside their plugins via the `subscribeTo` convention and `client.wallet.subscribe` / `getState`. The client identity stays stable; React hooks subscribe to plugin-published reactivity via `useSyncExternalStore`. When a config *does* need to change at runtime (chain toggle, RPC URL switch), consumers rebuild the client in `useMemo` and pass the new reference — the provider is a value channel, not a lifecycle channel. + +**Async plugins suspend.** When a plugin's `.use()` returns a promise, `createClient().use(...)` returns `Promise`; consumers pass that promise to `KitClientProvider`, which suspends via the nearest `` boundary. On React 19 this uses native `React.use(promise)`; on React 18 the provider uses a thrown-promise shim internally. No special async mode, no per-plugin React wrapper. + +**`useSyncExternalStore` for all reactive state.** Wallet state, live queries, RPC reads, actions — every reactive hook in the library is a bridge from a Kit-side store (`ReactiveStreamStore`, `ReactiveActionStore`, or the wallet plugin's subscribe/getState contract) into `useSyncExternalStore`. No polling, no `useEffect` + `setState`, no hand-rolled state machines in the hook layer. + +**Kit owns the state machines.** Lifecycle enums (`loading | loaded | error | retrying` for streams, `idle | running | success | error` for actions), abort semantics (double-click supersede on actions, retry-as-reconnection on streams), and stale-while-revalidate behavior all live in Kit primitives. kit-react exposes them through `useSyncExternalStore`; it does not reimplement them. This keeps the React layer thin and the behavior consistent with any future Vue / Svelte / Solid binding. + +**Named hooks only where there's domain logic.** `useBalance` exists because it hides RPC + subscription pairing, slot dedup, and response mapping. `useGetEpochInfo` does not exist because it would be a one-liner wrapping `client.rpc.getEpochInfo()`. For one-off reads without domain logic, callers use `useRequest` (the generic bridge for any `.reactiveStore()`-backed pending, including RPC calls) or reach into `client.rpc.*` directly through the escape-hatch `useClient()`. + +**Adapters integrate, they don't replace.** The SWR and TanStack Query adapters bridge kit-react's reactive state into those libraries' cache layers (dedupe across components, persistence, devtools, Suspense modes) and expose mutation hooks that play with cache invalidation. One-shot reads no longer *require* a cache library — `useRequest` covers them natively — but apps that want shared cache semantics across many components can opt in. + +## Core Library (`@solana/kit-react`) + +### Dependencies + +```json +{ + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "@solana/kit": "^6.x", + "@solana/kit-plugin-wallet": "^1.x" + }, + "peerDependenciesMeta": { + "@solana/kit-plugin-wallet": { "optional": true } + } +} +``` + +`@solana/kit-plugin-wallet` is an **optional** peer dependency — required only if you import from `@solana/kit-react/wallet`. Apps that don't use wallet (read-only dashboards, bots, server flows) install the core peers and skip it. + +Kit plugins used to build the client (`@solana/kit-plugin-rpc`, `@solana/kit-plugin-signer`, `@solana/kit-plugin-litesvm`, `@solana/kit-plugin-instruction-plan`, …) are **not** peer dependencies of kit-react — the library doesn't import or wrap them. Consumers install whichever plugins their client needs as their own direct dependencies. This keeps kit-react decoupled from the plugin catalog: a new Kit plugin works out of the box the moment a consumer calls `.use()` on it. + +The SWR and TanStack Query adapters follow the same pattern: `swr` and `@tanstack/react-query` are declared as optional peer dependencies, pulled in only when their respective subpath is imported. + +> **Note:** `@solana/kit-plugin-wallet` is currently under development and not yet released. It provides the `walletWithoutSigner`, `walletPayer`, `walletIdentity`, and `walletSigner` plugins. + +### `KitClientProvider` + +`KitClientProvider` is the only provider in the library. It publishes a caller-owned Kit client to the subtree; every hook requires one as an ancestor. Plugin composition happens in plain Kit — the provider doesn't build or extend a client, it distributes the one you pass in. + +```typescript +type KitClientProviderProps = Readonly<{ + client: Client | Promise>; + chain?: ChainIdentifier; + children?: ReactNode; +}>; +``` + +- `client` — the Kit client to publish, or a promise resolving to one. The reference must be stable across renders — build it at module scope, or memoize it with `useMemo` when its config is reactive. +- `chain` _(optional)_ — a wallet-standard `ChainIdentifier` (e.g. `"solana:mainnet"`, `"solana:devnet"`, or any `${namespace}:${network}`) published to the subtree for wallet-aware descendants to read via `useChain()`. `useChain()` throws if nothing has set a chain; reaching for chain outside wallet-aware code is a programmer error, not a runtime branch. + +Most apps mount a single instance at the top of the tree. Sibling `KitClientProvider`s (e.g. a mainnet section and a devnet section) each get their own client and chain; the nearest ancestor wins. + +#### Common case + +```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' })); + +function App() { + return ( + + + + ); +} +``` + +The wallet plugin keeps `client.payer` / `client.identity` reactive internally (via the [`subscribeTo` convention](#subscribetocapability-convention)) — connect, disconnect, and account switches all fire through `useSyncExternalStore` without any client rebuild. + +#### Dynamic clients + +When a config changes at runtime (chain toggle, RPC URL change, relayer rotation), rebuild the client in `useMemo` and pass the new reference. The subtree remounts cleanly; hooks re-subscribe against the new client identity: + +```tsx +function App() { + const [chain, setChain] = useState('solana:mainnet'); + + const client = useMemo(() => { + const rpcUrl = + chain === 'solana:mainnet' + ? 'https://api.mainnet-beta.solana.com' + : 'https://api.devnet.solana.com'; + return createClient() + .use(walletSigner({ chain })) + .use(solanaRpc({ rpcUrl })) + .use(planAndSendTransactions()); + }, [chain]); + + return ( + + + + + ); +} +``` + +Wallet connect / disconnect / account switch is **not** a dynamic-client case. The wallet plugins update internal state on a stable client; don't rebuild the client on wallet events. + +#### Async plugins (Suspense) + +If any plugin's `.use()` is async, `createClient().use(...)` returns `Promise`. Pass it straight in; `KitClientProvider` suspends via the nearest `` boundary until the promise resolves. + +```tsx +import { Suspense, useMemo } from 'react'; + +function Root() { + const clientPromise = useMemo( + () => createClient().use(someAsyncPlugin({ /* … */ })).use(solanaMainnetRpc({ rpcUrl })), + [], + ); + return ( + + + + ); +} + +export function App() { + return ( + }> + + + ); +} +``` + +On React 19, the provider delegates to the built-in `React.use(promise)`. On React 18, it uses an internal thrown-promise shim that honors the same contract — Suspense catches the throw, the shim's `WeakMap` cache remembers per-promise state, and a resolved value is returned on retry. Either way, the promise identity must be stable across renders — pass a `useMemo`'d or module-scope value, never an inline `new Promise(...)`. + +#### Advanced examples + +All configurations are the same move — compose in Kit, distribute in React. Some representative shapes: + +```tsx +import { createClient } from '@solana/kit'; +import { payer, identity, signer } from '@solana/kit-plugin-signer'; +import { litesvm } from '@solana/kit-plugin-litesvm'; +import { solanaMainnetRpc } from '@solana/kit-plugin-rpc'; +import { walletSigner, walletIdentity, walletWithoutSigner } from '@solana/kit-plugin-wallet'; +import { KitClientProvider } from '@solana/kit-react'; + +// Wallet is identity, relayer pays +const walletAndRelayer = createClient() + .use(payer(relayerSigner)) + .use(walletIdentity({ chain: 'solana:mainnet' })) + .use(solanaMainnetRpc({ rpcUrl: 'https://...' })); + + + + + +// Wallet is UI only, payer and identity are explicit +const walletUiOnly = createClient() + .use(walletWithoutSigner({ chain: 'solana:mainnet' })) + .use(payer(relayerSigner)) + .use(identity(identitySigner)) + .use(solanaMainnetRpc({ rpcUrl: 'https://...' })); + +// Testing with LiteSVM — no wallet +const litesvmClient = createClient() + .use(signer(testKeypair)) + .use(litesvm()); + + + + + +// Custom chain identifier (escape hatch for L2s or non-Solana chains) +const customChainClient = createClient().use(customChainPlugin()); + + + + +``` + +### Hooks + +Hooks in this library fall into six return-shape categories. Knowing which category a hook belongs to tells you how to consume it without having to read its signature: + +| Category | Return shape | Backed by | Examples | +| --------------- | ------------------------------------------------------------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Live data | `{ data, error, status, isLoading, retry, slot }` (reactive, read-only) | `ReactiveStreamStore` | `useBalance`, `useAccount`, `useTransactionConfirmation`, `useLiveData`, `useSubscription` | +| One-shot read | `{ data, error, status, isLoading, refresh }` (reactive, read-only) | `ReactiveActionStore` (auto-dispatched) | `useRequest` | +| Tracked action | `{ send, status, isIdle, isRunning, isSuccess, isError, data, error, reset }` (async) | `ReactiveActionStore` | `useSendTransaction`, `useSendTransactions`, `usePlanTransaction`, `usePlanTransactions`, `useAction`; `useConnectWallet`, `useDisconnectWallet`, `useSignMessage`, `useSignIn` *(from `@solana/kit-react/wallet`)* | +| Bare callback | `(args) => result` (stable fn) | Plugin method | `useSelectAccount` *(from `@solana/kit-react/wallet`; synchronous — local account switch, no async lifecycle)* | +| Context value | Raw value (stable per provider) | React context | `useClient`, `useChain` | +| Reactive value | Raw value (reactive, read-only) | `subscribe` / `getState` on plugin | `usePayer`, `useIdentity`; `useWallets`, `useWalletStatus`, `useConnectedWallet`, `useWalletSigner`, `useWalletState` *(from `@solana/kit-react/wallet`)* | + +Live-data and one-shot-read hooks share the `loading / loaded / error / retrying / disabled` read vocabulary; `useRequest` maps the underlying action-store states (`idle / running / success / error`) onto it — see [One-shot requests](#one-shot-requests-userequest) for the mapping. The distinction at the render layer is the extra `slot` field on live-data and `refresh` vs. `retry` affordance. Every user-triggered async action — wallet connect, sign, send — returns the same `ActionResult` shape. Context values (`useClient`, `useChain`) are stable for the lifetime of the nearest provider. Reactive values are live snapshots of plugin-owned state (wallet identity, payer, identity) that update when the underlying store emits. Bare callback is a single-member category by design: `useSelectAccount` is the only wallet operation with no async lifecycle to track (it's a local state switch between already-authorized accounts), so an `ActionResult` wrapper would be inventing a state machine that never ticks. + +#### Client access + +```typescript +/** + * The React context that holds the Kit client. + * Exported for third-party providers that need to extend the client + * (see Third-party extensions). Most consumers use hooks instead. + */ +const ClientContext: React.Context; + +/** + * Access the raw Kit client from context. + * + * Defaults to the base `Client` shape. Callers who know a specific plugin + * is installed can widen the type via the generic — same escape-hatch + * pattern as `useChain()`. Pure cast, no runtime capability check; + * use {@link useClientCapability} when you also want the missing-plugin + * error to surface at mount. + * + * Power-user escape hatch for imperative use; most consumers reach for a + * named hook (`useBalance`, `useRequest`, …) instead. + */ +function useClient(): Client; + +/** + * The wallet-standard chain identifier published by `KitClientProvider`. + * + * Unions `SolanaChain` (autocompletes "solana:mainnet" / "solana:devnet" / + * "solana:testnet") with wallet-standard's `IdentifierString` as an escape + * hatch for custom or non-Solana chains. The `& {}` preserves literal + * autocomplete — TypeScript won't collapse the literals into the wider + * template type. + */ +type ChainIdentifier = SolanaChain | (IdentifierString & {}); + +/** + * Returns the current chain identifier from context. + * + * Throws if no ancestor has published a chain — reaching for chain + * outside wallet-aware code is a programmer error, not a runtime branch. + * Defaults to the narrow `SolanaChain` literal union — callers get + * autocomplete and no cast for the 99% case. Power users who opted into + * a custom chain widen the return type via the generic (same escape-hatch + * pattern as `useClient()`). + */ +function useChain(): T; +``` + +#### Wallet + +*All wallet hooks are exported from the `@solana/kit-react/wallet` subpath, not the core `@solana/kit-react` entry.* + +##### State hooks + +Wallet state is split into focused hooks so components subscribe only to the slice they need. A wallet discovery event won't re-render components that only care about the connected account, and vice versa. + +```typescript +/** + * All discovered wallets for the configured chain. + * Use this to build wallet-picker UIs. + */ +function useWallets(): readonly UiWallet[]; + +/** + * The current connection status. + * Use for conditional rendering (e.g. show connect button vs account info). + */ +function useWalletStatus(): WalletStatus; + +/** + * The active wallet connection, or `null` when disconnected. + * + * Returns only the account + wallet identity. The associated signer lives + * behind {@link useWalletSigner} because a connected wallet may still be + * read-only (no signer) and splitting keeps that contract visible in the + * types — a single `{account, signer, wallet}` shape invites callers to + * write `connected.signer.signTransactions(...)` without a null check, + * which crashes silently on read-only / watch-only wallets. + * + * The projection is memoized so that signer-only changes upstream (e.g. the + * wallet recreating its signer after a `change` event) do not re-render + * consumers of this hook. + */ +function useConnectedWallet(): { + readonly account: UiWalletAccount; + readonly wallet: UiWallet; +} | null; + +/** + * The connected wallet's signer, or `null` when disconnected or when the + * active account is read-only (watch-only wallet, or a wallet that + * doesn't support the configured chain). + * + * Separate from {@link useConnectedWallet} so the null case is visible in + * the type: `connected !== null` does not imply `signer !== null`. + */ +function useWalletSigner(): WalletSigner | null; + +/** + * Full wallet state. Convenience hook when you need everything — + * prefer the focused hooks above for performance-sensitive components. + */ +function useWalletState(): WalletState; +``` + +##### Action hooks + +The four async wallet actions (`useConnectWallet`, `useDisconnectWallet`, `useSignMessage`, `useSignIn`) return the same `ActionResult` shape as `useSendTransaction` — `send` plus reactive `status` / booleans / `data` / `error` / `reset`. Fire-and-forget is the common case (render from `isRunning` / `error`); callers that `await send(...)` to read the resolved value filter supersedes with `isAbortError`. `useSelectAccount` stays a bare callback because it's synchronous — local state switch, no async lifecycle. + +```typescript +/** + * Connect to a wallet. Tracked action — returns the same `ActionResult` + * shape as `useSendTransaction`. `send(wallet)` resolves to the accounts + * the wallet authorized. Calling `send` again while a prior connect is in + * flight aborts the first — good default for double-click on "Connect". + */ +function useConnectWallet(): ActionResult<[wallet: UiWallet], readonly UiWalletAccount[]>; + +/** + * Disconnect the active wallet. Tracked action. No-ops if nothing is + * connected. + */ +function useDisconnectWallet(): ActionResult<[], void>; + +/** + * Select a different account within the connected wallet. Stable function + * reference. Synchronous by design — selecting between already-authorized + * accounts is a local state switch, not a round-trip to the wallet — so + * there's no `ActionResult` wrapping (nothing async to track). + */ +function useSelectAccount(): (account: UiWalletAccount) => void; + +/** + * Sign a message with the connected wallet. Tracked action. + */ +function useSignMessage(): ActionResult<[message: Uint8Array], SignatureBytes>; + +/** + * Sign In With Solana. Tracked action. + * + * Takes the target wallet explicitly — pass `useConnectedWallet()?.wallet` to + * sign in with the currently-connected wallet, or any `UiWallet` from + * `useWallets()` for a fresh SIWS-as-connect flow. Matches the underlying + * plugin's `client.wallet.signIn(wallet, input)` shape. + */ +function useSignIn(): ActionResult<[wallet: UiWallet, input?: SolanaSignInInput], SolanaSignInOutput>; +``` + +#### Signer access + +Two hooks for reading the signers installed on the client by the payer / identity / wallet plugins. Both return `null` when unavailable (e.g. wallet-backed signer with no wallet connected) and throw a capability error if the relevant plugin isn't installed at all. When the installed plugin is reactive (e.g. a wallet plugin that reassigns `client.payer` as the wallet connects and disconnects), the hooks re-render on change without having to name the specific plugin — see [`subscribeTo` convention](#subscribetocapability-convention) below. + +```typescript +/** + * Returns the current fee payer (`client.payer`), or `null` if unavailable. + * Wallet-backed via `.use(walletSigner(...))` or `.use(walletPayer(...))` — reactive. + * Static via `.use(payer(...))` or `.use(signer(...))` — always returns the signer. + */ +function usePayer(): TransactionSigner | null; + +/** + * Returns the current identity (`client.identity`), or `null` if unavailable. + * Same reactivity semantics as `usePayer`. + */ +function useIdentity(): TransactionSigner | null; +``` + +For flows that specifically need the connected wallet's signer (rather than the logical payer/identity), use {@link useWalletSigner} — returns the wallet signer directly, or `null` when disconnected or when the active account is read-only. + +##### `subscribeTo` convention + +`usePayer` and `useIdentity` are capability-agnostic: they don't know (or care) which plugin installed `client.payer` or `client.identity`. To stay reactive without naming a specific plugin, they duck-type on a convention: a plugin whose capability can change over time installs a sibling `subscribeTo(listener): () => void` function alongside the capability itself. The hook subscribes to it via `useSyncExternalStore` and re-reads the capability on every notification. + +```typescript +// Expected shape, duck-typed by usePayer / useIdentity: +type ClientWithSubscribeToPayer = { + readonly subscribeToPayer: (listener: () => void) => () => void; +}; +type ClientWithSubscribeToIdentity = { + readonly subscribeToIdentity: (listener: () => void) => () => void; +}; +``` + +Plugins that participate today: + +- **`walletSigner`** installs both `subscribeToPayer` and `subscribeToIdentity`, forwarded from the wallet store's `subscribe`. +- **`walletPayer`** installs `subscribeToPayer` only. +- **`walletIdentity`** installs `subscribeToIdentity` only. +- **`walletWithoutSigner`** installs neither — it doesn't set `payer` or `identity`. +- **`payer`** / **`identity`** / **`signer`** *(from `@solana/kit-plugin-signer`)* install neither — the signer is fixed for the lifetime of the client, so there is no change to observe. + +Static plugins without the subscribe hook still work fine: the hook falls back to a no-op subscribe and just reads the capability once per render. Consumers can ignore this detail entirely — it's only relevant for plugin authors whose capability is reactive and who want `usePayer` / `useIdentity` to stay in sync. + +The shape is a Kit-level convention — `ClientWithSubscribeToPayer` and `ClientWithSubscribeToIdentity` are exported from `@solana/kit`, so any reactive framework binding (Vue, Svelte, Solid) or direct client consumer can observe it without depending on kit-react. kit-react just provides the `useSyncExternalStore` bridge. See [Future directions](#future-directions) for the option of promoting the *producer-side* machinery (listener registry, notify helper) into a shared kit-core helper once a second reactive plugin appears. + +##### Reading capabilities whose getters may throw + +Wallet-backed payer / identity plugins expose `client.payer` and `client.identity` as getters that **throw** when the wallet is disconnected (there is no signer to return; `undefined` would be a type lie). `useSyncExternalStore` propagates an exception from `getSnapshot` by unmounting the subtree, so the hook can't read the getter directly. + +Both `usePayer` and `useIdentity` read through a small `readOptional` helper that coerces thrown getters to `null`: + +```typescript +function readOptional(read: () => T): NonNullable | null { + try { + return read() ?? null; + } catch { + return null; + } +} + +function usePayer(): TransactionSigner | null { + const client = useClient & SubscribeToCapability<'payer'>>(); + const getSnapshot = () => readOptional(() => client.payer); + const payer = useSyncExternalStore(client.subscribeToPayer ?? NOOP_SUBSCRIBE, getSnapshot, getSnapshot); + if (!('payer' in client)) throwMissingCapability('usePayer', '`client.payer`', '…'); + return payer; +} +``` + +This is load-bearing for wallet-backed flows: without the swallow, installing `walletSigner()` before a wallet connects would crash the subtree on first render. The swallow is specifically for the "present but unavailable" state — the outer `'payer' in client` check still throws loudly when no payer plugin is installed at all, so missing-plugin bugs are not hidden. + +This is also why `usePayer` / `useIdentity` can't route through `useClientCapability` like the other capability hooks — that helper returns a narrowed client whose `payer` / `identity` would be read via the throwing getter; the `readOptional` wrapper needs to sit between the read and the caller, so we do the `'payer' in client` assertion manually. + +#### Getting a kit signer from a wallet account + +For cases where you need a signer for an account other than the connected one (e.g. a different account within the same wallet, or multi-wallet flows), use `createSignerFromWalletAccount` from `@solana/wallet-account-signer` with any `UiWalletAccount`: + +```typescript +const wallets = useWallets(); +const chain = useChain(); +const account = wallets[0]?.accounts[0]; +const signer = useMemo( + () => (account ? createSignerFromWalletAccount(account, chain) : null), + [account, chain], +); +``` + +`createSignerFromWalletAccount` returns a signer that implements `TransactionModifyingSigner`, `TransactionSendingSigner` (if the wallet supports `solana:signAndSendTransaction`), and `MessageSigner` (if the wallet supports `solana:signMessage`). No kit-react hook is needed here — this is a plain kit function. + +> This is the one place the spec asks you to reach for a React primitive (`useMemo`) rather than consume a named hook. It's intentional: a named hook here would be a trivial wrapper with no domain logic to hide, matching the [one-shot read policy](#one-shot-reads). `useMemo` is the right tool when you need a per-account signer derived from inputs that are already reactive (`useWallets` + `useChain`). + +Implementation: + +```tsx +// Shared helper: every wallet hook needs `client.wallet`, so route through +// `useClientCapability` to get both the typed narrowing and a loud error if +// the wallet plugin isn't installed. +function useWalletClient(hookName: string) { + return useClientCapability({ + capability: 'wallet', + hookName, + providerHint: 'Install a wallet plugin (e.g. `walletSigner()`) on the client.', + }); +} + +function useWallets(): readonly UiWallet[] { + const client = useWalletClient('useWallets'); + return useSyncExternalStore( + client.wallet.subscribe, + () => client.wallet.getState().wallets, + ); +} + +function useWalletStatus(): WalletStatus { + const client = useWalletClient('useWalletStatus'); + return useSyncExternalStore( + client.wallet.subscribe, + () => client.wallet.getState().status, + ); +} + +function useConnectedWallet() { + const client = useWalletClient('useConnectedWallet'); + // Project {account, wallet} out of the snapshot, memoizing across calls so + // signer-only changes upstream don't re-render consumers of this hook. + const lastRef = useRef<{ account: UiWalletAccount; wallet: UiWallet } | null>(null); + const getSnapshot = () => { + const connected = client.wallet.getState().connected; + if (connected === null) return (lastRef.current = null); + const prev = lastRef.current; + if (prev && prev.account === connected.account && prev.wallet === connected.wallet) { + return prev; + } + return (lastRef.current = { account: connected.account, wallet: connected.wallet }); + }; + return useSyncExternalStore(client.wallet.subscribe, getSnapshot, getSnapshot); +} + +function useWalletSigner() { + const client = useWalletClient('useWalletSigner'); + const getSnapshot = () => client.wallet.getState().connected?.signer ?? null; + return useSyncExternalStore(client.wallet.subscribe, getSnapshot, getSnapshot); +} + +function useWalletState(): WalletState { + const client = useWalletClient('useWalletState'); + return useSyncExternalStore(client.wallet.subscribe, client.wallet.getState); +} + +function useConnectWallet() { + const client = useWalletClient('useConnectWallet'); + return useAction( + (signal, wallet: UiWallet) => client.wallet.connect(wallet, { abortSignal: signal }), + [client], + ); +} + +function useDisconnectWallet() { + const client = useWalletClient('useDisconnectWallet'); + return useAction( + (signal) => client.wallet.disconnect({ abortSignal: signal }), + [client], + ); +} + +function useSignMessage() { + const client = useWalletClient('useSignMessage'); + return useAction( + (signal, message: Uint8Array) => client.wallet.signMessage(message, { abortSignal: signal }), + [client], + ); +} + +function useSignIn() { + const client = useWalletClient('useSignIn'); + return useAction( + (signal, wallet: UiWallet, input?: SolanaSignInInput) => + client.wallet.signIn(wallet, input, { abortSignal: signal }), + [client], + ); +} + +function useSelectAccount() { + const client = useWalletClient('useSelectAccount'); + // Synchronous — no ActionResult wrapping. + return useCallback( + (account: UiWalletAccount) => client.wallet.selectAccount(account), + [client], + ); +} +``` + +#### Live data (subscription-backed) + +Named hooks for common RPC + subscription pairings. These use Kit's `createReactiveStoreWithInitialValueAndSlotTracking` internally and expose the result via `useSyncExternalStore`. + +```typescript +type LiveQueryResult = { + /** + * The current value. `undefined` while loading or when disabled. On `error` + * and `retrying` holds the last known value (if one ever arrived) so UIs + * can show stale data with a loading / error overlay rather than flashing + * to blank. + */ + data: T | undefined; + /** Error from the fetch or subscription, or undefined. */ + error: unknown; + /** + * The lifecycle status, drawn from Kit's `ReactiveState` plus a kit-react + * `'disabled'` variant for null-gated queries: + * + * - `loading`: active, no data or error has arrived yet. + * - `loaded`: data has arrived and the stream is healthy. + * - `error`: the fetch or subscription failed; `data` holds the last known value. + * - `retrying`: `retry()` was called after an error; `data` holds the stale + * value while a fresh connection is being established. + * - `disabled`: the query is intentionally off (null arg). + * + * Prefer branching on `status` when your UI needs to distinguish + * `retrying` (show stale data with an overlay) from `loading` (show a + * spinner). Use `isLoading` for the simple spinner case. + */ + status: 'loading' | 'loaded' | 'error' | 'retrying' | 'disabled'; + /** + * Convenience shorthand for `status === 'loading'`. **A disabled query + * (null arg) reports `false`** — matching react-query / SWR semantics when + * the key is `null`, so callers can render an empty state instead of a + * forever-loading spinner. `retrying` also reports `false` because `data` + * still holds the last known value — check `status === 'retrying'` + * explicitly if you want to reflect it in your UI. + */ + isLoading: boolean; + /** + * Re-open the stream after an error. No-op unless `status === 'error'`. + * Stable reference — safe to pass directly to `onClick` handlers or to + * include in effect deps. + * + * Retry is end-to-end: on error, the store tears down the subscription + * (and, for named hooks, re-runs the initial RPC fetch), transitions to + * `status: 'retrying'` preserving `data`, and returns to `loaded` once a + * fresh value arrives. If the retry itself fails, the store transitions + * back to `error` with the new reason. + */ + retry: () => void; + /** + * The slot `data` was observed at. `undefined` while loading, disabled, on + * server render, or when only an error has arrived. Drawn from the same + * snapshot as `data`, so the two always correspond: a later subscription + * notification at a higher slot updates both in the same commit. Useful + * for "as of slot X" freshness indicators, coordinating a refetch with a + * just-sent transaction's slot, and stale-data detection. + * + * For `useSubscription`, populated when the subscription emits + * `SolanaRpcResponse`-shaped notifications (e.g. `accountNotifications`, + * `programNotifications`) and `undefined` for subscriptions that emit + * raw values (e.g. `slotNotifications`, `logsNotifications`). The + * envelope is unwrapped on the way out, so `data` is always the inner + * value regardless of shape. + */ + slot: Slot | undefined; +}; + +/** + * Live SOL balance for an address. + * Combines getBalance + accountNotifications with slot-based dedup. + * Pass `null` to disable (e.g. when wallet is not connected). A disabled + * query reports `{ status: 'disabled', data: undefined, isLoading: false }`. + */ +function useBalance(address: Address | null): LiveQueryResult; + +/** + * Live account for an address. + * Combines getAccountInfo + accountNotifications with slot-based dedup. + * When a decoder is provided, the account data is decoded and returned as + * a typed `MaybeAccount`. Without a decoder, returns the raw + * `MaybeEncodedAccount`. Both are Kit's "fetched account that may or may + * not exist on-chain" discriminated union — `exists: true` narrows to an + * `Account` / `EncodedAccount` with data, `exists: false` keeps the + * address for the missing-account case. + * Pass `null` to disable — a disabled query reports `status: 'disabled'`. + */ +function useAccount(address: Address | null): LiveQueryResult; +function useAccount( + address: Address | null, + decoder: Decoder, +): LiveQueryResult>; + +/** + * Live transaction confirmation status. + * Combines getSignatureStatuses + signatureNotifications with slot-based dedup. + * Pass `null` to disable (e.g. before a transaction is sent) — a disabled + * query reports `status: 'disabled'`. + */ +function useTransactionConfirmation( + signature: Signature | null, + options?: { commitment?: Commitment }, +): LiveQueryResult<{ + err: TransactionError | null; + confirmationStatus: Commitment | null; +}>; +``` + +**Lifecycle states.** Five distinct cases map to five distinct `status` values, so callers can tell them apart without extra props: + +- **Disabled (null arg)** — the query is intentionally off. `status: 'disabled'`, `isLoading: false`. Render an empty state. +- **Active, no data yet** — the query is running, waiting for the first value. `status: 'loading'`, `isLoading: true`. Render a spinner. +- **Active, healthy** — data has arrived. `status: 'loaded'`, `isLoading: false`. +- **Errored** — the fetch or subscription failed. `status: 'error'`, `isLoading: false`, `data` holds the last known value (if any). Call `retry()` to re-open. +- **Retrying** — `retry()` was called after an error. `status: 'retrying'`, `isLoading: false`, `data` still holds the stale value. Branch on `status === 'retrying'` if you want a distinct UI from a cold `loading` state. +- **Server render** — the query is inert on the server (no HTTP / WebSocket); from the consumer's perspective the value is still "pending", so the hook reports `status: 'loading'` and hydration matches the first client render before the real store kicks in. + +Internally this is two static "empty" stores, not one: `disabledLiveStore` is tagged so `useLiveQueryResult` maps it to `status: 'disabled'`, while `nullLiveStore` (used on server builds) isn't, so the same snapshot shows `status: 'loading'`. Collapsing them would force the caller to pick a single meaning for "empty", which only one of the cases wants. + +Implementation sketch: + +```tsx +// Shared helper: every live-data hook needs `client.rpc` + `client.rpcSubscriptions`. +// Route through `useClientCapability` so mounting one of these hooks without an +// RPC plugin installed on the client fails loud at mount. +function useRequestConnectionClient(hookName: string) { + return useClientCapability({ + capability: ['rpc', 'rpcSubscriptions'], + hookName, + providerHint: 'Install `solanaRpc()` or `solanaRpcConnection()` on the client.', + }); +} + +// Each named hook is paired with a framework-agnostic live-data builder. +// The builder produces a LiveDataSpec — the RPC request, subscription +// request, and two mappers — without any React or abort-signal plumbing. +// useBalance / useLiveSwr / useLiveQuery all consume the same spec, so +// the choice of cache layer is orthogonal to the choice of data source. + +type LiveDataSpec = Omit< + CreateReactiveStoreConfig, + 'abortSignal' +>; + +function createBalanceLiveData( + client: ClientWithRpc & ClientWithRpcSubscriptions, + address: Address, +): LiveDataSpec { + return { + rpcRequest: client.rpc.getBalance(address), + rpcSubscriptionRequest: client.rpcSubscriptions.accountNotifications(address), + // The factory unwraps the `SolanaRpcResponse` envelope before + // handing it to the mapper, so `value` here is already + // `Lamports`, not `{ value: Lamports, ... }`. + rpcValueMapper: (lamports) => lamports, + rpcSubscriptionValueMapper: ({ lamports }) => lamports, + }; +} + +function createAccountLiveData( + client: ClientWithRpc & ClientWithRpcSubscriptions, + address: Address, + decoder?: Decoder, +): LiveDataSpec> { + // parseBase64RpcAccount returns a MaybeEncodedAccount when its input + // may be null — the missing-account case becomes + // `{ address, exists: false }` rather than a raw `null`. + const mapValue = (value: unknown) => { + const encoded = parseBase64RpcAccount(address, value); + return decoder ? decodeAccount(encoded, decoder) : encoded; + }; + return { + rpcRequest: client.rpc.getAccountInfo(address, { encoding: 'base64' }), + rpcSubscriptionRequest: client.rpcSubscriptions.accountNotifications(address, { encoding: 'base64' }), + rpcValueMapper: mapValue, + rpcSubscriptionValueMapper: mapValue, + }; +} + +function createTransactionConfirmationLiveData( + client: ClientWithRpc & ClientWithRpcSubscriptions, + signature: Signature, + commitment: Commitment, +): LiveDataSpec { + return { + rpcRequest: client.rpc.getSignatureStatuses([signature]), + rpcSubscriptionRequest: client.rpcSubscriptions.signatureNotifications(signature, { commitment }), + rpcValueMapper: (statuses) => { + const status = statuses[0]; + return status + ? { err: status.err, confirmationStatus: status.confirmationStatus } + : { err: null, confirmationStatus: null }; + }, + rpcSubscriptionValueMapper: (notification) => ({ + err: notification.err, + confirmationStatus: commitment, + }), + }; +} + +// The named hooks are thin wrappers: they resolve the client, gate on a +// null argument, and delegate to the builder. + +function useBalance(address: Address | null): LiveQueryResult { + const client = useRequestConnectionClient('useBalance'); + return useLiveData( + () => (address ? createBalanceLiveData(client, address) : null), + [client, address], + ); +} + +function useAccount( + address: Address | null, + decoder?: Decoder, +): LiveQueryResult> { + const client = useRequestConnectionClient('useAccount'); + return useLiveData( + () => (address ? createAccountLiveData(client, address, decoder) : null), + [client, address, decoder], + ); +} + +function useTransactionConfirmation( + signature: Signature | null, + options?: { commitment?: Commitment }, +): LiveQueryResult { + const client = useRequestConnectionClient('useTransactionConfirmation'); + const commitment = options?.commitment ?? 'confirmed'; + return useLiveData( + () => (signature ? createTransactionConfirmationLiveData(client, signature, commitment) : null), + [client, signature, commitment], + ); +} +``` + +Where the two static "empty" stores (`disabledLiveStore` for user-initiated disable, `nullLiveStore` for server render) and `useLiveStore` are internal helpers: + +```typescript +const LOADING_STATE: ReactiveState = Object.freeze({ + data: undefined, + error: undefined, + status: 'loading', +}); + +const NOOP_UNSUBSCRIBE = () => {}; +const NOOP_RETRY = () => {}; + +/** + * Static store that never emits. `useLiveQueryResult` surfaces its `loading` + * status so SSR matches the first client render, and the real store kicks in + * once the client mounts. + */ +function nullLiveStore(): ReactiveStreamStore { + return { + getError: () => undefined, + getState: () => undefined, + getUnifiedState: () => LOADING_STATE, + retry: NOOP_RETRY, + subscribe: () => NOOP_UNSUBSCRIBE, + }; +} + +/** + * Static store tagged as "disabled". `useLiveQueryResult` detects the tag and + * maps it to `status: 'disabled'` — matches react-query / SWR semantics when + * the key is `null`, so disabled queries don't render forever-loading UI. + */ +const DISABLED = Symbol('DisabledLiveStore'); + +function disabledLiveStore(): ReactiveStreamStore & { readonly [DISABLED]: true } { + return { + [DISABLED]: true, + getError: () => undefined, + getState: () => undefined, + getUnifiedState: () => LOADING_STATE, // ignored — the tag takes precedence at the bridge + retry: NOOP_RETRY, + subscribe: () => NOOP_UNSUBSCRIBE, + }; +} + +// `useLiveStore` is generic over both stream and action store shapes — both +// implement the ReactiveStore interface (subscribe + getUnifiedState) so the +// lifecycle management (abort on dep change / unmount) is identical. +function useLiveStore void) => () => void }>( + factory: (signal: AbortSignal) => TStore, + deps: DependencyList, +): TStore { + const abortRef = useRef(null); + + // The SSR branch lives inside the memo (not as an early return) so the + // hook sequence stays identical between server and client. + const store = useMemo(() => { + if (!__BROWSER__ && !__REACTNATIVE__) return nullLiveStore() as unknown as TStore; + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + return factory(controller.signal); + }, deps); + + useEffect(() => () => abortRef.current?.abort(), []); + + return store; +} + +/** + * Bridge for named hooks whose stores come from + * `createReactiveStoreWithInitialValueAndSlotTracking` and therefore hold + * `SolanaRpcResponse` envelopes (value + slot context). + */ +function useLiveQueryResult( + store: ReactiveStreamStore>, +): LiveQueryResult { + // Kit guarantees `getUnifiedState()` returns a stable reference per update, + // so one subscription is enough (vs. the old shape which needed two). + const state = useSyncExternalStore(store.subscribe, store.getUnifiedState); + const disabled = (store as { [DISABLED]?: true })[DISABLED] === true; + + return useMemo(() => { + if (disabled) { + return { + data: undefined, + error: undefined, + isLoading: false, + retry: NOOP_RETRY, + slot: undefined, + status: 'disabled', + }; + } + return { + data: state.data?.value, + error: state.error, + isLoading: state.status === 'loading', + retry: store.retry, + // Pulled from the same snapshot as `data` so the two always agree. + slot: state.data?.context.slot, + status: state.status, + }; + }, [state, disabled, store.retry]); +} + +/** + * Bridge for `useSubscription`. The store comes straight from `.reactiveStore()` + * on a pending subscription request. Each notification is duck-typed for + * Kit's `SolanaRpcResponse` envelope: when present, `data` is unwrapped to + * the inner value and `slot` is lifted from `context.slot`; when absent, + * the notification passes through as-is with `slot: undefined`. + */ +function useSubscriptionResult( + store: ReactiveStreamStore, +): LiveQueryResult> { + const state = useSyncExternalStore(store.subscribe, store.getUnifiedState); + const disabled = (store as { [DISABLED]?: true })[DISABLED] === true; + + return useMemo(() => { + if (disabled) { + return { + data: undefined, + error: undefined, + isLoading: false, + retry: NOOP_RETRY, + slot: undefined, + status: 'disabled', + }; + } + const { data, slot } = splitRpcResponse(state.data); + return { + data, + error: state.error, + isLoading: state.status === 'loading', + retry: store.retry, + slot, + status: state.status, + }; + }, [state, disabled, store.retry]); +} + +// Duck-type the `SolanaRpcResponse` envelope — `{ context: { slot }, value }` — +// and split it into `{ data, slot }`. Anything else passes through. +function splitRpcResponse( + notification: T | undefined, +): { data: UnwrapRpcResponse | undefined; slot: Slot | undefined } { + if ( + notification != null && + typeof notification === 'object' && + 'context' in notification && + 'value' in notification + ) { + const envelope = notification as SolanaRpcResponse; + return { data: envelope.value as UnwrapRpcResponse, slot: envelope.context.slot }; + } + return { data: notification as UnwrapRpcResponse | undefined, slot: undefined }; +} +``` + +`retry()` is end-to-end because Kit's reactive stores own the full stream lifecycle: `createReactiveStoreWithInitialValueAndSlotTracking` and `PendingRpcSubscriptionsRequest.reactiveStore()` both return stores built on `createReactiveStoreFromDataPublisherFactory`, which takes a `() => Promise` and re-invokes it on each retry. The React bridge is pure passthrough — `LiveQueryResult.retry` is `store.retry` with stable identity, safe to pass straight to an `onClick` handler. + +#### Generic live data (`useLiveData`) + +For custom RPC + subscription combinations the named hooks don't cover: + +```typescript +/** + * Generic live-data hook for any RPC + subscription pair. + * Handles store creation, slot dedup, abort, and cleanup. + * + * The builder function runs when `deps` change and returns a + * `LiveDataSpec` — the RPC request, subscription request, and the two + * mappers that unify their value shapes. Return `null` to disable the + * query (matches the null-gate convention used by `useBalance`, + * `useAccount`, etc.). Abort signal plumbing is handled internally. + * + * ESLint's `react-hooks/exhaustive-deps` rule can trace which values the + * builder captures and warn when any are missing from `deps` — add + * `'useLiveData'` to your project's `exhaustive-deps` `additionalHooks` + * setting to opt in. + */ +function useLiveData( + buildSpec: () => LiveDataSpec | null, + deps: DependencyList, +): LiveQueryResult; +``` + +Usage: + +```tsx +// Using a stock builder — equivalent to `useBalance(address)`: +const { data: balance } = useLiveData( + () => (address ? createBalanceLiveData(client, address) : null), + [client, address], +); + +// Inline for a custom program account: +const { data: gameState } = useLiveData( + () => ({ + rpcRequest: client.rpc.getAccountInfo(gameAddress), + rpcSubscriptionRequest: client.rpcSubscriptions.accountNotifications(gameAddress), + rpcValueMapper: (v) => parseGameState(v.value), + rpcSubscriptionValueMapper: (v) => parseGameState(v), + }), + [client, gameAddress], +); +``` + +Third-party plugins are expected to ship their own `createLiveData(client, ...args)` builders alongside the plugin. The builder has no React dependency, so the same function works with `useLiveData`, `useLiveSwr`, and `useLiveQuery` — the cache layer is the caller's choice, not the plugin author's. + +#### Subscriptions (no initial fetch) + +For subscription-only data where there is no RPC fetch equivalent: + +```typescript +/** + * Unwraps `SolanaRpcResponse` → `U` at the type level, so subscriptions + * that emit slot-stamped notifications surface `U` as `data` (the slot + * moves to the top-level `slot` field). Non-envelope subscriptions pass + * through unchanged. + */ +type UnwrapRpcResponse = T extends SolanaRpcResponse ? U : T; + +/** + * Anything that produces a `ReactiveStreamStore` via + * `.reactiveStore({ abortSignal })`. `PendingRpcSubscriptionsRequest` + * satisfies this by design; plugin-authored streaming-request objects that + * follow the same convention plug in without modification. + */ +type ReactiveStreamSource = { + reactiveStore(options: { abortSignal: AbortSignal }): ReactiveStreamStore; +}; + +/** + * Subscribe to a stream. Returns the latest notification in the same + * `LiveQueryResult` shape as the named hooks. + * + * Accepts any object with `.reactiveStore({ abortSignal })` — typically + * `PendingRpcSubscriptionsRequest`, but plugin-authored streaming objects + * that follow the same convention work too. + * + * When the stream emits `SolanaRpcResponse`-shaped notifications + * (`accountNotifications`, `programNotifications`, etc.), the envelope is + * unwrapped: `data` is the inner value `U` and `slot` is populated from + * `context.slot`. For streams that emit raw values (`slotNotifications`, + * `logsNotifications`, etc.), `data` is the notification as-is and `slot` + * is `undefined`. + * + * Return `null` from the factory to disable — matches the null-gate + * convention used by `useBalance` / `useAccount` / + * `useTransactionConfirmation`. A disabled subscription fires no RPC + * traffic and reports `status: 'disabled'`. + */ +function useSubscription( + factory: (signal: AbortSignal) => ReactiveStreamSource | null, + deps: DependencyList, +): LiveQueryResult>; +``` + +Usage: + +```tsx +const { data: logs, status } = useSubscription( + (signal) => client.rpcSubscriptions.logsNotifications(programId), + [client, programId], +); + +const { data: slot, error, retry } = useSubscription( + (signal) => client.rpcSubscriptions.slotNotifications(), + [client], +); +if (error) return ; + +// Gated on a feature flag +const { data: enabledLogs } = useSubscription( + (signal) => enabled ? client.rpcSubscriptions.logsNotifications(programId) : null, + [client, programId, enabled], +); +``` + +Implementation: + +```tsx +function useSubscription( + factory: (signal: AbortSignal) => ReactiveStreamSource | null, + deps: DependencyList, +): LiveQueryResult> { + const store = useLiveStore>( + (signal) => { + const pending = factory(signal); + if (pending == null) return disabledLiveStore(); + // `.reactiveStore()` returns synchronously — transport setup + // happens inside the store, which starts in `status: 'loading'` + // and transitions when the WebSocket resolves. Setup failures + // surface as `status: 'error'`, recoverable via `retry()`. + return pending.reactiveStore({ abortSignal: signal }); + }, + deps, + ); + return useSubscriptionResult(store); +} +``` + +`useSubscription` shares the same `useLiveStore` + bridge pipeline as the named hooks. The bridge (`useSubscriptionResult`, defined [earlier](#live-data-subscription-backed)) inspects each notification at runtime: subscriptions that emit `SolanaRpcResponse` (`accountNotifications`, `programNotifications`, `signatureNotifications`, …) are unwrapped so `data` is the inner value and `slot` comes from `context.slot`; subscriptions that emit raw values (`slotNotifications`, `logsNotifications`, `rootNotifications`, …) pass through as-is with `slot` undefined. The `UnwrapRpcResponse` conditional type keeps the return type aligned with the runtime behavior, so callers never have to reach for `data.context.slot` or `data.value` themselves. + +> **Why sync `.reactiveStore()` and not async `.reactive()`?** The async form returns `Promise>`, which forces every consumer to invent a second state machine on top of the store's own `loading | loaded | error | retrying` — "waiting for the promise" vs. "waiting for the first notification" — and leaves no place to put `retry()` during transport-setup failures (no store exists yet). The sync form delegates transport setup to `createReactiveStoreFromDataPublisherFactory` inside Kit, so the store is usable immediately and setup failures surface through the store's existing `error` state with `retry()` working out of the box. + +#### One-shot requests (`useRequest`) + +For RPC calls that don't have a subscription counterpart — `getEpochInfo`, `getMinimumBalanceForRentExemption`, `getLatestBlockhash`, `getRecentPerformanceSamples`, etc. — or for cases where you want a one-shot read of a value that `useAccount` / `useBalance` would otherwise subscribe to. + +Backed by `ReactiveActionStore` via `PendingRpcRequest.reactiveStore()` ([Prerequisites](#prerequisites)): each mount creates the store and fires the request eagerly (the `.reactiveStore()` method auto-dispatches on creation), deps change rebuilds the store with a fresh dispatch (auto-aborting any in-flight predecessor), and consumers get a `refresh()` function to re-fire manually. + +```typescript +/** The state returned by {@link useRequest}. */ +type RequestResult = { + /** + * The current value, or `undefined` while loading or when disabled. On + * `error`, holds the last successful value (if any) so UIs can show + * stale data with an error banner rather than flashing to blank. + */ + data: T | undefined; + /** Error from the RPC call, or undefined. */ + error: unknown; + /** + * Lifecycle status: + * - `loading`: first call in flight, no data yet. + * - `loaded`: call succeeded. + * - `error`: call failed. `retry()` re-fires. + * - `retrying`: re-fire after an error; `data` still holds stale value. + * - `disabled`: factory returned `null`. + */ + status: 'loading' | 'loaded' | 'error' | 'retrying' | 'disabled'; + /** Convenience shorthand for `status === 'loading'`. */ + isLoading: boolean; + /** + * Re-fire the RPC call with the current deps. Stable reference. Safe to + * pass to an onClick handler or put in effect deps. Call this when the + * user explicitly requests a refresh, or to retry after an error. + * + * Note: unlike live-data hooks (which expose a separate `retry()` for + * the error path), `useRequest` collapses both affordances under + * `refresh` — there's only one re-dispatch mechanism on an action + * store, and distinguishing "user-initiated refresh" from "error + * recovery" at the API level would be a naming split without a + * behavioural one. + */ + refresh: () => void; +}; + +/** + * Anything that produces a `ReactiveActionStore<[], T>` via `.reactiveStore()`. + * `PendingRpcRequest` satisfies this by design; plugin authors whose + * pending-request objects expose the same method plug in without + * modification. This duck-type is the orthogonality boundary — `useRequest` + * doesn't know or care where the pending came from. + */ +type ReactiveActionSource = { + reactiveStore(): ReactiveActionStore<[], T>; +}; + +/** + * Fire a one-shot request on mount and whenever `deps` change. Returns + * reactive state tracking the call's lifecycle. + * + * Accepts any object with `.reactiveStore()` — typically `PendingRpcRequest`, + * but plugin-authored pending objects that follow the same convention work + * too (e.g. a DAS client's `getAsset(address)`). + * + * Return `null` from `factory` to disable — matches the null-gate convention + * used by `useBalance` / `useAccount`. + */ +function useRequest( + factory: (signal: AbortSignal) => ReactiveActionSource | null, + deps: DependencyList, +): RequestResult; +``` + +Usage: + +```tsx +// Fetch epoch info on mount; refresh on user click. +const { data: epoch, error, refresh } = useRequest( + () => client.rpc.getEpochInfo(), + [client], +); +if (error) return ; + +// Deps-driven: refetch when the address changes. +const { data: supply } = useRequest( + () => client.rpc.getTokenSupply(mintAddress), + [client, mintAddress], +); + +// Conditional (disabled when inputs aren't ready). +const { data: account } = useRequest( + () => address ? client.rpc.getAccountInfo(address) : null, + [client, address], +); +``` + +Implementation: + +```tsx +function useRequest( + factory: (signal: AbortSignal) => ReactiveActionSource | null, + deps: DependencyList, +): RequestResult { + const store = useLiveStore>( + (signal) => { + const pending = factory(signal); + if (pending == null) return disabledActionStore(); + // `.reactiveStore()` auto-dispatches on creation — see + // [Prerequisites](#prerequisites). The hook doesn't need a + // separate useEffect to fire the initial request. + return pending.reactiveStore(); + }, + deps, + ); + + return useRequestResult(store); +} +``` + +The bridge maps the action-store's `idle | running | success | error` to the read shape above: + +- action `running` with no prior data → read `loading` (the auto-dispatch from `.reactiveStore()` fires at construction, so there's no pre-dispatch idle state to expose). +- action `idle` from `disabledActionStore` → read `disabled`. +- action `running` with prior data (re-dispatch via `refresh()`) → read `retrying` (same "stale-while-revalidate" UX as stream stores). +- action `success` → read `loaded`. +- action `error` → read `error`. +- `refresh()` wraps `store.dispatch()` — re-fires the RPC manually. + +> **Why does `.reactiveStore()` auto-dispatch when `ReactiveActionStore` is neutral on initiation?** The primitive stays neutral — `useSendTransaction` and custom `useAction` flows build their own action stores via `createReactiveActionStore(fn)` and dispatch on user input. Only the `.reactiveStore()` convenience method on `PendingRpcRequest` / `PendingRpcSubscriptionsRequest` commits to eager dispatch, because calling `.reactiveStore()` semantically means "I want this live now" (same reasoning as subscriptions). Consumers who want build-now-dispatch-later drop one layer to `createReactiveActionStore`. + +#### Sending transactions + +Wraps `client.sendTransaction()` and `client.sendTransactions()` (from the instruction-plan plugin) with React async state tracking. These are the primary way to send transactions in kit-react — they handle the full plan → sign → send → confirm lifecycle. + +```typescript +type ActionResult = { + /** The send function. Stable reference. */ + send: (...args: TArgs) => Promise; + /** + * The current lifecycle status as a discriminated string. The + * `isIdle` / `isRunning` / `isSuccess` / `isError` booleans below are + * derived from this — pick whichever reads better at the call site. + */ + status: 'idle' | 'running' | 'success' | 'error'; + /** `true` when `status === 'idle'`. */ + isIdle: boolean; + /** `true` when `status === 'running'` — a send is in flight. */ + isRunning: boolean; + /** `true` when `status === 'success'`. */ + isSuccess: boolean; + /** `true` when `status === 'error'`. */ + isError: boolean; + /** The result on success, or undefined. */ + data: TResult | undefined; + /** The error on failure, or undefined. */ + error: unknown; + /** Reset state back to idle. Stable reference. */ + reset: () => void; +}; + +/** + * Send a single transaction. Accepts instructions, an instruction plan, + * a transaction message, or a pre-built SingleTransactionPlan. + * Asserts that the plan contains exactly one transaction. + */ +function useSendTransaction(): ActionResult< + Parameters, + SuccessfulSingleTransactionPlanResult +>; + +/** + * Send one or more transactions. Accepts instructions, an instruction plan, + * a transaction message, or a pre-built TransactionPlan. + */ +function useSendTransactions(): ActionResult< + Parameters, + TransactionPlanResult +>; + +/** + * Plan a single transaction without sending it. Same input shape as + * useSendTransaction; returns the planned transaction message. Useful for + * preview-then-send UX (confirmation modal showing fee / writable accounts). + * The planned output feeds straight back into useSendTransaction. + */ +function usePlanTransaction(): ActionResult< + Parameters, + SingleTransactionPlan['message'] +>; + +/** + * Plan one or more transactions without sending them. Multi-transaction + * variant of usePlanTransaction. + */ +function usePlanTransactions(): ActionResult< + Parameters, + TransactionPlan +>; +``` + +`ActionResult` is generic over both the argument tuple and the result so callers get full autocomplete on `send(...)` — the argument positions match whichever Kit method the hook wraps. + +Each hook asserts only the single capability it calls (`planTransaction`, `planTransactions`, `sendTransaction`, `sendTransactions`) so the React layer stays aligned with Kit's granular plugin model — a plugin that installs just `planTransaction` is enough to use `usePlanTransaction`. + +Usage: + +```tsx +const { send, status, data, error } = useSendTransaction(); + +// Send instructions directly +await send(getTransferInstruction({ source, destination, amount })); + +// Send using the fluent program client API +await send(client.system.instructions.transfer({ source, destination, amount })); + +// Send an instruction plan +await send(sequentialInstructionPlan([ixA, ixB])); +``` + +Implementation: + +```tsx +function useSendTransaction() { + const client = useClientCapability({ + capability: 'sendTransaction', + hookName: 'useSendTransaction', + providerHint: 'Install `solanaRpc()` or `litesvm()` on the client (or another plugin that installs transaction execution).', + }); + return useAction( + (signal, input: Parameters[0], config?: Config) => + client.sendTransaction(input, { ...config, abortSignal: signal }), + [client], + ); +} + +// useAction bridges Kit's ReactiveActionStore into React. +function useAction( + fn: (signal: AbortSignal, ...args: TArgs) => Promise, + deps: DependencyList, +): ActionResult { + // Latest-ref keeps the store's operation calling the newest closure + // without rebuilding the store on every render. Store identity stays + // stable for the hook's lifetime unless deps change. + const fnRef = useRef(fn); + useEffect(() => { fnRef.current = fn; }); + + const store = useMemo( + () => createReactiveActionStore( + (signal, ...args) => fnRef.current(signal, ...args), + ), + // eslint-disable-next-line react-hooks/exhaustive-deps -- `fnRef` decouples deps from the store + deps, + ); + + const snapshot = useSyncExternalStore(store.subscribe, store.getState); + + return useMemo( + () => ({ + send: store.dispatch, + status: snapshot.status, + isIdle: snapshot.status === 'idle', + isRunning: snapshot.status === 'running', + isSuccess: snapshot.status === 'success', + isError: snapshot.status === 'error', + data: snapshot.data, + error: snapshot.error, + reset: store.reset, + }), + [snapshot, store], + ); +} +``` + +The state machine, abort-on-supersede, and stale-while-revalidate semantics live in `createReactiveActionStore` — the React hook is a ~20-line bridge adding `is*` convenience booleans and a stable `send` alias for `dispatch`. + +The `send` function accepts the same inputs as `client.sendTransaction()` — raw instructions, fluent program client instructions, instruction plans, or pre-built transaction messages. For imperative flows where you don't need React state tracking, you can also call `client.sendTransaction(...)` directly via `useClient()`. + +#### Generic async action + +`useAction` wraps any async function with status/data/error tracking. It's the building block behind `useSendTransaction`, and is exported for custom async flows like sign-then-send or partial signing. Backed by `createReactiveActionStore` ([Prerequisites](#prerequisites)) — the state machine, supersede semantics, and stale-while-revalidate behavior all live in the Kit primitive. + +```typescript +/** + * Track the async state of a user-triggered action. + * Returns a stable `send` function and reactive status/data/error. + * + * The wrapped function receives an `AbortSignal` as its first argument — + * thread it into your `fetch` / RPC / wallet call for true cancellation. + * Calling `send` while a prior call is in flight (or calling `reset()`) + * aborts the prior call's signal: its `await` rejects with `AbortError` + * and its outcome is never written to state. + * + * `deps` captures the values `fn` closes over (client, chain, signer). + * Pass to `react-hooks/exhaustive-deps` via `additionalHooks` to lint. + */ +function useAction( + fn: (signal: AbortSignal, ...args: TArgs) => Promise, + deps: DependencyList, +): ActionResult; +``` + +See [`ActionResult`](#sending-transactions) above for the full return shape — including the `status` discriminated string and the `isIdle` / `isRunning` / `isSuccess` / `isError` booleans derived from it. + +Fire-and-forget is the common case — call `send(...)` from an event handler and render from `status` / `data` / `error`. The hook's reactive state tracks the newest call, so a superseded call's rejection is never observed. Only flows that `await send(...)` to read the resolved value (e.g. navigate on success, post signed bytes to an API) need to filter supersedes; kit-react exports `isAbortError` as the one-liner: + +```tsx +import { isAbortError } from '@solana/kit-react'; + +try { + const result = await send(...); + navigate(`/tx/${result.signature}`); +} catch (err) { + if (isAbortError(err)) return; // superseded — state reflects the newer call + // handle real error +} +``` + +Usage — sign-then-send flow: + +```tsx +const client = useClient(); + +// Step 1: Plan the transaction +const { send: plan, data: message } = useAction( + (_signal, input: InstructionPlanInput) => client.planTransaction(input), +); + +// Step 2: Sign (without sending) +const { send: sign, data: signed } = useAction( + (_signal, msg: TransactionMessage) => signTransactionMessageWithSigners(msg), +); + +// Step 3: Send the already-signed transaction +const { send: sendSigned, status } = useAction( + (signal, tx: Transaction) => + sendAndConfirmTransaction(client.rpc, tx, { abortSignal: signal, commitment: 'confirmed' }), +); + +// In your UI: +await plan(getTransferInstruction({ source, destination, amount })); +// ... user reviews the planned message ... +await sign(message); +// ... user reviews the signed transaction ... +await sendSigned(signed); +``` + +Usage — DeFi aggregator flow (sign locally, submit to external API): + +This pattern is common for swap aggregators, relayers, and any flow where a third-party service builds the transaction and handles submission. The wallet only signs — it doesn't send to the RPC. Passing the signal through to `fetch` means a rapid second click actually cancels the first submission, not just the outer await: + +```tsx +const signer = useWalletSigner(); +const base64Codec = useMemo(() => getBase64Codec(), []); + +// Optional: track sub-phases for granular loading UI +const [phase, setPhase] = useState<'idle' | 'signing' | 'confirming'>('idle'); + +const { send: handleSwap, status, data: result, error } = useAction( + async (signal, order: { transaction: string; requestId: string }) => { + if (!signer) throw new Error('Connect a signing wallet to continue.'); + // 1. Decode the pre-built transaction from the API + setPhase('signing'); + const txBytes = base64Codec.encode(order.transaction); + const [signed] = await signer.signTransactions([ + getTransactionDecoder().decode(txBytes), + ]); + + // 2. Submit signed transaction back to the API (not to the RPC) + setPhase('confirming'); + const signedBase64 = base64Codec.decode( + getTransactionEncoder().encode(signed), + ); + return submitToAggregatorApi( + { signedTransaction: signedBase64, requestId: order.requestId }, + { signal }, + ); + }, +); + +// status: 'idle' | 'running' | 'success' | 'error' +// phase: 'signing' | 'confirming' (granular sub-state for loading UI) +// result: API response on success +// error: rejection or API error +``` + +`useAction` handles the state machine (idle → sending → success/error). The `phase` useState is an optional app-specific detail for distinguishing "waiting for wallet popup" from "waiting for API confirmation" in the UI — it doesn't affect `useAction`'s lifecycle. + +#### One-shot reads + +Covered by **`useRequest`** — see [the one-shot requests section](#one-shot-requests-userequest) earlier. Auto-dispatches on mount / deps change, returns `{ data, error, status, isLoading, refresh }`, backed by `PendingRpcRequest.reactiveStore()` (a `ReactiveActionStore`) from [Prerequisites](#prerequisites). + +```typescript +const { data: epoch, error, refresh } = useRequest( + () => client.rpc.getEpochInfo(), + [client], +); +``` + +When you want shared cache semantics across many components (dedupe, persistence, devtools, Suspense), opt into `useRequestSwr` or `useRequestQuery` from the [SWR](#swr-adapter-solanakit-reactswr) or [TanStack Query](#tanstack-query-adapter-solanakit-reactquery) adapters — same underlying Kit primitive, routed through the cache library. + +For imperative one-offs (outside the render path), call the pending request directly: + +```typescript +const client = useClient(); +const epochInfo = await client.rpc.getEpochInfo().send(); +``` + +## Third-party extensions + +Any Kit plugin works with kit-react out of the box — consumers install it on their client with `.use()` and read it back through `useClient()` or a typed convenience hook. No React-specific wrapper needed from the plugin author. + +### Example: a DAS plugin package + +A DAS package ships a Kit plugin and optionally convenience hooks: + +**1. The kit plugin** (framework-agnostic, adds `client.das.*`): + +```typescript +// @my-org/kit-plugin-das +export function dasPlugin(config: DasConfig): Plugin<{ das: DasClient }>; +``` + +**2. Usage** — consumer installs the plugin on their client before handing it to `KitClientProvider`: + +```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'; +import { dasPlugin } from '@my-org/kit-plugin-das'; + +const client = createClient() + .use(walletSigner({ chain: 'solana:mainnet' })) + .use(dasPlugin({ endpoint: 'https://mainnet.helius-rpc.com/?api-key=...' })) + .use(solanaMainnetRpc({ rpcUrl: '...' })); + + + +; +``` + +Any `useClient()` call in the subtree returns the DAS-extended client at runtime. + +**3. Optional typed convenience hooks:** + +```typescript +// @my-org/kit-react-das +import { useClientCapability, useRequest } from '@solana/kit-react'; +import type { DasClient } from '@my-org/kit-plugin-das'; + +export function useAsset(address: Address) { + const client = useClientCapability({ + capability: 'das', + hookName: 'useAsset', + providerHint: 'Usually supplied by .', + }); + return useRequest(() => client.das.getAsset(address), [client, address]); +} +``` + +Consumers just import `useAsset` — they never need to touch `useClient` or know about the underlying DAS plugin. + +Note the choice of primitive. Plugins and cache libraries are **orthogonal axes of extensibility**: a plugin author shouldn't assume their users have installed SWR or TanStack Query, so the reference convenience hook is built on kit-react's native primitives (`useRequest` for one-shot reads, `useLiveData` / `useSubscription` for streams). That gives every consumer a working hook out of the box. Plugin authors who want to offer cache-integrated variants too can ship them under their own subpaths (`@my-org/kit-react-das/swr`, `.../query`) that peer-depend on the relevant cache library — the same pattern kit-react itself uses for its adapters. End users then pick the cache layer independently of which plugins they've installed. + +### `useClientCapability` — runtime-checked third-party hooks + +Core hooks like `useBalance` / `useSendTransaction` don't just cast the client with `useClient()` — they also assert the required capabilities are installed and throw a consistently-formatted error when they aren't. That machinery is exported as `useClientCapability` so third-party hook authors get the same DX for free: + +```typescript +function useClientCapability(options: { + /** Single key or ordered list — each is checked via `key in client`. */ + capability: string | readonly string[]; + /** Hook name used as the subject of the error. */ + hookName: string; + /** Free-text "how to fix" hint appended to the error. */ + providerHint: string; +}): Client; +``` + +The TypeScript narrowing is still a caller-declared cast (same as `useClient()`), but the runtime check makes the missing-provider failure loud at mount rather than deferring to a cryptic call-site crash. This is the recommended path for hook authors who want their users to see "useAsset() requires `client.das`. Usually supplied by ``" rather than "Cannot read properties of undefined". + +### `useClient()` vs. `useClientCapability()` + +`useClient()` (shown in [Client access](#client-access)) is a pure cast — no runtime check. Use it when you specifically don't want the runtime check (e.g. testing code inspecting the raw client, or a hook that tolerates missing capabilities). In production hooks that depend on a specific plugin being installed, reach for `useClientCapability` first so the missing-provider failure surfaces at mount with a typed message instead of a cryptic crash at the call site. + +## SWR Adapter (`@solana/kit-react/swr`) + +### Dependencies + +```json +{ + "peerDependencies": { + "@solana/kit-react": "^1.x", + "swr": "^2.x" + } +} +``` + +### Naming convention + +Every hook in this adapter carries the `Swr` suffix (e.g. `useLiveSwr`, `useRequestSwr`, `useSendTransactionSwr`). The suffix makes the cache backing visible at every call site, avoids collisions with core hook names, and stays greppable. The [TanStack adapter](#tanstack-query-adapter-solanakit-reactquery) uses the `Query` suffix the same way. + +### Generic bridge + +Bridges a `LiveDataSpec` (the same shape consumed by `useLiveData` and the stock live-data builders) into SWR's cache via `useSWRSubscription`: + +```typescript +/** + * Bridge a `LiveDataSpec` into SWR's cache via `useSWRSubscription`. + * Manages subscription lifecycle and error propagation. + * + * `spec` is the same framework-agnostic shape consumed by `useLiveData` + * and `useLiveQuery`, so the stock live-data builders + * (`createBalanceLiveData`, `createAccountLiveData`, …) and plugin-author + * builders plug in directly. Pass `null` as the key to disable. + */ +function useLiveSwr( + key: SWRKey | null, + spec: LiveDataSpec, +): SWRResponse; +``` + +Usage: + +```tsx +// Route `useBalance`'s data source through SWR — every component reading +// `['balance', address]` dedupes into one subscription and participates +// in SWR's cache invalidation / devtools / persistence. +const { data: balance, error, isLoading } = useLiveSwr( + ['balance', address], + createBalanceLiveData(client, address), +); + +// Custom live data — same bridge, arbitrary RPC + subscription pair. +const { data: gameState } = useLiveSwr( + ['gameState', gameAddress], + { + rpcRequest: client.rpc.getAccountInfo(gameAddress), + rpcSubscriptionRequest: client.rpcSubscriptions.accountNotifications(gameAddress), + rpcValueMapper: (v) => parseGameState(v.value), + rpcSubscriptionValueMapper: (v) => parseGameState(v), + }, +); +``` + +### Subscription-only bridge + +For streams without an RPC fetch counterpart, `useSubscriptionSwr` routes a `ReactiveStreamSource` (same duck-type consumed by core's `useSubscription`) through SWR's cache. Subscriptions don't have meaningful initial-fetch or persistence semantics — the cache win is dedup across components and devtools visibility into which streams are active. Envelope unwrapping matches core's `useSubscription`. + +```typescript +/** + * Bridge a `ReactiveStreamSource` into SWR's cache via + * `useSWRSubscription`. `SolanaRpcResponse`-shaped notifications are + * unwrapped the same way as core's `useSubscription` — `data` is the + * inner value. + */ +function useSubscriptionSwr( + key: SWRKey | null, + source: ReactiveStreamSource, +): SWRResponse>; +``` + +Usage: + +```tsx +const { data: slot } = useSubscriptionSwr( + ['slot'], + client.rpcSubscriptions.slotNotifications(), +); +``` + +### Mutation hooks + +Same underlying `client.sendTransaction()` / `client.sendTransactions()` as core, but wired through SWR's `useSWRMutation` for cache revalidation. Distinct names from core's `useSendTransaction` / `useSendTransactions` — the return shape is SWR's, not kit-react's `ActionResult`. + +```typescript +/** + * Send a single transaction with SWR mutation support. + * Revalidates the provided keys on success. + */ +function useSendTransactionSwr(options?: { + revalidateKeys?: SWRKey[]; +}): SWRMutationResponse; + +/** + * Send one or more transactions with SWR mutation support. + */ +function useSendTransactionsSwr(options?: { + revalidateKeys?: SWRKey[]; +}): SWRMutationResponse; +``` + +Usage: + +```tsx +import { useSendTransactionSwr } from '@solana/kit-react/swr'; + +const { trigger, isMutating, error } = useSendTransactionSwr({ + revalidateKeys: [['balance', sourceAddress]], +}); + +await trigger(getTransferInstruction({ source, destination, amount })); +// SWR automatically revalidates the balance query after success +``` + +Implementation: + +```tsx +import { useClientCapability } from '@solana/kit-react'; +import useSWRMutation from 'swr/mutation'; + +function useSendTransactionSwr(options?: { revalidateKeys?: SWRKey[] }) { + const client = useClientCapability({ + capability: 'sendTransaction', + hookName: 'useSendTransactionSwr', + providerHint: 'Install `solanaRpc()` or `litesvm()` on the client.', + }); + + return useSWRMutation( + 'sendTransaction', + (_key, { arg }: { arg: Parameters[0] }) => + client.sendTransaction(arg), + { + onSuccess() { + options?.revalidateKeys?.forEach((key) => mutate(key)); + }, + }, + ); +} +``` + +The adapter is thin — it delegates entirely to `client.sendTransaction()` and wires up `useSWRMutation`'s lifecycle around it. + +### Generic action bridge + +`useActionSwr` mirrors core's `useAction` but routes through `useSWRMutation`, so the operation's lifecycle plugs into SWR's cache invalidation and devtools. Useful for custom mutations (wallet sign flows, off-chain API calls, compound sign-then-send) where you want cache-library integration without reaching past kit-react's API. + +```typescript +/** + * Bridge any async operation into SWR's mutation primitive. The generic + * counterpart to `useSendTransactionSwr`. + */ +function useActionSwr( + key: SWRKey, + fn: (...args: TArgs) => Promise, + options?: { revalidateKeys?: SWRKey[] }, +): SWRMutationResponse; +``` + +Usage: + +```tsx +const { trigger, isMutating } = useActionSwr( + 'sign-payload', + async (payload: Uint8Array) => signer.signMessage(payload), + { revalidateKeys: [['session']] }, +); +``` + +### One-shot reads + +Core provides `useRequest` for one-shot requests. Use `useRequestSwr` when you want SWR's cache (shared across components, persistence, Suspense mode): + +```typescript +// Core — no cache sharing, per-hook state +const { data } = useRequest(() => client.rpc.getEpochInfo(), [client]); + +// SWR-backed — cache hits across components, persistence, Suspense +const { data } = useRequestSwr(['epochInfo'], () => client.rpc.getEpochInfo()); +``` + +## TanStack Query Adapter (`@solana/kit-react/query`) + +### Dependencies + +```json +{ + "peerDependencies": { + "@solana/kit-react": "^1.x", + "@tanstack/react-query": "^5.x" + } +} +``` + +### Naming convention + +Every hook in this adapter carries the `Query` suffix (e.g. `useLiveQuery`, `useRequestQuery`, `useSendTransactionQuery`). Matches TanStack Query's own ecosystem vocabulary (`useQuery`, `useInfiniteQuery`, `useSuspenseQuery` are all named with `Query`) and avoids collisions with core hook names — core's generic live-data hook is `useLiveData`, not `useLiveQuery`. + +### Generic bridge + +Bridges a `LiveDataSpec` into TanStack Query's cache — initial fetch via `queryFn`, ongoing updates pushed via `queryClient.setQueryData`. Same spec shape as `useLiveData` / `useLiveSwr`: + +```typescript +/** + * Bridge a `LiveDataSpec` into TanStack Query's cache. + * Initial fetch via queryFn, ongoing updates via subscription → setQueryData. + * + * `spec` is the same framework-agnostic shape consumed by `useLiveData` + * and `useLiveSwr`, so the stock live-data builders + * (`createBalanceLiveData`, `createAccountLiveData`, …) and plugin-author + * builders plug in directly. + */ +function useLiveQuery( + key: QueryKey, + spec: LiveDataSpec, + options?: UseQueryOptions, +): UseQueryResult; +``` + +Usage: + +```tsx +// Route `useBalance`'s data source through TanStack Query — every +// component reading `['balance', address]` dedupes into one subscription +// and participates in TanStack's cache invalidation / devtools / +// Suspense. +const { data: balance, error, isLoading } = useLiveQuery( + ['balance', address], + createBalanceLiveData(client, address), +); + +// Custom live data — same bridge, arbitrary RPC + subscription pair. +const { data: gameState } = useLiveQuery( + ['gameState', gameAddress], + { + rpcRequest: client.rpc.getAccountInfo(gameAddress), + rpcSubscriptionRequest: client.rpcSubscriptions.accountNotifications(gameAddress), + rpcValueMapper: (v) => parseGameState(v.value), + rpcSubscriptionValueMapper: (v) => parseGameState(v), + }, +); +``` + +### Subscription-only bridge + +For streams without an RPC fetch counterpart, `useSubscriptionQuery` routes a `ReactiveStreamSource` through TanStack's cache. The subscription pushes updates via `queryClient.setQueryData` — same dedup / devtools benefits as `useLiveQuery`, just without an initial `queryFn` fetch (the first value arrives from the first notification). Envelope unwrapping matches core's `useSubscription`. + +```typescript +/** + * Bridge a `ReactiveStreamSource` into TanStack Query's cache. + * `SolanaRpcResponse`-shaped notifications are unwrapped the same way + * as core's `useSubscription`. + */ +function useSubscriptionQuery( + key: QueryKey, + source: ReactiveStreamSource, + options?: UseQueryOptions, +): UseQueryResult>; +``` + +Usage: + +```tsx +const { data: slot } = useSubscriptionQuery( + ['slot'], + client.rpcSubscriptions.slotNotifications(), +); +``` + +### Mutation hooks + +Same underlying `client.sendTransaction()` / `client.sendTransactions()` as core, wired through TanStack's `useMutation` for automatic cache invalidation, optimistic updates, and devtools visibility. Distinct names from core's — the return shape is TanStack's `UseMutationResult`, not kit-react's `ActionResult`. + +```typescript +/** + * Send a single transaction with TanStack mutation support. + * Invalidates the provided query keys on success. + */ +function useSendTransactionQuery(options?: { + onSuccess?: (result: SuccessfulSingleTransactionPlanResult) => void; + invalidateKeys?: QueryKey[]; +}): UseMutationResult; + +/** + * Send one or more transactions with TanStack mutation support. + */ +function useSendTransactionsQuery(options?: { + onSuccess?: (result: TransactionPlanResult) => void; + invalidateKeys?: QueryKey[]; +}): UseMutationResult; +``` + +Usage: + +```tsx +import { useSendTransactionQuery } from '@solana/kit-react/query'; + +const { mutateAsync, isPending, error } = useSendTransactionQuery({ + invalidateKeys: [['balance', sourceAddress]], + onSuccess(result) { + console.log('Confirmed:', result.signature); + }, +}); + +// Pass an instruction — the hook calls client.sendTransaction() internally +await mutateAsync(getTransferInstruction({ source, destination, amount })); + +// Fluent program client API works too — pass the instruction, not .sendTransaction() +await mutateAsync(client.system.instructions.transfer({ source, destination, amount })); + +// TanStack automatically invalidates the balance query after success +``` + +Implementation: + +```tsx +import { useClientCapability } from '@solana/kit-react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +function useSendTransactionQuery(options?: { + onSuccess?: (result: SuccessfulSingleTransactionPlanResult) => void; + invalidateKeys?: QueryKey[]; +}) { + const client = useClientCapability({ + capability: 'sendTransaction', + hookName: 'useSendTransactionQuery', + providerHint: 'Install `solanaRpc()` or `litesvm()` on the client.', + }); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: Parameters[0]) => + client.sendTransaction(input), + onSuccess(result) { + options?.onSuccess?.(result); + options?.invalidateKeys?.forEach((key) => + queryClient.invalidateQueries({ queryKey: key }), + ); + }, + }); +} +``` + +The adapter is thin — it delegates entirely to `client.sendTransaction()` and wires up `useMutation`'s lifecycle around it. + +### Generic action bridge + +`useActionQuery` mirrors core's `useAction` but routes through TanStack's `useMutation`, so the operation plugs into cache invalidation, optimistic updates, and devtools. The generic counterpart to `useSendTransactionQuery`. + +```typescript +/** + * Bridge any async operation into TanStack's mutation primitive. + */ +function useActionQuery( + fn: (...args: TArgs) => Promise, + options?: { + onSuccess?: (result: TResult) => void; + invalidateKeys?: QueryKey[]; + }, +): UseMutationResult; +``` + +Usage: + +```tsx +const { mutateAsync, isPending } = useActionQuery( + async (payload: Uint8Array) => signer.signMessage(payload), + { invalidateKeys: [['session']] }, +); +``` + +### One-shot reads + +Core provides `useRequest`. Use `useRequestQuery` when you want TanStack's cache (dedupe, Suspense, devtools, invalidation): + +```typescript +// Core — no cache sharing, per-hook state +const { data } = useRequest(() => client.rpc.getEpochInfo(), [client]); + +// TanStack-backed — shared cache, Suspense-capable, devtools-visible +const { data } = useRequestQuery(['epochInfo'], () => client.rpc.getEpochInfo()); +``` + +## What Each Layer Provides + +| Feature | Core | SWR Adapter | TanStack Adapter | +|---------|------|-------------|------------------| +| Providers | ✅ | — | — | +| Wallet hooks | ✅ (`@solana/kit-react/wallet` subpath) | — | — | +| `useBalance`, `useAccount` | ✅ (ReactiveStreamStore → useSyncExternalStore) | ✅ (SWR cache) | ✅ (TanStack cache) | +| Generic live data | `useLiveData` | `useLiveSwr` | `useLiveQuery` | +| Subscription-only bridge | `useSubscription` | `useSubscriptionSwr` | `useSubscriptionQuery` | +| One-shot reads | `useRequest` (ReactiveActionStore, auto-dispatched) | `useRequestSwr` (SWR cache) | `useRequestQuery` (TanStack cache) | +| Send transactions | `useSendTransaction` / `useSendTransactions` (ActionResult) | `useSendTransactionSwr` / `useSendTransactionsSwr` (SWR mutation) | `useSendTransactionQuery` / `useSendTransactionsQuery` (TanStack mutation) | +| Generic action bridge | `useAction` | `useActionSwr` | `useActionQuery` | +| Suspense | — | ✅ | ✅ | +| Devtools | — | ✅ | ✅ | +| Cross-component dedup | — (each hook has its own store; lift into context to share) | SWR built-in (by key) | TanStack built-in (by query key) | + +## Design Decisions + +**Headless by design — no UI.** kit-react ships providers, hooks, and reactive state primitives; it does not ship buttons, modals, wallet pickers, connect flows, or any other rendered components. Wallet-standard discovery and the `walletSigner` plugin are the deepest this library goes — the app (or a higher-level UI library like wallet-ui, connectorkit, or a framework-kit-style opinionated bundle) owns how that state is presented. Rationale: UI is where apps differ most, and a kit-react modal would compete with every downstream library that wants to own the visual layer, while solving a problem the hooks already solve at a lower level. Keeping kit-react headless lets UI libraries build on top instead of around, and lets teams with design-system constraints skip kit-react's opinions without giving up the state machinery. + +**Client is an implementation detail.** Consumers use providers and hooks. `useClient()` is an escape hatch for power users, not the primary API. This matches how wagmi hides its core under React hooks. + +**No per-RPC-method hooks.** Kit has dozens of RPC methods. Wrapping each in a hook adds maintenance surface without adding logic — `useRequest(() => client.rpc.getEpochInfo(), [client])` is the generic escape hatch and reads cleanly at the call site. A dedicated `useGetEpochInfo()` would save exactly the body of that factory function, at the cost of a ~50-hook surface to maintain in lockstep with Kit's RPC spec. + +**Named hooks only for live data.** `useBalance` and `useAccount` earn their existence by hiding the RPC + subscription pairing, slot dedup, and response mapping. These are Solana-specific domain knowledge that developers shouldn't need to figure out. `useAccount` additionally hides the RPC encoding format and the `parseBase64RpcAccount` bridge between raw RPC responses and Kit's `Account` type, and progressively discloses decoding via an optional `decoder` argument. + +**One-shot reads in core via `useRequest`.** Earlier drafts delegated one-shot reads entirely to SWR / TanStack on the reasoning that "plain React doesn't have a good data-fetching primitive." Once Kit ships `PendingRpcRequest.reactiveStore(): ReactiveActionStore` with eager auto-dispatch on creation, that reasoning stops applying — the primitive exists, one layer down. `useRequest` bridges the action store into `useSyncExternalStore` and surfaces `{ data, error, status, refresh }` with the same stale-while-revalidate semantics that the subscription hooks give. Consumers who want shared cache / Suspense / devtools still opt into the SWR / TanStack adapters; those who don't get a first-class read hook without pulling in a cache library. + +**Read shape vs. send shape — `useRequest` vs. `useAction`.** Two separate hooks rather than one with a flag, because the two use cases want different affordances: `useRequest` consumes an eager-dispatching `.reactiveStore()` and returns a read-oriented shape (`data`, `refresh`); `useAction` wraps any async function via `createReactiveActionStore` (neutral on initiation) and returns a send-oriented shape (`send`, `reset`, `isIdle`). Both wrap `ReactiveActionStore` internally, but collapsing them into one hook would force every caller to choose which half to ignore at every site. Plugin authors whose pending objects expose `.reactiveStore(): ReactiveActionStore` plug straight into `useRequest` via the `ReactiveActionSource` duck-type; anything else (a user-triggered operation, a custom async call) reaches for `useAction`. + +**Duck-typed orthogonality boundaries.** The generic hooks (`useRequest`, `useSubscription`, `useLiveData`, `useLiveSwr`, `useLiveQuery`) all accept the smallest possible input shape: `ReactiveActionSource` (anything with `.reactiveStore(): ReactiveActionStore<[], T>`), `ReactiveStreamSource` (anything with `.reactiveStore({ abortSignal }): ReactiveStreamStore`), or `LiveDataSpec` (the RPC + subscription + mappers, minus signal). Kit's `PendingRpcRequest` / `PendingRpcSubscriptionsRequest` satisfy these by design, but so does any plugin-authored pending object that follows the same convention — no patching kit-react, no registering types, no wrapper layer. This is the same pattern used by `subscribeTo`: the framework layer publishes a duck-type; the plugin layer conforms. + +**Adapters integrate, not replace.** The SWR and TanStack adapters bridge kit-react's reactive stores into those libraries' cache layers — `useSWRSubscription` for streams, `setQueryData` for live updates, `useSWRMutation` / `useMutation` for sends — plus `useRequestSwr` / `useRequestQuery` for cache-backed one-shot reads. They don't re-implement the Kit-side state machines; they pipe `subscribe` / `getUnifiedState` (streams) / `getState` + `dispatch` (actions) into the cache library's existing APIs. + +**Plugin React hooks are optional.** Any Kit plugin works with kit-react the moment a consumer calls `.use()` on it — core's generic hooks (`useRequest(() => client.myPlugin.foo())`, `useLiveData(...)`, `useSubscription(...)`, `useAction(...)`, or the `useClient()` escape hatch) cover the consumer-facing side. Plugin authors don't need to ship React bindings for their plugin to be usable — core provides enough primitives for consumers to build whatever hook shape they need against any plugin. Typed convenience hooks (see [Third-party extensions](#third-party-extensions)) are a DX upgrade, not a prerequisite — they let plugin authors reduce boilerplate and attach a stable error story via `useClientCapability`, but the consumer-facing functionality is available the moment the plugin is installed. + +**`{ data, error, status, retry }` rather than Suspense / Error Boundaries.** Live-data hooks return a reactive snapshot shape (mirroring Kit's `ReactiveState` with an added `'disabled'` variant) instead of suspending or throwing. + +*Subscriptions can't suspend.* Suspense's contract is "throw a promise that eventually resolves or rejects" — one-shot — and subscriptions don't fit that model: they never "resolve" in Suspense's sense, they keep emitting updates. `useSyncExternalStore` is the React-team-supplied primitive for this class of state and is deliberately incompatible with Suspense. The rest of the ecosystem makes the same call: TanStack Query's `useSuspenseQuery` only wraps one-shot fetches; its subscription path uses `{ data, isLoading, error }`. Consumers who specifically want Suspense for one-shot RPC reads opt in via the SWR / TanStack adapters, both of which have Suspense modes — kit-react owns the live-data primitives that fundamentally can't suspend. + +*Mutations can't suspend either.* You can't throw a promise from an event handler, and every mutation primitive in the ecosystem (TanStack's `useMutation`, SWR's `useSWRMutation`) returns the same `{ status, data, error }` shape. `useSendTransaction` / `useAction` follow that convention. + +*Error Boundaries remain a valid backstop, but not the primary error channel.* Boundaries catch *unexpected* errors (bugs, crashes) and remain useful above the tree. Expected errors (RPC down, signature rejected, wallet disconnected mid-fetch) usually need specific UI branches ("Try again", "Switch RPC", "Reconnect") — returning `error` + `retry()` reactively lets the component branch on the error shape and recover in-place without remounting. + +**First-class retry.** Every live-data hook returns a `retry()` function drawn from the underlying `ReactiveStore.retry` — stable identity, safe as an `onClick`. Retry is end-to-end: Kit's stores tear down the broken stream, transition through `status: 'retrying'` preserving the last known `data`, re-open the WebSocket (and for named hooks, re-run the initial RPC fetch), and return to `loaded` or `error` as appropriate. The React bridge adds no layer on top — consumers writing `` get correct behavior without a `useCallback` wrapper or external state. + +**SSR-safe by default.** Every provider renders on the server without throwing, and every hook returns a hydration-stable "not yet available" snapshot during SSR. The wallet plugin explicitly ships a server stub (`status === 'pending'`, empty `wallets`, throwing actions) so its first render matches on both server and client. The reactive hooks (`useBalance`, `useAccount`, `useTransactionConfirmation`, `useLiveQuery`, `useSubscription`, `useRequest`) skip the reactive-store factory entirely on non-browser builds — they return `{ status: 'loading', data: undefined, isLoading: true }` without firing HTTP or opening WebSockets, then the real store kicks in on the client. This skip is load-bearing for `useRequest`: `PendingRpcRequest.reactiveStore()` auto-dispatches on creation (same semantics as `PendingRpcSubscriptionsRequest.reactiveStore()`), so not calling it on the server is what prevents a server-side fetch. Action hooks (`useSendTransaction`, `useAction`, the wallet action hooks) are already safe: they build action stores via `createReactiveActionStore(fn)` directly (which stays neutral on initiation), so nothing fires until `dispatch()` is called, which doesn't happen during SSR since it's event-triggered. We deliberately don't prefetch on the server even though we could: on-chain state moves fast enough that any prefetched value would usually mismatch the first client snapshot, and the hydration failure is worse than an extra loading flicker. For per-request clients (Next.js app router, Remix), `KitClientProvider`'s `client` prop accepts a pre-built client whose lifecycle the caller owns. + +**Errors are surfaced as `unknown`, narrowed with Kit helpers.** Kit throws `SolanaError` with stable error codes; the wallet plugin throws `WalletStandardError` with the same pattern. Hooks propagate errors through `LiveQueryResult.error` / `ActionResult.error` / `RequestResult.error` as `unknown`, and consumers narrow in render branches via `isSolanaError(e, SOLANA_ERROR__WALLET__USER_REJECTED)` / `isWalletStandardError(e, ...)`: + +```tsx +const { error, retry } = useBalance(address); +if (error) { + if (isSolanaError(error, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR)) { + return
RPC unreachable.
; + } + return
Unexpected error.
; +} +``` + +kit-react doesn't re-wrap or coerce errors — the original `SolanaError` / `WalletStandardError` passes through so code narrowing against Kit's error codes works uniformly across the library and downstream of it. + +**Client-first, single provider.** Consumers build a Kit client with `createClient().use(...)` outside React and hand it to `KitClientProvider`. The provider doesn't compose, extend, or dispose — it distributes a caller-owned value. Plugin composition, ordering, dispose semantics, and async setup all belong one layer down in Kit; React reduces to a value channel plus `useSyncExternalStore` bridges. An earlier draft wrapped each plugin in a matching React provider (`SignerProvider`, `SolanaMainnetRpcProvider`, …) and composed via JSX nesting, mirroring the wallet-adapter pattern. That duplicated Kit's composition API in React form, forced async plugins into a per-provider suspend implementation, and split the source of truth between the React tree and the Kit client. Collapsing to a single provider removes all of that: any Kit plugin works the moment a consumer calls `.use()` on it, sync or async, without kit-react needing to know. + +**Provider accepts `Client | Promise`.** For apps whose plugin chain contains an async `.use()` (where `createClient().use(...)` returns `Promise`), consumers pass the promise directly; the provider suspends via the nearest `` boundary. On React 19 this is `React.use(promise)`; on React 18 a small thrown-promise shim (~15 lines, WeakMap-cached) inside the provider honors the same Suspense contract. Consumers don't write the `use(promise)` dance themselves; they just add a `` boundary above. + +**Explicit client, not implicit.** An earlier draft had the root provider call `createClient()` for the caller when no `client` was provided. In practice that hid the client's origin, made the dispose story ambiguous (when does the provider own dispose vs. the caller?), and encouraged mounting plugin-specific providers below to extend it. Making `client` required pushes composition to the caller where it belongs — and since the caller is already calling `createClient().use(...)` anyway, there's no ergonomic cost. + +**`ChainIdentifier`, not just `SolanaChain`.** The chain prop on `KitClientProvider` accepts `SolanaChain | (IdentifierString & {})`. Known Solana chains (`"solana:mainnet"`, etc.) autocomplete as literals; any other wallet-standard-shaped identifier (`${string}:${string}`) is accepted as an escape hatch for custom chains and L2s. The `& {}` is the canonical TS trick for preserving literal autocomplete alongside a wider string-template type. + +**No dedicated transfer/token/stake hooks.** `useSendTransaction()` is generic — it accepts any instruction, instruction plan, or transaction message. Dedicated hooks like `useSolTransfer()` or `useSplToken()` would be thin wrappers that don't add meaningful logic. They can be built on top by higher-level libraries. + +**Transaction confirmation is subscription-backed.** `useTransactionConfirmation` uses `signatureNotifications` + `getSignatureStatuses` with slot-based dedup, rather than polling. This fits the core's philosophy that named hooks earn their place by hiding RPC + subscription pairing. + +**Signer hooks duck-type on a per-capability subscribe convention, not on wallet state.** Earlier drafts had `usePayer` / `useIdentity` reach for `client.wallet.subscribe` directly to stay reactive. That coupled core signer hooks to the wallet plugin's type — a smell, since any reactive plugin (not just wallet) could install a dynamic `payer` or `identity`. The current design instead defines a per-capability subscribe convention: whoever installs `client.payer` can optionally install `client.subscribeToPayer(listener)` alongside it (same for `identity`). The hook observes that sibling if present, otherwise falls back to a no-op subscribe. This keeps the core hooks wallet-agnostic, supports future reactive plugins for free, and is a much smaller surface than a global `client.subscribe` primitive (which would force every reactive plugin to share one bus and cause over-rendering). Static plugins like `payer()` / `identity()` / `signer()` from `@solana/kit-plugin-signer` participate implicitly by not installing a subscribe hook — the value never changes, so nothing needs to fire. + +**Single chain per `KitClientProvider`.** Each `KitClientProvider` is scoped to one chain — discovery, connection, and signer creation all depend on it. Apps that need multiple chains (e.g. a mainnet trading section and a devnet testing section) use separate `KitClientProvider`s, which means separate clients and separate wallet connections. This is the correct behavior: a wallet that supports `solana:mainnet` may not support a different chain like `l2:mainnet`, so you can't safely share a connection across chains. + +## Future directions + +Items explicitly considered during design and deferred. None are blocking, but each has a concrete motivation that may trigger revisiting. + +### Promote the `subscribeTo` producer-side helper to kit-core + +The consumer-facing shape (see [Signer access](#signer-access)) already lives in Kit — `ClientWithSubscribeToPayer` / `ClientWithSubscribeToIdentity` are exported from `@solana/kit`, and kit-react's `usePayer` / `useIdentity` duck-type against them. What's *not* yet shared is the **producer-side** machinery every reactive plugin needs to install a `subscribeTo` hook: a listener registry, unsubscribe idempotency, and safe iteration during notify. Today `kit-plugin-wallet` hand-rolls this by forwarding its internal wallet store's `subscribe`; a second reactive plugin would have to re-derive the same ~20 lines of glue around `@solana/subscribable`'s existing `DataPublisher` / `ReactiveStore` primitives. + +If a second reactive plugin appears (e.g. a relayer plugin whose `payer` rotates), graduate the producer-side helper to kit-core: + +```typescript +// Speculative kit-core API +function createCapabilityChangeNotifier(): { + subscribe: (listener: () => void) => () => void; + notify: () => void; +}; +``` + +Until then, producers re-implement ad-hoc; consumers already have the types they need. + +### Batched live-query hook + +A hook like `useBalances([addr1, addr2, addr3])` that subscribes to multiple accounts in one call (analogous to wagmi's `useContracts`) is a common ask. It's deferred, not rejected. + +Solana's `accountSubscribe` RPC method is one-at-a-time — there's no WebSocket-level batching to exploit. But a batched hook would still offer two things a fan-out of `useBalance` can't: + +1. **Rules-of-hooks ergonomics.** `useBalance` inside a `.map()` over a dynamic list is illegal. A batched hook makes variable-length lists expressible. +2. **Batched initial read.** `getMultipleAccounts` fetches up to 100 accounts in one RPC call. A batched hook could seed N per-account stores from a single initial fetch, then fan out to N `accountSubscribe` calls for live updates. + +The upstream primitive (`createReactiveStoreWithInitialValueAndSlotTracking` in `@solana/kit`) is 1:1 by design — one RPC request + one subscription → one store. A batched hook needs either a new upstream primitive (e.g. `createReactiveStoreWithBatchedInitialValuesAndSlotTracking`) or a variant of the existing one that accepts a pre-fetched initial value instead of performing the fetch itself. + +Deferred until there's a concrete use case. The rules-of-hooks argument is the stronger motivator — revisit when someone hits it. + +### Observe external writes to `WalletStorage` + +The wallet plugin's storage layer is write-through today: it persists the active wallet selection, but doesn't watch for external writes. If `WalletStorage` gained an optional `subscribe` method (matching the pattern used by Zustand's `persist` and TanStack Query's storage adapters), the plugin could react to cross-tab writes or sibling-provider changes and reconcile its in-memory state without a reload. Two motivating cases: + +1. **Cross-tab sync.** User connects in tab A; tab B picks up the selection without the user re-prompting. +2. **Sibling-provider sync within one app.** Two `KitClientProvider`s in the same tree sharing a storage key could propagate wallet selection across them (each still verifies chain support independently — storage propagates the selection, not the connection). + +Plain `localStorage` (no `subscribe`) would continue to work unchanged — callers opt in by supplying a storage adapter that exposes `subscribe`. Deferred until a concrete product use case appears; both motivators are nice-to-have, neither is blocking. + +## Appendix: Comparisons + +The rest of this document compares `kit-react` with the existing React libraries in the Solana ecosystem and with the hand-rolled primitives the [Kit example React app](https://github.com/anza-xyz/kit/tree/main/examples/react-app) uses today. The detail here is for reviewers who want to understand exactly what this proposal does and doesn't cover relative to the tools developers already know. It's not required reading to understand the design. + +### framework-kit + +[`framework-kit`](https://github.com/solana-foundation/framework-kit) is a feature-complete React library for Solana built on a different architecture (`@solana/client` + Zustand + SWR). `kit-react` is not a replacement — it's a lower-level foundation that framework-kit (or similar libraries) could build on top of. + +#### What kit-react covers + +All of framework-kit's core functionality is covered: + +| Area | framework-kit | kit-react | +|------|--------------|-----------| +| Wallet connection | `useWallet`, `useWalletSession`, `useConnectWallet`, `useDisconnectWallet` | `useWalletStatus`, `useConnectedWallet`, `useConnectWallet`, `useDisconnectWallet` | +| Wallet discovery | Via `autoDiscover()` + connectors | `useWallets()` (wallet-standard via plugin) | +| Auto-connect | `SolanaProvider` walletPersistence config | `walletSigner({ autoConnect })` plugin config | +| Balance | `useBalance()` (SWR polling) | `useBalance()` (subscription-backed) | +| Account data | `useAccount()` | `useAccount()` with optional decoder | +| Send transaction | `useSendTransaction()` | `useSendTransaction()` | +| Signature tracking | `useSignatureStatus()` + `useWaitForSignature()` | `useTransactionConfirmation()` (unified, subscription-backed) | +| Chain/cluster | `useClusterState()`, `useClusterStatus()` | `useChain()` + provider props | +| Client access | `useClientStore(selector)` | `useClient()` | + +#### Intentional gaps + +These framework-kit features are omitted by design, not oversight: + +- **Dedicated transfer/token/stake hooks** (`useSolTransfer`, `useSplToken`, `useWrapSol`, `useStake`) — covered by `useSendTransaction()` + the relevant instruction. Higher-level libraries can add these. +- **Per-method one-shot RPC hooks** (`useProgramAccounts`, `useLookupTable`, `useNonceAccount`, `useLatestBlockhash`, `useSimulateTransaction`) — covered generically by `useRequest(() => client.rpc.X(...), deps)` rather than one named hook per RPC method. Avoids the maintenance surface of dozens of thin wrappers and stays aligned with Kit's granular RPC surface. +- **Wallet modal state** (`useWalletModalState`, `WalletConnectionManager`) — UI concern, left to consumer or UI libraries. +- **SWR query infrastructure** (`useSolanaRpcQuery`, query key scoping) — each cache library handles this natively. + +#### What kit-react adds + +Features that framework-kit does not provide: + +- **Cache-library agnostic** — SWR and TanStack adapters, not locked to one. +- **`useAccount` with decoder** — progressive disclosure of typed account decoding. +- **`useLiveData`** — generic subscription-backed queries for any RPC + subscription pair. +- **`useSubscription`** — raw subscription hook for subscription-only data. +- **Any Kit plugin works out of the box** — consumers `.use()` any plugin on their client and kit-react's hooks see it; no React-specific wrapper needed per plugin. +- **Separate payer / identity** — `payer()`, `identity()`, `signer()` from `@solana/kit-plugin-signer` for relayer, test, and CLI flows. +- **LiteSVM support** — drop-in via `litesvm()` for in-process transaction execution in tests. +- **`KitClientProvider`** — single explicit provider that distributes a caller-owned client; no hidden wrappers, no composition surface to learn. +- **Granular wallet hooks** — `useWallets()`, `useWalletStatus()`, `useConnectedWallet()` subscribe to only the slice they need. + +### connectorkit + +[`connectorkit`](https://github.com/nicholasgasior/connectorkit) (`@solana/connector`) is a production wallet connection library with headless UI components, multi-transport support (WalletConnect, Mobile Wallet Adapter), legacy `@solana/web3.js` compatibility, and devtools. kit-react provides the core primitives that connectorkit could build on top of. + +#### What kit-react covers + +| Area | connectorkit | kit-react | +|------|-------------|-----------| +| Wallet discovery | `useWalletConnectors()` (connector metadata) | `useWallets()` (UiWallet objects) | +| Wallet status | `useWallet()` (discriminated union) | `useWalletStatus()` + `useConnectedWallet()` | +| Connect / disconnect | `useConnectWallet()` / `useDisconnectWallet()` | `useConnectWallet()` / `useDisconnectWallet()` | +| Auto-connect | Config-driven, 200ms delay, silent-first | `walletSigner({ autoConnect })` plugin config | +| Balance | `useBalance()` (polling + cache) | `useBalance()` (subscription-backed) | +| Sign message | Via `signer.signMessage()` | `useSignMessage()` | +| Sign In With Solana | Not built-in | `useSignIn()` | +| Transaction sending | `useTransactionSigner()` / `useKitTransactionSigner()` | `useSendTransaction()` (instruction-plan lifecycle) | +| Chain/cluster | `useCluster()` with persistence + UI | `useChain()` + provider props | +| Client access | `useConnectorClient()` | `useClient()` | + +#### What connectorkit adds on top + +These are app-layer and transport-layer concerns that kit-react intentionally leaves to higher-level libraries: + +- **Headless UI components** — `WalletListElement`, `BalanceElement`, `TokenListElement`, `TransactionHistoryElement`, `ClusterElement`, `AccountElement`, `DisconnectElement` (all render-prop based) +- **Multi-transport wallet support** — WalletConnect (QR codes, deep links) and Mobile Wallet Adapter alongside browser extensions, with branded connector IDs to distinguish transports to the same wallet +- **Legacy compatibility** — `createWalletAdapterCompat()` for `@solana/web3.js` transaction API +- **Token list / transaction history** — `useTokens()`, `useTransactions()` with shared query cache +- **Cluster management UI** — persistence, explorer URL resolution, formatted addresses, clipboard utils +- **Event system** — `wallet:connected`, `transaction:signed`, etc. for analytics +- **Error boundaries** — recoverable errors, retry logic, fallback UI +- **Devtools** — `@solana/connector-debugger` with transaction inspection + +#### How connectorkit would build on kit-react + +Connectorkit would build a memoized client that layers its own transport/storage on top of the wallet plugin's, then hand it to `KitClientProvider`: + +```tsx +// Connectorkit disables plugin-level persistence and auto-connect, +// then implements its own with richer storage and reconnect logic. +function ConnectorProvider({ config, children }) { + const [chain, setChain] = useState(config.initialCluster); + + const client = useMemo( + () => createClient() + .use(walletSigner({ chain, autoConnect: false, storage: null })) + .use(walletConnectTransportPlugin(config)) + .use(solanaRpc({ rpcUrl: config.rpcUrlFor(chain) })), + [chain, config], + ); + + return ( + + {children} + + ); +} +``` + +Key integration points: + +- **`storage: null`** disables all plugin-level reads and writes, giving connectorkit a clean slate for its own versioned storage (`connector-kit:v1:wallet`) that stores full connector IDs (e.g. `mwa:phantom` vs `wallet-standard:phantom`) +- **`autoConnect: false`** skips plugin auto-reconnect (status goes `'pending'` → `'disconnected'` immediately), so connectorkit controls the full state machine — its own 200ms delay, silent-first with interactive fallback, etc. +- **`useWallets()`**, **`useConnectWallet()`**, **`useConnectedWallet()`**, **`useWalletStatus()`** are the building blocks for connectorkit's hooks, wrapped with its own event emission, error recovery, and connector ID mapping +- **Additional `.use(...)` plugins** can initialize WalletConnect or MWA as supplementary wallet-standard transports before they're needed +- **`useClient()`** provides access for connectorkit's legacy adapter layer and transaction signing hooks + +### wallet-ui + +[`wallet-ui`](https://github.com/nicholasgasior/wallet-ui) (`@wallet-ui/react`) is a simpler wallet library — a modern, Wallet-Standard-native replacement for the old wallet-adapter. It provides wallet connection hooks, account/cluster persistence, and headless UI components (dropdowns, modals, wallet lists) styled via data attributes and optional Tailwind CSS. + +#### What kit-react covers + +Wallet-UI has significant overlap with kit-react + kit-plugin-wallet. The core state management, wallet discovery, connection, and persistence are all handled: + +| Area | wallet-ui | kit-react | +|------|----------|-----------| +| Wallet discovery | `useWalletUiWallets()` | `useWallets()` | +| Bundled wallet state | `useWalletUi()` | `useWallets()` + `useConnectedWallet()` + `useWalletStatus()` | +| Connect / disconnect | `useWalletUiWallet({ wallet })` | `useConnectWallet()` / `useDisconnectWallet()` | +| Selected account | `useWalletUiAccount()` | `useConnectedWallet()` | +| Transaction signer | `useWalletUiSigner({ account })` | `useConnectedWallet().signer` | +| Cluster selection | `useWalletUiCluster()` | `useChain()` + provider props | +| Account persistence | Nanostores persistent atom (`wallet-ui:account`) | kit-plugin-wallet storage (`kit-wallet`) | +| Cluster persistence | Nanostores persistent atom (`wallet-ui:cluster`) | Not in kit-react (app-layer concern) | + +#### What wallet-ui adds on top + +Wallet-UI's unique contribution is its **UI component layer** — kit-react provides no UI: + +- **`WalletUiDropdown`** — connect/disconnect dropdown with wallet list +- **`WalletUiModal`** / **`WalletUiModalTrigger`** — wallet selection modal +- **`WalletUiList`** / **`WalletUiListButton`** — wallet list with icons +- **`WalletUiIcon`** / **`WalletUiLabel`** — wallet icon and name display +- **`WalletUiAccountGuard`** — conditional rendering based on connection status +- **`WalletUiClusterDropdown`** — cluster selector +- **`BaseDropdown`** / **`BaseModal`** — generic headless primitives (Zag.js) +- **`@wallet-ui/css`** / **`@wallet-ui/tailwind`** — optional Tailwind styling via `data-wu` attributes + +#### How wallet-ui would build on kit-react + +Wallet-UI is the simplest integration — its core state (Nanostores + contexts) maps directly to kit-react's hooks with no friction: + +```tsx +// Wallet-UI's provider builds a Kit client with the wallet plugin, then hands +// it to KitClientProvider and uses kit-react hooks instead of Nanostores. +function WalletUi({ config, children }) { + const chain = config.clusters[0].id; + const client = useMemo( + () => createClient() + .use(walletSigner({ chain })) + .use(solanaRpc({ rpcUrl: config.rpcUrlFor(chain) })), + [chain, config], + ); + return ( + + + {children} + + + ); +} + +// Wallet-UI's hooks become thin wrappers around kit-react +function useWalletUi() { + const wallets = useWallets(); + const connected = useConnectedWallet(); + const status = useWalletStatus(); + const connect = useConnectWallet(); + const disconnect = useDisconnectWallet(); + + return { + wallets, + wallet: connected?.wallet, + account: connected?.account, + connected: status === 'connected', + connect: (account: UiWalletAccount) => connect(account.wallet), + disconnect, + }; +} +``` + +The plugin's built-in persistence (`walletName:address` format) matches what wallet-ui already stores, so wallet-ui can use it directly — no need to disable and reimplement like connectorkit. The UI components (dropdowns, modals, wallet lists) remain wallet-ui's value-add, now built on kit-react's hooks instead of its own state layer. + +### wallet-adapter + +[`wallet-adapter`](https://github.com/anza-xyz/wallet-adapter) (`@solana/wallet-adapter-react`) is the most widely used wallet library in the Solana ecosystem. It's the API most React developers are currently familiar with. kit-react is not a drop-in replacement — it's built on Kit and wallet-standard instead of web3.js and the adapter pattern — but the mental model maps closely. + +#### API mapping + +| wallet-adapter | kit-react | Notes | +|---|---|---| +| `useWallet().wallets` | `useWallets()` | `UiWallet[]` (wallet-standard) instead of `Wallet[]` (adapter wrapper) | +| `useWallet().wallet` | `useConnectedWallet()?.wallet` | | +| `useWallet().publicKey` | `useConnectedWallet()?.account.address` | `Address` (string) instead of `PublicKey` (class) | +| `useWallet().connected` | `useWalletStatus() === 'connected'` | | +| `useWallet().connecting` | `useWalletStatus() === 'connecting'` | | +| `useWallet().select(name)` + `connect()` | `useConnectWallet()(wallet)` | One step instead of two | +| `useWallet().disconnect()` | `useDisconnectWallet()` | | +| `useWallet().sendTransaction(tx, conn)` | `useSendTransaction().send(instruction)` | Takes instructions, not pre-built transactions | +| `useWallet().signTransaction` | `useConnectedWallet()?.signer` + Kit signing | Or `useAction()` for state tracking | +| `useWallet().signAllTransactions` | `useConnectedWallet()?.signer` + Kit signing | | +| `useWallet().signMessage` | `useSignMessage()` | | +| `useWallet().signIn` | `useSignIn()` | | +| `useConnection().connection` | `useClient().rpc` | Kit client instead of web3.js `Connection` | +| `ConnectionProvider` | `.use(solanaRpc(...))` on the client | Composition in Kit, not React | +| `WalletProvider` | `.use(walletSigner(...))` on the client + `` | Single explicit React provider | +| `WalletModalProvider` / `useWalletModal` | Not provided | UI concern — use wallet-ui or connectorkit | +| `WalletMultiButton` | Not provided | UI concern | +| `useAnchorWallet()` | Not provided | Anchor-specific, buildable on `useConnectedWallet()` | +| Adapter packages (`PhantomWalletAdapter`, etc.) | Not needed | Wallet-standard handles discovery automatically | +| `onError` global handler | Not provided | Errors surface per-hook and via promise rejection — standard React patterns | + +#### Key differences developers will notice + +**Wallet-standard only.** wallet-adapter supports both the legacy adapter pattern (`new PhantomWalletAdapter()`) and wallet-standard; adapters are optional but the escape hatch is still there for wallets that haven't migrated. kit-react only supports wallet-standard — wallets register themselves, no per-wallet imports, and no legacy adapter fallback. The ecosystem has moved: all major wallets ship wallet-standard support, so the simpler surface is the right tradeoff. + +**No `select` + `connect` two-step.** wallet-adapter separates wallet selection from connection. kit-react's `useConnectWallet()` takes a `UiWallet` and connects in one call. The two-step pattern was an artifact of the adapter model where selection and connection were separate concerns. + +**No `publicKey`.** wallet-adapter developers are used to `wallet.publicKey` as the primary identifier. In kit-react it's `useConnectedWallet()?.account.address` — a string `Address` instead of a `PublicKey` class. This is a Kit-wide change. + +**Instructions, not transactions.** wallet-adapter's `sendTransaction` takes a pre-built `Transaction` + `Connection`. kit-react's `useSendTransaction` takes instructions — the plugin chain handles blockhash, fee payer, signing, sending, and confirmation. For cases that need manual transaction construction (sign-then-send, partial signing), `useAction` + Kit's signing primitives provide full control. + +**No bundled UI.** wallet-adapter ships `WalletMultiButton` and modal components that were a common pain point — hard to customize and didn't match app design systems. kit-react is headless. UI comes from wallet-ui, connectorkit, or the app's own components. + +**Granular hooks.** wallet-adapter puts everything on one `useWallet()` context — any wallet state change re-renders all consumers. kit-react splits into focused hooks (`useWallets`, `useWalletStatus`, `useConnectedWallet`, etc.) so components subscribe only to what they need. This is a tradeoff: wallet-adapter's one-hook API is easier for newcomers to learn (one import, one object, shallow surface), while granular hooks add a discoverability cost in exchange for finer re-render control. Apps that only render a connect button will barely notice the win; apps with many wallet-aware components (portfolio views, multi-account flows, per-account subscriptions) benefit substantially. `useWalletState()` is provided for callers who explicitly want the one-object shape. + +**No global error handler.** wallet-adapter's `onError` prop was a second error channel alongside thrown errors, which caused confusion about which path errors take. kit-react uses standard React patterns: hook-level errors (`useBalance().error`), promise rejection (`await connect(wallet)` throws on failure), and Error Boundaries for unexpected failures. + +### Before and after: Kit example React app + +The [Kit example React app](https://github.com/anza-xyz/kit/tree/main/examples/react-app) is a complete wallet/transaction app built directly on `@solana/kit` and `@solana/react` — without any higher-level library. It demonstrates what developers must build today. Comparing it to kit-react shows the boilerplate that kit-react eliminates. + +#### Provider setup + +**Today** — three hand-built contexts stacked together: + +```tsx +// ChainContextProvider: localStorage persistence, URL resolution per chain, fallback handling +// RpcContextProvider: manual createSolanaRpc() + createSolanaRpcSubscriptions(), useMemo +// SelectedWalletAccountContextProvider: localStorage sync object, wallet filtering + + + + + + + + +``` + +**With kit-react:** + +```tsx +import { createClient } from '@solana/kit'; +import { solanaDevnetRpc } from '@solana/kit-plugin-rpc'; +import { walletSigner } from '@solana/kit-plugin-wallet'; + +const client = createClient() + .use(walletSigner({ chain: 'solana:devnet' })) + .use(solanaDevnetRpc()); + + + +; +``` + +Chain context, RPC client creation, wallet persistence, and localStorage sync are handled by the single provider plus the underlying plugins. + +#### Wallet connection UI + +**Today** — ~100 lines of custom code: manually filter wallets by `StandardConnect` / `StandardDisconnect` features, build a menu with per-wallet submenus for account selection, compare accounts with `uiWalletAccountsAreSame()`, handle connect/disconnect errors, and manage a separate Sign In With Solana menu. + +**With kit-react:** + +```tsx +const wallets = useWallets(); +const connect = useConnectWallet(); +const disconnect = useDisconnectWallet(); +const connected = useConnectedWallet(); +// Build your UI with these — no feature filtering, account comparison, or state sync needed +``` + +#### Live balance + +**Today** — a custom `balanceSubscribe` function (~40 lines) that manually creates a `createReactiveStoreWithInitialValueAndSlotTracking` (a `ReactiveStreamStore`), manages `AbortController` lifecycle, bridges into SWR via `useSWRSubscription`, and tracks seen errors with a `WeakSet` to avoid duplicate dialogs. + +**With kit-react:** + +```tsx +const { data: balance, error, isLoading } = useBalance(address); +``` + +#### Transaction sending + +**Today** — three separate feature panels (sign & send, sign then send, partial sign), each 150–350 lines. Each manually: builds a form, converts SOL strings to lamports, fetches the latest blockhash, pipes together a transaction message with `setTransactionMessageFeePayerSigner` / `setTransactionMessageLifetimeUsingBlockhash` / `appendTransactionMessageInstruction`, manages a multi-state state machine (`'inputs-form-active' | 'creating-transaction' | 'ready-to-send' | 'sending-transaction'`), signs, sends, confirms, and manually calls `mutate()` to invalidate the SWR balance cache. + +**With kit-react** — the common case (sign & send) is one line: + +```tsx +const { send, status, data, error } = useSendTransaction(); +await send(getTransferInstruction({ source, destination, amount })); +``` + +Sign-then-send and partial signing use `useAction` to track each step independently: + +```tsx +const client = useClient(); + +// Plan → sign → review → send (three separate user-visible steps) +const { send: plan, data: message } = useAction( + (_signal, input) => client.planTransaction(input), +); +const { send: sign, data: signed } = useAction( + (_signal, msg) => signTransactionMessageWithSigners(msg), +); +const { send: sendSigned, status } = useAction( + (signal, tx) => sendAndConfirmTransaction(client.rpc, tx, { abortSignal: signal, commitment: 'confirmed' }), +); + +// Partial signing — sign with one signer, pass to another +const { send: partialSign, data: partiallySigned } = useAction( + (_signal, msg) => partiallySignTransactionMessageWithSigners(msg), +); +``` + +Balance invalidation is handled by the adapter's mutation hooks (`invalidateKeys` / `revalidateKeys`). + +#### Subscription management + +**Today** — the slot indicator component (~50 lines) manually creates a reactive store from `rpcSubscriptions.slotNotifications().reactiveStore()`, wires it into `useSyncExternalStore` with a custom subscribe/getSnapshot, and manages an `AbortController` in a `useEffect`. + +**With kit-react:** + +```tsx +const { data: slot } = useSubscription( + (signal) => client.rpcSubscriptions.slotNotifications(), + [client], +); +``` + +#### Summary + +| Area | Kit example (today) | kit-react | +|------|-------------------|-----------| +| Provider setup | 3 custom contexts, localStorage sync, manual RPC creation | 1 provider + plain `createClient().use(...)` composition | +| Wallet UI | ~100 lines, manual feature filtering | Hooks + your own UI | +| Balance | ~50 lines, SWR + reactive store + AbortController + WeakSet | `useBalance(address)` | +| Transaction (×3 types) | 150–350 lines each, manual state machines | `useSendTransaction()` | +| Subscriptions | Manual reactive store + useSyncExternalStore + AbortController | `useSubscription()` | +| Chain switching | Custom context + localStorage + URL resolution | Provider props | +| **Total custom code** | **~1,200 lines** | **Focus on app-specific logic** | + +The Kit example app is well-written — the complexity is inherent to building on low-level primitives. kit-react absorbs that complexity into reusable hooks and providers so developers can focus on their app. \ No newline at end of file