Add @solana/kit-react core package#205
Conversation
First drop of `@solana/kit-react`, a thin `useSyncExternalStore` layer over Kit. Ships providers for composing a client, named live-data hooks (`useBalance`, `useAccount`, `useTransactionConfirmation`), generic data hooks (`useLiveData`, `useSubscription`, `useRequest`), transaction and action hooks, and signer readers — all typed against the plugins installed on the client. Chain is optional and orthogonal to the client: chain-specific RPC providers publish it implicitly, or apps can mount `<ChainProvider>` directly. `useChain()` throws if nothing has published one, which surfaces misconfiguration at mount time instead of at runtime. Includes the `react-lib-spec.md` RFC at repo root for review; it'll be removed once the design is settled. A `src/kit-prereqs/` shim module stands in for unreleased Kit primitives (`ReactiveStreamStore`, `ReactiveActionStore`, `.reactiveStore()` on pending requests) and gets deleted in a follow-up once Kit ships them. Out of scope: `/wallet` subpath, SWR/TanStack adapters, async-plugin support.
🦋 Changeset detectedLatest commit: 5ea85af The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
trevor-cortex
left a comment
There was a problem hiding this comment.
Summary
Adds the new @solana/kit-react package: a thin React layer over Kit that wires providers (root client, chain, plugin, RPC connection, full RPC, LiteSVM, payer/identity/signer) and hooks (useClient / useChain / useClientCapability, reactive primitives useAction / useRequest / useSubscription / useLiveData, and the higher-level useBalance / useAccount / useTransactionConfirmation / useSendTransaction(s) / usePlanTransaction(s) / usePayer / useIdentity). State machines live in Kit; the package glues them to useSyncExternalStore and handles SSR, disabled (null) queries, abort-on-supersede, and slot-based dedup.
Shape is clean and consistent — every live hook returns the same LiveQueryResult envelope, every action hook the same ActionResult, and the null-gate disabled story is wired through both stream and action paths via sentinel symbols. Nice. Docblocks, README, and changeset are all present and coherent.
One important note for reviewers: the PR intentionally ships against unmerged Kit PRs (kit#1550/#1552/#1553/#1555) via the src/kit-prereqs/ shim folder. That folder is clearly documented and has a deletion plan tied to each upstream PR — fine for a draft, but worth double-checking on the day you flip over that the upstream signatures match what kit-react consumes (particularly PendingRpcSubscriptionsRequest.reactiveStore({ abortSignal }) which the sync shim never actually re-implements — see below).
Things worth a second look
LiteSvmProvidermemo dependency list is incomplete — onlyconfig.transactionConfigis listed, so otherLiteSvmConfigfields (sanstransactionConfig) won't rebuild the plugin when they change. The sibling RPC providers enumerate every field. Inline comment below.useSubscription/useRequestdon't wire the shim bridges — both callpending.reactiveStore(...)directly, butkit-prereqsexportsreactiveStoreFromPendingRequest/reactiveStoreFromPendingSubscriptionsRequestwithout them being referenced anywhere insrc/. Today, neither hook works against current-Kit pending objects (the.reactiveStoremethod lands with kit#1553 / kit#1555). That matches "depends on open Kit PRs" in the description, but the shims themselves are effectively dead code — worth either wiring them up as a bridge until the Kit PRs merge, or deleting them fromkit-prereqs/index.tsso it's clearuseSubscription/useRequestdon't have a fallback path today.useSyncExternalStoreordering inusePayer/useIdentity— the hook subscribes first and checks'payer' in clientsecond. It works (the throw happens unconditionally after all hook calls, so hook order is stable), but it's a bit surprising on first read. Inline comment.useAccount's decoder param is part ofuseLiveDatadeps — if consumers pass an inline decoder literal (useAccount(addr, getMintDecoder())), they'll churn the store on every render. The dev-mode identity-churn warning only fires onPluginProvider'spluginsprop; might be worth either extending the warning into the live-data hooks or calling this out explicitly in theuseAccountREADME snippet. Not a blocker.
Notes for subsequent reviewers
- Kit prereqs: the implementations in
src/kit-prereqs/are essentially redeclarations of the upstream PR contracts; worth cross-referencing against each linked Kit PR (particularly theReactiveStreamStoreshape —getState+getError+getUnifiedStatecoexisting is deliberate per the spec but unusual). - SSR path:
IS_LIVE_CLIENT = __BROWSER__ || __REACTNATIVE__. Node builds (including the vitestnodeproject) get the permanently-loading fallback — that's why a number of tests are gated on__BROWSER__ || __REACTNATIVE__. Confirm the vitest configs for browser / RN exercise the live paths. - Abort semantics:
createReactiveActionStoreaborts the previous controller on every freshdispatch, anduseActionthreads that into the user'sfn(signal, ...args). Worth double-checking theuseSendTransaction/usePlanTransactionadapters actually pass the signal through — they do ({ ...config, abortSignal: signal }), but note that if a caller passes their ownabortSignalinconfig, it gets overridden silently. That's documented behaviour per the spec; just flagging. - Test coverage: providers, capability checks, null-gates, live emissions, and action supersede are all covered. I didn't see a test for
usePayer/useIdentityreturningnullwhen the client's getter throws (readOptional's try/catch path); worth adding if wallet plugins that throw on disconnected state are a real scenario. - Bundle shape: the
package.jsonexportsmap,browserfield, and browserslist look right for a Node/browser/RN triple-bundle. The Prettier ignore, tsup config, and vitest project config all match the sibling plugin packages.
trevor-cortex
left a comment
There was a problem hiding this comment.
Re-review
Looks good — the substantive points from my prior pass are addressed:
use-request/use-subscriptionnow wire the kit-prereqs shims, with a typed union (ReactiveActionSource/ReactiveStreamSource) that accepts both a native.reactiveStore()and the currentPendingRpc(Subscriptions)Requestshape, falling back toreactiveStoreFromPending(...). Both branches carry aTODO(kit#...)for the eventual collapse. Clean bridge — this was the biggest gap last time and it's the right fix.useAccountnow callsuseIdentityChurnWarning(decoder, ...)with a good remedy string pointing at module-level memoization. Exactly what I wanted.readOptionalthrowing-getter path has test coverage in bothusePayeranduseIdentity, including full null → value → null cycles for the reactive-subscribe case. Nice.- Four RPC providers now share
useRpcConfigPlugin, eliminating the dep-list duplication — addresses the refactor suggestion I made last time.
Retracting one thing from my prior review
I flagged LiteSvmProvider's memo dep list ([config.transactionConfig]) as incomplete. That was wrong — LiteSvmConfig today only has transactionConfig, so the dep list is exhaustive. Apologies for the noise. Same drift hazard as the shared helper note below if it ever grows fields, but nothing to change now.
One small note for maintainers, not blocking
useRpcConfigPlugin closes over config via build and has an eslint-disable for exhaustive-deps, relying on the explicit seven-field key list to track churn. That's fine today, but if a new field ever lands on SolanaRpcConfig it'll need to be added here too or the provider will silently stop rebuilding on that field. A short comment on the key list calling that out — or a type-level satisfies Record<keyof SolanaRpcConfig<string>, unknown> guard on a sibling object keyed by the same fields — would make future drift loud rather than silent. Same structural hazard applies to LiteSvmProvider if its config grows.
No blockers from me. The remaining shape (Kit prereqs, draft status, async plugins follow-up) is as described in the PR body — would approve if I had the permission to.
… the KitClientProvider
|
@trevor-cortex Please review based on the updated design |
trevor-cortex
left a comment
There was a problem hiding this comment.
Summary
Major redesign — and a real improvement. The provider stack collapses from many specialized providers (KitClient/Plugin/Chain/Rpc{,Connection}/LiteSvm/Signer/Payer/Identity) down to a single KitClientProvider, with client composition pushed entirely into Kit-land via createClient().use(...). Async plugins are now handled via Promise<Client> + <Suspense> rather than a separate sync-only mode. The signer hooks lean on a subscribeTo<Capability> duck-type convention so wallet plugins, static signer plugins, and any future reactive signer plugin all participate without kit-react naming specific plugin types. Net: fewer concepts to learn, escape hatches are uniform, and the bindings stay genuinely thin.
A usePromise indirection (React 19's native React.use with a thrown-promise shim fallback for React 18) cleanly hides the Suspense wire protocol from KitClientProvider. Hook bodies are unchanged from the prior pass — useAction / useRequest / useSubscription / useLiveData and the higher-level named hooks still wrap Kit's ReactiveActionStore / ReactiveStreamStore with the same LiveQueryResult / ActionResult envelopes.
Prior-review follow-ups — addressed
- ✅ The shim bridges (
reactiveStoreFromPendingRequest/reactiveStoreFromPendingSubscriptionsRequest) are now wired inuse-request.tsanduse-subscription.ts, each with aTODO(kit#…)comment marking the eventual collapse. - ✅
useAccountfiresuseIdentityChurnWarningon thedecoderarg with a remedy that points at module-scope memoization. README also calls it out explicitly. - ✅
usePayer/useIdentitycapability check now precedes theuseSyncExternalStorecall (not the prior surprising order), and both have full coverage including the throwing-getter path and a null → value → null disconnect/reconnect cycle. - ✅ Per-provider memo dep-list duplication concern is now moot — the providers it applied to (
RpcProvider,RpcConnectionProvider,LiteSvmProvider, etc.) are gone. Composition lives in Kit.
Things worth a second look
-
Suspense resolution-and-retry is unverified end-to-end.
client-context.test.tsxcorrectly notes that happy-dom + vitest don't flush React 19's Suspense scheduler after a settled promise — even nativeReact.use(promise)reproduces the hang. So we have: (a) a unit-tested shim contract (pending throws / fulfilled returns / rejected re-throws), (b) a unit-tested suspend-half (throws → fallback renders), and (c) React's own contract for the retry. The composition isn't exercised in CI. That's acceptable, but it means a regression at the React-version boundary (e.g. a strayuseMemoslipping intoKitClientProviderthat breaks the React 18 fallback path) wouldn't be caught. Worth either a minimal jsdom-based integration test using a controlled promise + manualact(async () => { await promise; })flush, or — at minimum — a CI matrix run against React 18 to exercise the shim path. The current test suite is React 19 only as far as I can see. -
clientprop identity stability has no dev-mode safety net. The README is clear that theclientprop must be stable across renders (module-scope oruseMemo). FordecoderinuseAccount, you wireduseIdentityChurnWarningto flag inline-allocation footguns. The same footgun exists at the root:<KitClientProvider client={createClient().use(...)}>inline in a component will silently churn every render — no warning, the children just keep remounting. Worth firing the same churn warning on the resolved client insideKitClientProvider. Inline comment below. -
useChain()is unconditionally throwy. Reasonable default — if you call it, you presumably need it. But for a read-only dashboard that never touches a chain, a hook deep in some shared component might calluseChain()for telemetry / display and explode. The error message is clear, but consider exposing auseOptionalChain(): ChainIdentifier | nullvariant to make the "display if available" pattern explicit withouttry/catch. Not blocking — just an ergonomic gap. -
isPromiseLikeduck-types on.then. Fine in practice — Kit clients are built viaextendClientand never install a.then. The realm-crossing fallback comment is on point. One edge: if a consumer wraps a client in aProxythat intercepts.then(extremely unlikely, but real for some debugging proxies), the runtime check would misidentify it as a promise. Worth theinstanceofcheck first (which it already is) — the duck-type fallback is the only path that could trip, and only across realms. Note for posterity, not action.
Notes for subsequent reviewers
- Conditional
usePromisecall inKitClientProvider. Looks like a Rules of Hooks violation at first glance (isPromiseLike(client) ? usePromise(client) : client), but it isn't.usePromiseis a thin indirection overReact.use(which is explicitly conditional-call-safe in React 19) and the shim that only throws — neither tracks per-component state. Calling it conditionally is fine. That said, if a caller swaps theclientprop's type betweenClientandPromise<Client>at runtime, the user-facing behavior (suspends mid-tree, then resolves) is correct but worth confirming with a manual test. shimUsePromisecache lifecycle. TheWeakMap<Promise, State>is keyed on promise identity. Stale entries can't accumulate — when the promise is GC'd, the entry goes with it. The unit tests cover pending / fulfilled / rejected / multiple promises. Solid.useClientCapabilitycapability iteration.'rpc' in clientchecks both own and inherited keys. Plugin authors who useextendClientshould be fine since extensions are own-properties; if someone hand-rolls a client via prototype chain it'd still work. Mention only.- Test helper
Providers. Each<Providers extensions={...}>mount builds a fresh client viauseMemo([extensions]). Tests that pass an inline extensions object literal will get a fresh client every render — fine forusePayerstatic tests, but worth keeping in mind if you ever add a test that asserts client identity stability across re-renders. - README. Comprehensive, accurate, and the disposal section is a particularly good addition — explicit about when
Symbol.disposematters (per-request SSR clients, dynamic rebuild) versus when it doesn't (long-running SPAs). - Bundle / package shape.
exportsmap is React 18+19 compatible, browser/node/RN triple-bundle is preserved,peerDependenciescorrectly pins both React majors.@solana/subscribableas a runtime dep is new vs the prior pass — check that it's actually pulled in by something the bundle ships (didn't see it referenced in the diffs I read; might be an indirect transitive).
I'd APPROVE if I had the permission for it on this repo — submitting as COMMENT instead. The two real points worth tracking are the Suspense integration coverage gap (#1) and the missing churn warning on client (#2); everything else is discussion or future ergonomics.
| function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> { | ||
| if (value instanceof Promise) return true; | ||
| // Handle realm-crossing promises (a `Promise` instantiated in a different | ||
| // global, e.g. an iframe or a test runner that swaps globalThis) by |
There was a problem hiding this comment.
Consider firing useIdentityChurnWarning on client (and possibly chain) here — same rationale as the decoder warning in useAccount. The README is clear that client must be stable across renders, but inline <KitClientProvider client={createClient().use(...)}> will silently churn every render today with no dev-time signal. The footgun is at the root rather than at a leaf, so a user who hits it will see remount-storms across their entire tree.
Something like:
useIdentityChurnWarning(client, {
argName: 'client',
hookName: 'KitClientProvider',
remedy: 'Build the client at module scope or wrap it in `useMemo`. The provider does not own client lifecycle.',
});Skip when client is a promise (different identity contract — the promise itself must be stable, the resolved value is whatever it resolves to).
| const tree = <ClientContext.Provider value={resolved}>{children}</ClientContext.Provider>; | ||
| return chain ? <ChainContext.Provider value={chain}>{tree}</ChainContext.Provider> : tree; | ||
| } | ||
|
|
||
| function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> { |
There was a problem hiding this comment.
Minor: tree allocates a new <ClientContext.Provider> element on every render even when neither resolved nor children changed. Context bails on equal value, so the perf cost is bounded to a createElement call per render — but if you ever wanted to add the same JSX pattern in a hot path, this is the kind of place to reach for useMemo. Not worth changing here; just flagging the pattern.
| export function shimUsePromise<T>(promise: Promise<T>): T { | ||
| let state = cache.get(promise) as PromiseState<T> | undefined; | ||
| if (!state) { | ||
| state = { status: 'pending' }; | ||
| cache.set(promise, state as PromiseState<unknown>); | ||
| 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; |
There was a problem hiding this comment.
Worth a brief note in the docblock that the shim is only safe to invoke from inside a render that's wrapped by a <Suspense> boundary — without one, the thrown promise propagates to the nearest error boundary (or crashes the tree). The KitClientProvider JSDoc says this for the user, but shimUsePromise is exported for tests, and a future internal caller that grabs it without reading the surrounding context would get an unfriendly failure mode.
| 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), |
There was a problem hiding this comment.
Nit: the decoder is in the dep list, which is correct, but the rebuild it triggers cascades all the way down to createReactiveStoreWithInitialValueAndSlotTracking — meaning a churning decoder doesn't just rebuild the React-side store, it tears down the live RPC subscription. That's the right semantic (different decoder = different mapped value type), but combined with the useIdentityChurnWarning it's worth one extra sentence in the warning's remedy: "…otherwise the underlying account subscription will be torn down and re-opened on every render." Makes the cost concrete.
| 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'); | ||
| }); |
There was a problem hiding this comment.
Nit: this test only asserts that usePromise is callable, which is essentially a typeof check on a module export. Consider either dropping it or strengthening it — e.g. assert that on React 19 it's referentially equal to React.use, and on React 18 it's shimUsePromise. That'd actually catch a regression in the runtime detection (e.g. a stray bundler shim that returns undefined for React.use).
|
Closing - this is mostly moving to Kit |

This PR adds react bindings for Kit Plugins, building on the reactive types added to Kit. This is basically
useSyncExternalStoreover Kit's reactive types, in React-friendly wrappers.Note that it depends on open Kit PRs as well as the stack of kit-plugin PRs below it. In order to build and test this, we have a temporary kit-prereqs directory with shims for these. These will disappear when Kit PRs merge. I'll keep this PR in draft until then, and reviewers can ignore kit-prereqs. This PR description will describe Kit with all those PRs merged for clarity.
This PR only adds the core of kit-react. Functionality for the wallet plugin, and adapters for cache libraries, are not included yet. The PR includes the spec, which also describes these.
Note that this probably belongs in Kit, not kit-plugins. It's useful to have the draft PR here, and then we can stack a PR adding a react subpath to the wallet plugin, to validate the API. Everything here probably lands in Kit though.
The real goal here is that the React bindings are genuinely thin - state machines and fiddly logic are implemented in Kit, this just wraps them in React APIs. The library is also client-first: consumers build the Kit client themselves with
createClient().use(...)and hand it to a single React provider. Plugin composition belongs in Kit, not in the React tree.We have a single React provider:
KitClientProvideris the only provider. It accepts aclientprop (orPromise<Client>for async plugins) and an optionalchainprop, and publishes both to the subtree. The provider does no composition, lifecycle management, or disposal — that all belongs to the caller. Whenclientis a promise, the provider suspends via the nearest<Suspense>boundary; on React 19 this uses nativeReact.use(promise), on React 18 a small thrown-promise shim provides the same contract.We have a few hooks related to the provider:
useClient<T>to get the client,useClientCapabilityto do mount-time checks for required capabilities (with a typed missing-plugin error),useChainto get the chain if configured.Between client-first composition and
useClientCapability, apps can use arbitrary plugins without requiring specific react bindings to be published for them. Plugins can optionally ship them as a value add — the convention is a/reactsubpath on the plugin package, peer-depending on@solana/kit-react(or wherever this lands) foruseClientCapability+useActionetc. A follow-up PR adding the wallet plugin's react functionality will demonstrate this, and will be the validation suite for this API before kit-react eventually moves into the Kit monorepo.The other hooks are wrappers around Kit's react primitives.
Kit now has
ReactiveActionStorewhich provides a reactive state for any async function, andReactiveStreamStorewhich provides a reactive state for any async subscription.kit-react provides four basic hooks based on these stores (examples simplified for brevity):
useAction((signal) => signer.signMessage("cool!", { abortSignal: signal })): user-triggered actions backed byReactiveActionStoreuseRequest(() => client.rpc.getEpochInfo()): async requests dispatched on mount, backed byReactiveActionStore. The factory requires a.reactiveStore(): ReactiveActionStorefunction, which is being added to Kit's RPC methods.useSubscription(() => client.rpcSubscriptions.slotNotifications()): subscriptions backed byReactiveStreamStore. Similarly, just requires a.reactiveStore(): ReactiveStreamStorefunction, provided by Kit RPC subscription methods.useLiveData: Subscriptions that combine an RPC request with an RPC subscription and always provide the latest data from either source. Backed byReactiveStreamStore, using Kit's createReactiveStoreWithInitialValueAndSlotTrackingWhile we intentionally do not provide eg a specific named hook for every RPC request (they'd be one-liners around
useRequest), we do provide some higher-level hooks built on these primitives +useClientCapability.useBalance(address),useAccount(address, decoder?)anduseTransactionConfirmationall combine a specific RPC and subscription inuseLiveDatato provide functionality commonly required by apps.useSendTransaction(s)andusePlanTransaction(s)provideuseActionwrappers around the transaction planning/sending plugins. Plugin-specific functions would generally look very similar to this, with the logic belonging to the plugin and not to react.usePayeranduseIdentityreadclient.payer/client.identityreactively via thesubscribeTo<Capability>convention - they're capability-agnostic, so wallet plugins, static signer plugins, and any future reactive signer plugin all participate without kit-react naming specific plugin types.Notes:
@solana/kit's dep tree). I want to validate the API by building the wallet plugin's React bindings against it first, so the move is sequenced after that.createClient().use(asyncPlugin)returnsPromise<Client>, hand it toKitClientProvider, mount a<Suspense>above. No special async mode in kit-react itself.kit-reactas the package name. I think we should probably land this in@solana/reactin the Kit monorepo. We'd deprecate the existing functions there, which are superseded by the combination of this and the wallet plugin react bindings.