Update the react-app example to use the wallet plugin - #1825
Conversation
🦋 Changeset detectedLatest commit: 64ea156 The changes in this PR will be included in the next version bump. This PR includes changesets to release 48 packages
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 |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
| // Every wallet from `useWallets()` is pre-filtered by the plugin to those that support | ||
| // `standard:connect` on the active chain, so every item rendered here is connectable. |
There was a problem hiding this comment.
Flagging this: the previous implementation displayed wallets that cannot be connected to, eg because they don't support the selected chain or don't have the StandardConnect feature. The plugin filters wallets to only those that support the selected chain and have the StandardConnect feature.
It is not possible, using only the plugin, to display wallets that have been filtered out. An app could do this by using the wallet-standard libraries directly, as the previous implementation did, but I haven't included that here.
In general I think this is the right default - the app only gets wallets that it can connect to.
Opened an issue in kit-plugins to add the filtered wallets if we want to bring this ability to display them back: anza-xyz/kit-plugins#313
| onSignIn(); | ||
| } catch (e) { | ||
| // Filter out abort error, which just means a later action superseded | ||
| if (!isAbortError(e)) { |
There was a problem hiding this comment.
Example of the app using isAbortError to avoid throwing when an action is superseded, which previously required an install of @solana/promises.
| // Render-time capability guard: throws so the surrounding `ErrorBoundary` renders | ||
| // `FeatureNotSupportedCallout` when the connected account can't sign transactions | ||
| // Also narrows the signer for the `useAction` below | ||
| assertCanSignTransactions(signer); |
There was a problem hiding this comment.
Note that this is a bit different from the previous version, which used hooks like useWalletAccountTransactionSigner.
The app now receives a signer: TransactionSigner, and needs to check for (in this case) it having the partial sign feature. It does this by using an assertion which narrows the type. This is just a wrapper around functions we have in the signers package, but the app handles a null signer and has its own errors.
This assert throws if false, like the previous useWalletAccountTransactionSigner hook did, rendering the error boundary. But apps can also just branch on the signer type, etc, depending what makes sense for them.
I'd also note that this example is a bit arbitrary - the instruction-plan and signer APIs offer higher-level helpers and mean apps generally don't need to know exactly which features are available.
| getTransferSolInstruction({ | ||
| amount, | ||
| destination: address(recipientAccount.address), | ||
| source: transactionSigner, | ||
| source: signer, | ||
| }), | ||
| m, | ||
| ), |
There was a problem hiding this comment.
Just to highlight that this transaction construction and program instruction code will disappear in a future iteration that uses the instruction plans plugin and program plugins. This PR intentionally only adds wallet, and leaves everything RPC/transaction building/etc alone.
| chain: SolanaChain; | ||
| displayName: string; | ||
| setChain?(chain: `solana:${string}`): void; | ||
| setChain?(chain: SolanaChain): void; |
There was a problem hiding this comment.
Not new in the plugin, but wallet-standard exports a SolanaChain type that we use throughout
| * `whenReady()` resolves and the continuation runs — the guard is what keeps the disposed client | ||
| * from being published. | ||
| */ | ||
| export function WalletClientProvider({ children }: Props) { |
There was a problem hiding this comment.
This is the biggest red flag from this exercise
This is intentionally poking at an advanced use case, but it is realistic. When we change the chain, we build a new client (wallet plugin uses chain), but we avoid re-rendering using it until it is ready, using the next.wallet.whenReady construct. This means that the UI doesn't flash an unconnected state, and if the connected wallet remains valid on the new chain then there's no UI churn.
The rest of this is all juggling react lifecycle and the dispose mechanism.
My plan to fix this:
- Add
withWhenReadyas a function that plugins can use to register awhenReadypromise. Currently only the wallet needs this. In a similar way to dispose, we will internally expose awhenReadyon the client, that combines (usingPromise.allSettledin this case) all registered promises from all plugins. The result is that an app will only needclient.whenReadyregardless which plugins are used - Given that, this
WalletClientProvidercan be generalised to aManagedClientProviderthat takes a function to construct the client, and then handles theclient.whenReadyfor you. Nothing wallet specific there. I will add that to the@solana/reactpackage as a new provider. - After that, apps that don't need to change their client will just use
ClientProvideras they currently do. Apps that need to handle the client changing, and want to be able to wait until the new client has settled to re-render with it, will useManagedClientProvider. All this complexity will disappear from the app while achieving the same UX.
There was a problem hiding this comment.
I've reconsidered this design in ee8d403
New design, app changes:
- The client provider now immediately creates and updates the client, does not wait for
whenReady. All the complexity is gone, except dispose - The UI now uses the hooks from the wallet plugin to granularly disable/dim UI that depends on the wallet state when it's in a not ready state
I am no longer suggesting standardising whenReady in Kit, and ManagedClientProvider may not be worth building - maybe for dispose. Everything we have in the wallet plugin remains, as it enables the UI to react to the readiness.
The issue with this design shows as we add more to the client - we want to immediately have access to eg the new RPC when the chain changes. UI that only depends on the RPC should not wait for the wallet to reconnect before updating. I don't have any examples of other plugins that would need this yet, but if there was one then UI that depends only on the wallet shouldn't wait for that plugin either. A client-level whenReady doesn't make sense IMO.
The app-level complexity remaining is that I have a hook useDisplayedWallet which captures the last seen wallet.connected state, and returns that alongside an isStale based on the wallet ready hook. This enables the app to display the previous connected wallet in a disabled/dimmed state during the reconnect. We could improve this by exposing the UiWalletAccount that is being reconnected to from the plugin when it is available, which would provide a better first load UX (the app can't currently know it until it's connected), and would avoid the app needing to store it in this hook. Captured as a plugin issue: anza-xyz/kit-plugins#350
Example of the current UX using a deliberately slowed down wallet reconnect path:
react-app-chain.mp4
| const STORAGE_KEY = 'solana-wallet-standard-example-react:selected-wallet-and-address'; | ||
| const stateSync = { | ||
| deleteSelectedWallet: () => localStorage.removeItem(STORAGE_KEY), | ||
| getSelectedWallet: () => localStorage.getItem(STORAGE_KEY), | ||
| storeSelectedWallet: (accountKey: string) => localStorage.setItem(STORAGE_KEY, accountKey), | ||
| }; |
There was a problem hiding this comment.
All this goes away, the wallet plugin handles persisting the connected wallet (using localStorage by default)
8931118 to
3fb47c3
Compare
BundleMonUnchanged files (150)
No change in files bundle size Final result: ✅ View report in BundleMon website ➡️ |
BundleMonUnchanged files (150)
No change in files bundle size Final result: ✅ View report in BundleMon website ➡️ |
|
Documentation Preview: https://kit-docs-ex7erm46g-anza-tech.vercel.app |
trevor-cortex
left a comment
There was a problem hiding this comment.
Summary
Migrates the examples/react-app demo from @wallet-standard/react (useSelectedWalletAccount, useConnect, useDisconnect, useWalletAccount*Signer) to the @solana/kit-plugin-wallet model — a Kit Client built with the walletSigner plugin, published via ClientProvider, and consumed through the plugin's React hooks (useConnectedWallet, useConnect, useDisconnect, useSelectAccount, useSignIn, useSignMessage, useWallets, WalletReadyGate). Also bumps @wallet-standard/ui → ^1.0.3 and @wallet-standard/ui-registry → ^1.1.1 in packages/react so a consumer that mixes @solana/react with @solana/kit-plugin-wallet resolves a single, shared registry singleton (well-explained in the changeset).
The example ports feature-for-feature: connect/disconnect/sign-in dropdowns, sign message / sign transaction / sign-and-send / partial sign panels, balance, chain switching, and the recipient picker. The former SelectedWalletAccountContextProvider + localStorage sync is replaced by the plugin's own connected-account state, and UnconnectableWalletMenuItem/ErrorBoundary wrapping is dropped because useWallets() already filters to wallets that support standard:connect on the active chain.
A new WalletClientProvider handles the chain-switch client rebuild (each wallet plugin is chain-bound) with hand-over-hand disposal and a whenReady() handoff so mid-switch the old client stays on screen. WalletReadyGate gates only the wallet-dependent UI so the chrome stays visible during the initial warm-up. Feature panels now call render-time assertCan… guards (walletCapability.ts) that throw into the existing FeatureNotSupportedCallout ErrorBoundary, replacing the previous static feature-list checks in the wallet-menu items.
Overall the migration is clean and the tricky bits (client lifecycle, StrictMode double-invoke, whenReady() deadlock avoidance, optimizeDeps for the linked workspace) are all called out in comments. This is a good reference for how the plugin is meant to be consumed from a React app.
Key things to watch out for
WalletClientProviderlifecycle. Worth re-reading the docblock and effect carefully — the invariants (who owns disposal at each point, whycancelledis load-bearing, why the whenReady continuation can't own disposal) are all correct as far as I can tell, but this is the kind of code that's easy to break in a follow-up. Suggest keeping the current comments verbatim as a regression guard, and considering a small test in a later PR that exercises the chain-switch and StrictMode double-invoke paths — an example app doesn't have to have one, but this provider is subtle enough that it might be worth extracting for reuse eventually.- Draft / follow-ups. PR description flags (a) the pending bump to
@solana/kit-plugin-wallet@0.14.0(currently open onkit-plugins) will require a small refactor, and (b) future iterations will add thesolanaRpcand system-program plugins to the client. That's fine for a draft, just wanted to surface it here so it isn't lost. @wallet-standard/ui-registrybump in@solana/react.^1.0.1→^1.1.1is a minor bump, shipped as apatchchangeset. The changeset justifies it as a backward-compatible superset (registry is a runtime singleton and this de-duplicates the shared instance), which reads reasonably to me — but flagging for a second pair of eyes since this is the only user-facing change in a published package.packages/reactstill depends on@wallet-standard/react. The example app dropped it, butSelectedWalletAccountContextProvider/selectedWalletAccountContextare still exported from@solana/reactand still use it, so this is intentional. Just noting in case a follow-up wants to prune those exports now that the recommended flow no longer needs them.
Notes for subsequent reviewers
- The
ErrorBoundaryaround each feature panel is now doing double duty: it still catches thrown-from-render errors from the Kit signer hooks, and it now also catches the newassertCan…throws fromwalletCapability.ts. The message the user sees inFeatureNotSupportedCalloutshould be checked once against a wallet that doesn't support each feature (e.g. a wallet withoutsolana:signMessage, or a sending-only wallet against the "Sign Transaction" panel) to make sure the copy matches expectations. - The
SignInMenuused to wrap each menu item in anErrorBoundary→UnconnectableWalletMenuItem. That safety net is gone; sign-in failures now surface throughonError→ErrorDialog. That's a behaviour change worth verifying — sinceSolanaSignInis a per-feature filter (not a per-chain connect filter), any wallet that advertises the feature but errors on invocation should reachonErrorcorrectly, but a render-time throw fromuseSignIn()would now propagate. In practice it shouldn't happen, but worth a manual smoke test. - Manually verifying under StrictMode is worth doing: the
WalletClientProvidercomment describes the StrictMode double-invoke path, and it's the most likely place for a subtle regression to hide.
| return ( | ||
| <ErrorBoundary | ||
| fallbackRender={({ error }) => <UnconnectableWalletMenuItem error={error} wallet={wallet} />} | ||
| <SignInMenuItem | ||
| key={`wallet:${wallet.name}`} | ||
| > | ||
| <SignInMenuItem | ||
| onSignIn={account => { | ||
| setSelectedWalletAccount(account); | ||
| setForceClose(true); | ||
| }} | ||
| onError={setError} | ||
| wallet={wallet} | ||
| /> | ||
| </ErrorBoundary> | ||
| onSignIn={() => setForceClose(true)} | ||
| onError={setError} | ||
| wallet={wallet} | ||
| /> | ||
| ); |
There was a problem hiding this comment.
Behaviour change worth double-checking: previously each SignInMenuItem was wrapped in an ErrorBoundary that fell back to UnconnectableWalletMenuItem, so a wallet that advertised solana:signIn but crashed at hook-init render time still rendered as a disabled item rather than tearing down the menu. That safety net is gone here. Sign-in invocation errors still surface through onError → ErrorDialog (good), but a render-time throw from useSignIn() would now propagate up. In practice useSignIn() from the plugin shouldn't throw at render, so this is probably fine — flagging so it's an explicit decision rather than an accidental regression.
There was a problem hiding this comment.
This is intentional - the plugin version doesn't call the sign-in function at render, only when the action is dispatched. So there's no render-time failure path here
| if (!signer || !(isTransactionModifyingSigner(signer) || isTransactionPartialSigner(signer))) { | ||
| throw new Error('This account does not support signing transactions'); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Worth a passing note in the docblock (or here) that a TransactionSendingSigner also satisfies TransactionSigner structurally (a modifying signer that can also send), but this assert deliberately narrows to partial-or-modifying-only because the "Sign Transaction" / "Partial Sign Transaction" panels need to obtain the raw signed transaction bytes rather than have the wallet send it. The current docblock says "deliberately fails this assert" which is right — just calling out that the reason is UX-level (panel semantics), not that a sending signer isn't a valid TransactionSigner. Not blocking, but the comment might read more clearly if it said e.g. "…because those panels need the signed transaction bytes, not a submitted signature".
| optimizeDeps: { | ||
| // `@solana/react` is a linked workspace package, so Vite excludes it from dependency | ||
| // pre-bundling by default. When Vite then pre-bundles the npm dependency | ||
| // `@solana/kit-plugin-wallet` (which imports `@solana/react`), esbuild inlines a *second* | ||
| // copy of `@solana/react`, which causes mis-matched context. | ||
| // Forcing `@solana/react` to be pre-bundled makes it a single shared copy | ||
| // that both the app and the plugin reference. | ||
| // This is only an issue in our example because of the linked workspace. |
There was a problem hiding this comment.
Great comment — this is exactly the kind of thing that would burn hours to rediscover. One small suggestion: consider mentioning that the two-copies-of-@solana/react symptom typically surfaces as a context mismatch at runtime (e.g. useClient() from one copy reading null even though ClientProvider from the other copy is mounted above it), so a future reader hitting that symptom can search for it and land here. Optional.
3fb47c3 to
5abe375
Compare
5abe375 to
78c7d28
Compare
There was a problem hiding this comment.
Pull request overview
This PR updates the examples/react-app sample to use the @solana/kit-plugin-wallet client/plugin model (including its React hooks), and aligns @solana/react’s wallet-standard UI dependencies to avoid multiple incompatible wallet-standard UI registry singletons when consumers also depend on the wallet plugin.
Changes:
- Switch the react-app example from
@wallet-standard/react/@solana/reactwallet selection to@solana/kit-plugin-wallet(client + hooks +WalletReadyGate). - Add a wallet-capability assertion layer (
walletCapability.ts) to gate feature panels via render-time asserts andErrorBoundaryfallbacks. - Bump
@solana/react’s@wallet-standard/ui/@wallet-standard/ui-registryversions and update lockfile accordingly (with a changeset).
Reviewed changes
Copilot reviewed 25 out of 26 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Locks new wallet plugin dependency for the example and updates wallet-standard/ui versions across the workspace. |
| packages/react/package.json | Bumps @wallet-standard/ui and @wallet-standard/ui-registry to match wallet plugin expectations. |
| examples/react-app/package.json | Adds @solana/kit-plugin-wallet and migrates example dependencies from @wallet-standard/react to @wallet-standard/ui. |
| examples/react-app/vite.config.ts | Forces pre-bundling @solana/react to avoid duplicated linked-workspace copies when prebundling the wallet plugin. |
| examples/react-app/src/context/WalletClientProvider.tsx | Introduces a client provider that rebuilds/disposes the Kit client on chain changes with wallet plugin installed. |
| examples/react-app/src/context/ChainContext.tsx | Refines chain typing to SolanaChain for wallet-standard compatibility. |
| examples/react-app/src/walletCapability.ts | Adds render-time capability asserts for transaction signing/sending and message signing. |
| examples/react-app/src/main.tsx | Replaces prior wallet context wiring with WalletClientProvider and gates wallet-dependent UI via WalletReadyGate. |
| examples/react-app/src/routes/root.tsx | Switches root route to useConnectedWallet() and passes signer/account to feature panels. |
| examples/react-app/src/components/Nav.tsx | Disables wallet menus until wallet client is ready; updates chain casting to SolanaChain. |
| examples/react-app/src/components/ConnectWalletMenu.tsx | Migrates wallet listing/connection UI to wallet plugin hooks; updates empty-wallet messaging. |
| examples/react-app/src/components/ConnectWalletMenuItem.tsx | Reworks connect/select-account/disconnect behavior using wallet plugin actions and abort handling. |
| examples/react-app/src/components/SignInMenu.tsx | Migrates sign-in menu to plugin-provided wallets list and simplified menu items. |
| examples/react-app/src/components/SignInMenuItem.tsx | Switches to plugin useSignIn() action store; filters abort errors and uses action running state. |
| examples/react-app/src/components/SolanaSignMessageFeaturePanel.tsx | Uses plugin useSignMessage() and capability assert instead of legacy message-signer hook. |
| examples/react-app/src/components/SolanaSignTransactionFeaturePanel.tsx | Switches from account-based signer hook to plugin-provided `WalletSigner |
| examples/react-app/src/components/SolanaSignAndSendTransactionFeaturePanel.tsx | Switches to plugin signer + capability assert and updates recipient/wallet plumbing. |
| examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx | Switches to plugin signer + capability assert and updates imports/types accordingly. |
| examples/react-app/src/components/WalletAccountIcon.tsx | Uses plugin useWallets() and @wallet-standard/ui types/helpers for icon lookup. |
| examples/react-app/src/components/WalletMenuItemContent.tsx | Updates UiWallet import source to @wallet-standard/ui. |
| examples/react-app/src/components/BaseSignMessageFeaturePanel.tsx | Uses Kit’s ReadonlyUint8Array type instead of wallet-standard core’s type. |
| examples/react-app/src/components/Balance.tsx | Updates UiWalletAccount import source to @wallet-standard/ui. |
| examples/react-app/src/components/tests/Balance-test.browser.tsx | Updates UiWalletAccount import source to @wallet-standard/ui to match runtime changes. |
| examples/react-app/src/components/UnconnectableWalletMenuItem.tsx | Removes legacy “unconnectable wallet” menu item component now that plugin pre-filters wallets. |
| examples/react-app/src/components/DisconnectButton.tsx | Removes legacy disconnect button that relied on wallet-standard/react hooks directly. |
| .changeset/slick-rabbits-admire.md | Adds changeset for @solana/react wallet-standard UI dependency bumps. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * `solana:signTransaction`) deliberately fails this assert even though it satisfies | ||
| * `TransactionSigner`, because it doesn't work with those panels. | ||
| */ | ||
| export function assertCanSignTransactions(signer: WalletSigner | null): asserts signer is TransactionSigner { |
trevor-cortex
left a comment
There was a problem hiding this comment.
Nice iteration. The 0.14 hook API (client as first param) is threaded through cleanly, and the new decomposition — Dimmable, GatedRoot, SlotIndicatorPanel, plus useDisplayedWallet / useHasWalletSettled — is a real readability win over the previous inline logic. The stale-vs-settled distinction is subtle, and pulling it into two small named hooks with docblocks that spell out why they're separate is the right call. New tests cover the tricky bits (StrictMode double-mount disposal, retained-vs-live connection, latching behavior) — that's exactly where I would have wanted them.
A few small notes below; none block. Given Loris already approved, treat these as follow-up thoughts rather than change requests.
Things worth a second look:
-
Duplicated "busy button" styling between
ConnectWalletMenuandSignInMenu. Both apply the samearia-busy/opacity: 0.5/pointerEvents: 'none'/transition: 150msblock to their<Button>triggers.Dimmablecan't wrap aTriggerchild (Radix needs the button directly), but a tiny<BusyButton>component (or auseBusyButtonProps(busy)hook that returns the props bag) would keep the two triggers in lockstep and match the abstraction level ofDimmable. -
ConnectWalletMenuandSignInMenuuse different signals for the same UX.ConnectWalletMenudisables viaisStale(fromuseDisplayedWallet),SignInMenudisables via!isReady(fromuseIsWalletReady). They're the same value today (isStale === !isReady), but a reader has to work that out. Deriving both from the same primitive — or usinguseDisplayedWallet'sisStalein both — would make the intent unambiguous. -
SolanaSignMessageFeaturePaneldeliberately leaks aWALLET_STANDARD_ERROR__*up to the boundary sogetErrorMessagecan format it consistently with the other panels.walletCapability.tsexplains this, which is great — but a reader landing inSolanaSignMessageFeaturePanel.tsxfirst won't have that context. Consider a one-line comment on theassertCanSignMessages(account)call pointing at the docblock (or restating the "we intentionally let the wallet-standard error through here" bit).
Notes for later reviewers:
- The two custom hooks disable
react-hooks/refsand mutate a ref during render. That's safe here because the mutation is a pure function of the props (isReady,connected), so StrictMode's double-render is idempotent — and there are tests exercising exactly that path via the StrictMode-wrappingrenderhelper. Worth being aware of when reading them. WalletClientProviderpublishes the client synchronously from auseLayoutEffectand disposes it in cleanup. Under StrictMode the effect runs twice, so the mount test'smockPublishedClientsarray will actually contain the disposed dev-double and the live client —[length - 1]correctly picks the live one, but if that test ever gets tightened it's worth asserting the disposed one was in fact disposed.- Follow-ups the PR description already flags:
solanaRpc+ system-program plugins, and any hooks that currently acceptclientexplicitly could later move to auseClient()-implicit style if the plugin gains one.
ee8d403 to
3bef4b5
Compare
3bef4b5 to
e57247b
Compare
e57247b to
e352dce
Compare
Merge activity
|
e352dce to
581e54a
Compare
Instead use granular UI states to indicate the stale previous state
581e54a to
64ea156
Compare
|
🔎💬 Inkeep AI search and chat service is syncing content for source 'Solana Kit Docs' |

This PR updates the react-app example to use the
walletSignerplugin, and the react hooks from the wallet plugin.It creates a
Clientwith only thewalletSignerplugin, future iterations will add more plugins (namelysolanaRpcand the system program plugin).It maintains as much functionality from the previous react app as possible, I've flagged inline where the functionality differs.
This PR now uses 0.14.0 of the wallet plugin, which requires
clientas the first param of each hook.The PR also bumps wallet-standard dependencies in
@solana/reactto match those in the wallet plugin (the latest versions).