From 39e475b1cdfe52ab2a2087b6d70173b303e03bbb Mon Sep 17 00:00:00 2001 From: Callum Date: Tue, 14 Jul 2026 14:47:03 +0000 Subject: [PATCH 1/4] Make the type param in useClient non-optional From ad8b517a9a6a280078c2d1e934cb500bf7a95374 Mon Sep 17 00:00:00 2001 From: Callum Date: Fri, 10 Jul 2026 11:01:06 +0000 Subject: [PATCH 2/4] Update the react-app example to use the wallet plugin --- .changeset/slick-rabbits-admire.md | 5 + examples/react-app/package.json | 4 +- examples/react-app/src/components/Balance.tsx | 2 +- .../BaseSignMessageFeaturePanel.tsx | 2 +- .../src/components/ConnectWalletMenu.tsx | 71 +- .../src/components/ConnectWalletMenuItem.tsx | 111 ++- .../src/components/DisconnectButton.tsx | 51 - examples/react-app/src/components/Nav.tsx | 18 +- .../react-app/src/components/SignInMenu.tsx | 25 +- .../src/components/SignInMenuItem.tsx | 50 +- ...lanaPartialSignTransactionFeaturePanel.tsx | 29 +- ...lanaSignAndSendTransactionFeaturePanel.tsx | 21 +- .../SolanaSignMessageFeaturePanel.tsx | 31 +- .../SolanaSignTransactionFeaturePanel.tsx | 21 +- .../UnconnectableWalletMenuItem.tsx | 34 - .../src/components/WalletAccountIcon.tsx | 5 +- .../src/components/WalletMenuItemContent.tsx | 2 +- .../__tests__/Balance-test.browser.tsx | 2 +- .../react-app/src/context/ChainContext.tsx | 5 +- .../src/context/WalletClientProvider.tsx | 103 ++ examples/react-app/src/main.tsx | 32 +- examples/react-app/src/routes/root.tsx | 165 ++-- examples/react-app/src/walletCapability.ts | 60 ++ examples/react-app/vite.config.ts | 10 + packages/react/package.json | 4 +- pnpm-lock.yaml | 878 +++++++++++++----- 26 files changed, 1137 insertions(+), 604 deletions(-) create mode 100644 .changeset/slick-rabbits-admire.md delete mode 100644 examples/react-app/src/components/DisconnectButton.tsx delete mode 100644 examples/react-app/src/components/UnconnectableWalletMenuItem.tsx create mode 100644 examples/react-app/src/context/WalletClientProvider.tsx create mode 100644 examples/react-app/src/walletCapability.ts diff --git a/.changeset/slick-rabbits-admire.md b/.changeset/slick-rabbits-admire.md new file mode 100644 index 000000000..d77a9b597 --- /dev/null +++ b/.changeset/slick-rabbits-admire.md @@ -0,0 +1,5 @@ +--- +'@solana/react': patch +--- + +Bump the `@wallet-standard/ui` and `@wallet-standard/ui-registry` dependencies to `^1.0.3` and `^1.1.1` respectively. The `1.1.x` registry line is a backward-compatible superset that continues to export the names `@solana/react` relies on, and aligning with it lets consumers that also pull in `@solana/kit-plugin-wallet` resolve a single, shared copy of the wallet-standard UI registry (which is a runtime singleton) instead of splitting across two incompatible copies. diff --git a/examples/react-app/package.json b/examples/react-app/package.json index 937ba99cd..4f1e31229 100644 --- a/examples/react-app/package.json +++ b/examples/react-app/package.json @@ -19,9 +19,10 @@ "@radix-ui/themes": "3.3.0", "@solana-program/system": "^0.12.2", "@solana/kit": "workspace:*", + "@solana/kit-plugin-wallet": "0.13.1", "@solana/react": "workspace:*", "@wallet-standard/core": "^1.1.2", - "@wallet-standard/react": "^1.0.1", + "@wallet-standard/ui": "^1.0.3", "react": "^19.2.7", "react-dom": "^19.2.7", "react-error-boundary": "^5.0.0", @@ -29,6 +30,7 @@ }, "devDependencies": { "@solana/eslint-config": "workspace:*", + "@solana/wallet-standard-chains": "^1.1.1", "@solana/wallet-standard-features": "^1.4.0", "@types/react": "^19.2.17", "@solana/test-config": "workspace:*", diff --git a/examples/react-app/src/components/Balance.tsx b/examples/react-app/src/components/Balance.tsx index 32ccad30e..2f95a2be1 100644 --- a/examples/react-app/src/components/Balance.tsx +++ b/examples/react-app/src/components/Balance.tsx @@ -2,7 +2,7 @@ import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { Flex, Text, Tooltip } from '@radix-ui/themes'; import { address, formatDecimalFixedPoint, type Lamports, lamportsToSol } from '@solana/kit'; import { useTrackedDataSWR } from '@solana/react/swr'; -import type { UiWalletAccount } from '@wallet-standard/react'; +import type { UiWalletAccount } from '@wallet-standard/ui'; import { useContext, useMemo } from 'react'; import { ChainContext } from '../context/ChainContext'; diff --git a/examples/react-app/src/components/BaseSignMessageFeaturePanel.tsx b/examples/react-app/src/components/BaseSignMessageFeaturePanel.tsx index a1e50175b..23a99d91e 100644 --- a/examples/react-app/src/components/BaseSignMessageFeaturePanel.tsx +++ b/examples/react-app/src/components/BaseSignMessageFeaturePanel.tsx @@ -1,8 +1,8 @@ import { Pencil1Icon } from '@radix-ui/react-icons'; import { Blockquote, Box, Button, Code, DataList, Dialog, Flex, TextField } from '@radix-ui/themes'; +import type { ReadonlyUint8Array } from '@solana/kit'; import { getBase64Decoder } from '@solana/kit'; import { useAction } from '@solana/react'; -import type { ReadonlyUint8Array } from '@wallet-standard/core'; import type { SyntheticEvent } from 'react'; import { useState } from 'react'; diff --git a/examples/react-app/src/components/ConnectWalletMenu.tsx b/examples/react-app/src/components/ConnectWalletMenu.tsx index 93ea18749..36c9fb55d 100644 --- a/examples/react-app/src/components/ConnectWalletMenu.tsx +++ b/examples/react-app/src/components/ConnectWalletMenu.tsx @@ -1,15 +1,12 @@ import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { Button, Callout, DropdownMenu } from '@radix-ui/themes'; -import { useSelectedWalletAccount } from '@solana/react'; -import { StandardConnect, StandardDisconnect } from '@wallet-standard/core'; -import type { UiWallet } from '@wallet-standard/react'; -import { uiWalletAccountBelongsToUiWallet } from '@wallet-standard/react'; -import { useRef, useState } from 'react'; -import { ErrorBoundary } from 'react-error-boundary'; +import { useConnectedWallet, useWallets } from '@solana/kit-plugin-wallet/react'; +import type { UiWallet } from '@wallet-standard/ui'; +import { useContext, useRef, useState } from 'react'; +import { ChainContext } from '../context/ChainContext'; import { ConnectWalletMenuItem } from './ConnectWalletMenuItem'; import { ErrorDialog } from './ErrorDialog'; -import { UnconnectableWalletMenuItem } from './UnconnectableWalletMenuItem'; import { WalletAccountIcon } from './WalletAccountIcon'; type Props = Readonly<{ @@ -18,49 +15,32 @@ type Props = Readonly<{ export function ConnectWalletMenu({ children }: Props) { const { current: NO_ERROR } = useRef(Symbol()); - const [selectedWalletAccount, setSelectedWalletAccount, wallets] = useSelectedWalletAccount(); + const { displayName: currentChainName } = useContext(ChainContext); + const wallets = useWallets(); + const connected = useConnectedWallet(); const [error, setError] = useState(NO_ERROR); const [forceClose, setForceClose] = useState(false); + // 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. function renderItem(wallet: UiWallet) { return ( - } + - { - setSelectedWalletAccount(account); - setForceClose(true); - }} - onDisconnect={wallet => { - if (selectedWalletAccount && uiWalletAccountBelongsToUiWallet(selectedWalletAccount, wallet)) { - setSelectedWalletAccount(undefined); - } - }} - onError={setError} - wallet={wallet} - /> - + onAccountSelect={() => setForceClose(true)} + onError={setError} + wallet={wallet} + /> ); } - const walletsThatSupportStandardConnect = []; - const unconnectableWallets = []; - for (const wallet of wallets) { - if (wallet.features.includes(StandardConnect) && wallet.features.includes(StandardDisconnect)) { - walletsThatSupportStandardConnect.push(wallet); - } else { - unconnectableWallets.push(wallet); - } - } return ( <> - - ); -} diff --git a/examples/react-app/src/components/Nav.tsx b/examples/react-app/src/components/Nav.tsx index 0301e1e85..e4f63c73b 100644 --- a/examples/react-app/src/components/Nav.tsx +++ b/examples/react-app/src/components/Nav.tsx @@ -1,4 +1,6 @@ -import { Badge, Box, DropdownMenu, Flex, Heading } from '@radix-ui/themes'; +import { Badge, Box, Button, DropdownMenu, Flex, Heading } from '@radix-ui/themes'; +import { WalletReadyGate } from '@solana/kit-plugin-wallet/react'; +import type { SolanaChain } from '@solana/wallet-standard-chains'; import { useContext } from 'react'; import { ChainContext } from '../context/ChainContext'; @@ -33,7 +35,7 @@ export function Nav() { { - setChain(value as 'solana:${string}'); + setChain(value as SolanaChain); }} value={chain} > @@ -52,8 +54,16 @@ export function Nav() { )} - Connect Wallet - Sign In + + Connect Wallet + + } + > + Connect Wallet + Sign In + ); diff --git a/examples/react-app/src/components/SignInMenu.tsx b/examples/react-app/src/components/SignInMenu.tsx index 4d78ac8ea..e600bac87 100644 --- a/examples/react-app/src/components/SignInMenu.tsx +++ b/examples/react-app/src/components/SignInMenu.tsx @@ -1,14 +1,12 @@ import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { Button, Callout, DropdownMenu } from '@radix-ui/themes'; -import { useSelectedWalletAccount } from '@solana/react'; +import { useWallets } from '@solana/kit-plugin-wallet/react'; import { SolanaSignIn } from '@solana/wallet-standard-features'; -import type { UiWallet } from '@wallet-standard/react'; +import type { UiWallet } from '@wallet-standard/ui'; import { useRef, useState } from 'react'; -import { ErrorBoundary } from 'react-error-boundary'; import { ErrorDialog } from './ErrorDialog'; import { SignInMenuItem } from './SignInMenuItem'; -import { UnconnectableWalletMenuItem } from './UnconnectableWalletMenuItem'; type Props = Readonly<{ children: React.ReactNode; @@ -16,24 +14,17 @@ type Props = Readonly<{ export function SignInMenu({ children }: Props) { const { current: NO_ERROR } = useRef(Symbol()); - const [, setSelectedWalletAccount, wallets] = useSelectedWalletAccount(); + const wallets = useWallets(); const [error, setError] = useState(NO_ERROR); const [forceClose, setForceClose] = useState(false); function renderItem(wallet: UiWallet) { return ( - } + - { - setSelectedWalletAccount(account); - setForceClose(true); - }} - onError={setError} - wallet={wallet} - /> - + onSignIn={() => setForceClose(true)} + onError={setError} + wallet={wallet} + /> ); } const walletsThatSupportSignInWithSolana = []; diff --git a/examples/react-app/src/components/SignInMenuItem.tsx b/examples/react-app/src/components/SignInMenuItem.tsx index 886c5e63e..b4e7ba384 100644 --- a/examples/react-app/src/components/SignInMenuItem.tsx +++ b/examples/react-app/src/components/SignInMenuItem.tsx @@ -1,41 +1,41 @@ import { DropdownMenu } from '@radix-ui/themes'; -import { useSignIn } from '@solana/react'; -import type { UiWallet, UiWalletAccount } from '@wallet-standard/react'; -import React, { useCallback, useState } from 'react'; +import { isAbortError } from '@solana/kit'; +import { useSignIn } from '@solana/kit-plugin-wallet/react'; +import type { UiWallet } from '@wallet-standard/ui'; +import type { MouseEvent } from 'react'; import { WalletMenuItemContent } from './WalletMenuItemContent'; type Props = Readonly<{ onError(err: unknown): void; - onSignIn(account: UiWalletAccount | undefined): void; + onSignIn(): void; wallet: UiWallet; }>; export function SignInMenuItem({ onSignIn, onError, wallet }: Props) { - const signIn = useSignIn(wallet); - const [isSigningIn, setIsSigningIn] = useState(false); - const handleSignInClick = useCallback( - async (e: React.MouseEvent) => { - e.preventDefault(); - try { - setIsSigningIn(true); - try { - const { account } = await signIn({ - statement: 'You will enjoy being signed in.', - }); - onSignIn(account); - } finally { - setIsSigningIn(false); - } - } catch (e) { + const signIn = useSignIn(); + async function handleSignInClick(e: MouseEvent) { + e.preventDefault(); + try { + await signIn.dispatchAsync(wallet, { + domain: window.location.host, + statement: 'You will enjoy being signed in.', + }); + onSignIn(); + } catch (e) { + // Filter out abort error, which just means a later action superseded + if (!isAbortError(e)) { onError(e); } - }, - [signIn, onSignIn, onError], - ); + } + } return ( - - + { + void handleSignInClick(e); + }} + > + ); } diff --git a/examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx b/examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx index c8d2193a4..db4902ceb 100644 --- a/examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx +++ b/examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx @@ -11,18 +11,20 @@ import { getTransactionDecoder, getTransactionEncoder, pipe, + type ReadonlyUint8Array, sendAndConfirmTransactionFactory, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, - SignatureBytes, + type SignatureBytes, signTransaction, signTransactionMessageWithSigners, - TransactionPartialSigner, + type TransactionPartialSigner, } from '@solana/kit'; -import { useAction, useWalletAccountTransactionSigner } from '@solana/react'; +import type { WalletSigner } from '@solana/kit-plugin-wallet'; +import { useWallets } from '@solana/kit-plugin-wallet/react'; +import { useAction } from '@solana/react'; import { getTransferSolInstruction } from '@solana-program/system'; -import { ReadonlyUint8Array } from '@wallet-standard/core'; -import { getUiWalletAccountStorageKey, type UiWalletAccount, useWallets } from '@wallet-standard/react'; +import { getUiWalletAccountStorageKey } from '@wallet-standard/ui'; import type { SyntheticEvent } from 'react'; import { useContext, useId, useMemo, useState } from 'react'; @@ -30,12 +32,13 @@ import { ChainContext } from '../context/ChainContext'; import { RpcContext } from '../context/RpcContext'; import { solStringToLamports } from '../lamports'; import signerBytes from '../signerBytes.json' with { type: 'json' }; +import { assertCanSignTransactions } from '../walletCapability'; import { AirdropButton } from './AirdropButton'; import { ErrorDialog } from './ErrorDialog'; import { WalletMenuItemContent } from './WalletMenuItemContent'; type Props = Readonly<{ - account: UiWalletAccount; + signer: WalletSigner | null; }>; async function mockApiRequest(serializedTransaction: ReadonlyUint8Array): Promise { @@ -45,7 +48,7 @@ async function mockApiRequest(serializedTransaction: ReadonlyUint8Array): Promis return getBase58Encoder().encode(getSignatureFromTransaction(signedTransaction)) as SignatureBytes; } -export function SolanaPartialSignTransactionFeaturePanel({ account }: Props) { +export function SolanaPartialSignTransactionFeaturePanel({ signer }: Props) { const { rpc, rpcSubscriptions } = useContext(RpcContext); const sendAndConfirmTransaction = useMemo( () => sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }), @@ -66,7 +69,6 @@ export function SolanaPartialSignTransactionFeaturePanel({ account }: Props) { } }, [recipientAccountStorageKey, wallets]); const { chain: currentChain, solanaExplorerClusterName } = useContext(ChainContext); - const transactionSigner = useWalletAccountTransactionSigner(account, currentChain); const lamportsInputId = useId(); const recipientSelectId = useId(); @@ -85,10 +87,15 @@ export function SolanaPartialSignTransactionFeaturePanel({ account }: Props) { }, }; + // 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); + // Step one: Build + sign the transaction - // Note that we have two signers: feePayerSigner and transactionSigner + // Note that we have two signers: feePayerSigner and the connected wallet's `signer` // feePayerSigner uses the `mockApiRequest` to sign the transaction, acting like a server signer - // transactionSigner uses the connected wallet's signing feature + // `signer` uses the connected wallet's signing feature (as the transfer's `source`) const signAction = useAction(async signal => { const amount = solStringToLamports(solQuantityString); if (!recipientAccount) { @@ -106,7 +113,7 @@ export function SolanaPartialSignTransactionFeaturePanel({ account }: Props) { getTransferSolInstruction({ amount, destination: address(recipientAccount.address), - source: transactionSigner, + source: signer, }), m, ), diff --git a/examples/react-app/src/components/SolanaSignAndSendTransactionFeaturePanel.tsx b/examples/react-app/src/components/SolanaSignAndSendTransactionFeaturePanel.tsx index d527e8794..7f218ca1f 100644 --- a/examples/react-app/src/components/SolanaSignAndSendTransactionFeaturePanel.tsx +++ b/examples/react-app/src/components/SolanaSignAndSendTransactionFeaturePanel.tsx @@ -10,23 +10,26 @@ import { setTransactionMessageLifetimeUsingBlockhash, signAndSendTransactionMessageWithSigners, } from '@solana/kit'; -import { useAction, useWalletAccountTransactionSendingSigner } from '@solana/react'; +import type { WalletSigner } from '@solana/kit-plugin-wallet'; +import { useWallets } from '@solana/kit-plugin-wallet/react'; +import { useAction } from '@solana/react'; import { getTransferSolInstruction } from '@solana-program/system'; -import { getUiWalletAccountStorageKey, type UiWalletAccount, useWallets } from '@wallet-standard/react'; +import { getUiWalletAccountStorageKey } from '@wallet-standard/ui'; import type { SyntheticEvent } from 'react'; import { useContext, useId, useMemo, useState } from 'react'; import { ChainContext } from '../context/ChainContext'; import { RpcContext } from '../context/RpcContext'; import { solStringToLamports } from '../lamports'; +import { assertCanSignAndSendTransactions } from '../walletCapability'; import { ErrorDialog } from './ErrorDialog'; import { WalletMenuItemContent } from './WalletMenuItemContent'; type Props = Readonly<{ - account: UiWalletAccount; + signer: WalletSigner | null; }>; -export function SolanaSignAndSendTransactionFeaturePanel({ account }: Props) { +export function SolanaSignAndSendTransactionFeaturePanel({ signer }: Props) { const { rpc } = useContext(RpcContext); const wallets = useWallets(); const [solQuantityString, setSolQuantityString] = useState(''); @@ -43,10 +46,14 @@ export function SolanaSignAndSendTransactionFeaturePanel({ account }: Props) { } }, [recipientAccountStorageKey, wallets]); const { chain: currentChain, solanaExplorerClusterName } = useContext(ChainContext); - const transactionSendingSigner = useWalletAccountTransactionSendingSigner(account, currentChain); const lamportsInputId = useId(); const recipientSelectId = useId(); + // Render-time capability guard: throws so the surrounding `ErrorBoundary` renders + // `FeatureNotSupportedCallout` when the connected account can't sign and send transaction + // it also narrows `signer` for the `useAction` below + assertCanSignAndSendTransactions(signer); + const { data: lastSignature, dispatchAsync, @@ -63,14 +70,14 @@ export function SolanaSignAndSendTransactionFeaturePanel({ account }: Props) { .send({ abortSignal: signal }); const message = pipe( createTransactionMessage({ version: 0 }), - m => setTransactionMessageFeePayerSigner(transactionSendingSigner, m), + m => setTransactionMessageFeePayerSigner(signer, m), m => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m), m => appendTransactionMessageInstruction( getTransferSolInstruction({ amount, destination: address(recipientAccount.address), - source: transactionSendingSigner, + source: signer, }), m, ), diff --git a/examples/react-app/src/components/SolanaSignMessageFeaturePanel.tsx b/examples/react-app/src/components/SolanaSignMessageFeaturePanel.tsx index 0ad6c2c19..34ccf9f64 100644 --- a/examples/react-app/src/components/SolanaSignMessageFeaturePanel.tsx +++ b/examples/react-app/src/components/SolanaSignMessageFeaturePanel.tsx @@ -1,9 +1,7 @@ -import type { Address } from '@solana/kit'; -import { useWalletAccountMessageSigner } from '@solana/react'; -import type { ReadonlyUint8Array } from '@wallet-standard/core'; -import type { UiWalletAccount } from '@wallet-standard/react'; -import { useCallback } from 'react'; +import { useSignMessage } from '@solana/kit-plugin-wallet/react'; +import type { UiWalletAccount } from '@wallet-standard/ui'; +import { assertCanSignMessages } from '../walletCapability'; import { BaseSignMessageFeaturePanel } from './BaseSignMessageFeaturePanel'; type Props = Readonly<{ @@ -11,22 +9,9 @@ type Props = Readonly<{ }>; export function SolanaSignMessageFeaturePanel({ account }: Props) { - const messageSigner = useWalletAccountMessageSigner(account); - const signMessage = useCallback( - async (message: ReadonlyUint8Array) => { - const [result] = await messageSigner.modifyAndSignMessages([ - { - content: message as Uint8Array, - signatures: {}, - }, - ]); - const signature = result?.signatures[account.address as Address]; - if (!signature) { - throw new Error(); - } - return signature as ReadonlyUint8Array; - }, - [account.address, messageSigner], - ); - return ; + const { dispatchAsync } = useSignMessage(); + // Guard at render so the surrounding `ErrorBoundary` shows `FeatureNotSupportedCallout` + // when the connected account lacks it. + assertCanSignMessages(account); + return ; } diff --git a/examples/react-app/src/components/SolanaSignTransactionFeaturePanel.tsx b/examples/react-app/src/components/SolanaSignTransactionFeaturePanel.tsx index 4841dcfd8..f533e747f 100644 --- a/examples/react-app/src/components/SolanaSignTransactionFeaturePanel.tsx +++ b/examples/react-app/src/components/SolanaSignTransactionFeaturePanel.tsx @@ -12,23 +12,26 @@ import { setTransactionMessageLifetimeUsingBlockhash, signTransactionMessageWithSigners, } from '@solana/kit'; -import { useAction, useWalletAccountTransactionSigner } from '@solana/react'; +import type { WalletSigner } from '@solana/kit-plugin-wallet'; +import { useWallets } from '@solana/kit-plugin-wallet/react'; +import { useAction } from '@solana/react'; import { getTransferSolInstruction } from '@solana-program/system'; -import { getUiWalletAccountStorageKey, type UiWalletAccount, useWallets } from '@wallet-standard/react'; +import { getUiWalletAccountStorageKey } from '@wallet-standard/ui'; import type { SyntheticEvent } from 'react'; import { useContext, useId, useMemo, useState } from 'react'; import { ChainContext } from '../context/ChainContext'; import { RpcContext } from '../context/RpcContext'; import { solStringToLamports } from '../lamports'; +import { assertCanSignTransactions } from '../walletCapability'; import { ErrorDialog } from './ErrorDialog'; import { WalletMenuItemContent } from './WalletMenuItemContent'; type Props = Readonly<{ - account: UiWalletAccount; + signer: WalletSigner | null; }>; -export function SolanaSignTransactionFeaturePanel({ account }: Props) { +export function SolanaSignTransactionFeaturePanel({ signer }: Props) { const { rpc, rpcSubscriptions } = useContext(RpcContext); const sendAndConfirmTransaction = useMemo( () => sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }), @@ -49,10 +52,14 @@ export function SolanaSignTransactionFeaturePanel({ account }: Props) { } }, [recipientAccountStorageKey, wallets]); const { chain: currentChain, solanaExplorerClusterName } = useContext(ChainContext); - const transactionSigner = useWalletAccountTransactionSigner(account, currentChain); const lamportsInputId = useId(); const recipientSelectId = useId(); + // throws so the surrounding `ErrorBoundary` renders `FeatureNotSupportedCallout` when + // the connected account can't sign transactions + // it also narrows `signer` for the `useAction` below + assertCanSignTransactions(signer); + // Step one: build and sign the transaction const signAction = useAction(async signal => { const amount = solStringToLamports(solQuantityString); @@ -64,14 +71,14 @@ export function SolanaSignTransactionFeaturePanel({ account }: Props) { .send({ abortSignal: signal }); const message = pipe( createTransactionMessage({ version: 0 }), - m => setTransactionMessageFeePayerSigner(transactionSigner, m), + m => setTransactionMessageFeePayerSigner(signer, m), m => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m), m => appendTransactionMessageInstruction( getTransferSolInstruction({ amount, destination: address(recipientAccount.address), - source: transactionSigner, + source: signer, }), m, ), diff --git a/examples/react-app/src/components/UnconnectableWalletMenuItem.tsx b/examples/react-app/src/components/UnconnectableWalletMenuItem.tsx deleted file mode 100644 index 746ed2425..000000000 --- a/examples/react-app/src/components/UnconnectableWalletMenuItem.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; -import { Box, DropdownMenu, Text } from '@radix-ui/themes'; -import type { UiWallet } from '@wallet-standard/react'; -import { useState } from 'react'; - -import { ErrorDialog } from './ErrorDialog'; -import { WalletMenuItemContent } from './WalletMenuItemContent'; - -type Props = Readonly<{ - error: unknown; - wallet: UiWallet; -}>; - -export function UnconnectableWalletMenuItem({ error, wallet }: Props) { - const [dialogIsOpen, setDialogIsOpen] = useState(false); - return ( - <> - setDialogIsOpen(true)}> - - {wallet.name} - - - - - - {dialogIsOpen ? ( - setDialogIsOpen(false)} title="Unconnectable wallet" /> - ) : null} - - ); -} diff --git a/examples/react-app/src/components/WalletAccountIcon.tsx b/examples/react-app/src/components/WalletAccountIcon.tsx index 5786d6024..ef0b2e689 100644 --- a/examples/react-app/src/components/WalletAccountIcon.tsx +++ b/examples/react-app/src/components/WalletAccountIcon.tsx @@ -1,5 +1,6 @@ -import type { UiWalletAccount } from '@wallet-standard/react'; -import { uiWalletAccountBelongsToUiWallet, useWallets } from '@wallet-standard/react'; +import { useWallets } from '@solana/kit-plugin-wallet/react'; +import type { UiWalletAccount } from '@wallet-standard/ui'; +import { uiWalletAccountBelongsToUiWallet } from '@wallet-standard/ui'; import React from 'react'; type Props = React.ComponentProps<'img'> & diff --git a/examples/react-app/src/components/WalletMenuItemContent.tsx b/examples/react-app/src/components/WalletMenuItemContent.tsx index 3cb0d475d..87e793f85 100644 --- a/examples/react-app/src/components/WalletMenuItemContent.tsx +++ b/examples/react-app/src/components/WalletMenuItemContent.tsx @@ -1,5 +1,5 @@ import { Avatar, Flex, Spinner, Text } from '@radix-ui/themes'; -import type { UiWallet } from '@wallet-standard/react'; +import type { UiWallet } from '@wallet-standard/ui'; import React from 'react'; type Props = Readonly<{ diff --git a/examples/react-app/src/components/__tests__/Balance-test.browser.tsx b/examples/react-app/src/components/__tests__/Balance-test.browser.tsx index 15160222d..a09281766 100644 --- a/examples/react-app/src/components/__tests__/Balance-test.browser.tsx +++ b/examples/react-app/src/components/__tests__/Balance-test.browser.tsx @@ -10,7 +10,7 @@ import type { } from '@solana/kit'; import { createReactiveActionStore, createReactiveStoreFromDataPublisherFactory } from '@solana/kit'; import { act, waitFor } from '@testing-library/react'; -import type { UiWalletAccount } from '@wallet-standard/react'; +import type { UiWalletAccount } from '@wallet-standard/ui'; import React from 'react'; import { SWRConfig } from 'swr'; diff --git a/examples/react-app/src/context/ChainContext.tsx b/examples/react-app/src/context/ChainContext.tsx index fddb01f37..293b82f8a 100644 --- a/examples/react-app/src/context/ChainContext.tsx +++ b/examples/react-app/src/context/ChainContext.tsx @@ -1,11 +1,12 @@ import type { ClusterUrl } from '@solana/kit'; import { devnet } from '@solana/kit'; +import type { SolanaChain } from '@solana/wallet-standard-chains'; import { createContext } from 'react'; export type ChainContext = Readonly<{ - chain: `solana:${string}`; + chain: SolanaChain; displayName: string; - setChain?(chain: `solana:${string}`): void; + setChain?(chain: SolanaChain): void; solanaExplorerClusterName: 'devnet' | 'mainnet-beta' | 'testnet'; solanaRpcSubscriptionsUrl: ClusterUrl; solanaRpcUrl: ClusterUrl; diff --git a/examples/react-app/src/context/WalletClientProvider.tsx b/examples/react-app/src/context/WalletClientProvider.tsx new file mode 100644 index 000000000..cf625eb38 --- /dev/null +++ b/examples/react-app/src/context/WalletClientProvider.tsx @@ -0,0 +1,103 @@ +import { createClient } from '@solana/kit'; +import { walletSigner } from '@solana/kit-plugin-wallet'; +import { ClientProvider } from '@solana/react'; +import type { SolanaChain } from '@solana/wallet-standard-chains'; +import { useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +import { ChainContext } from './ChainContext'; + +type Props = Readonly<{ + children: React.ReactNode; +}>; + +function buildWalletClient(chain: SolanaChain) { + return createClient().use(walletSigner({ chain })); +} + +/** + * The concrete Kit client type published by {@link WalletClientProvider} — a base client with the + * wallet plugin installed. Pass it as the type argument to `useClient` wherever this app reads the + * client from context (e.g. `useClient()`) so the wallet plugin's namespace is typed. + */ +export type AppClient = ReturnType; + +/** + * Builds a Kit client with the wallet plugin installed and publishes it via `ClientProvider`, + * rebuilding it whenever the selected chain changes. + * + * Each wallet plugin is bound to a single chain, so switching chains means building a fresh client. + * A freshly built client runs a silent auto-reconnect that briefly passes through `'pending'` / + * `'reconnecting'`. Rather than hide the whole app during that warm-up, we publish the client right + * away and let {@link WalletReadyGate} hold back only the wallet-dependent UI — so the app chrome + * (title, chain switcher) stays on screen throughout. + * + * The first client is published from a layout effect — before the browser paints — so the app + * chrome is visible from the very first frame. On a later chain switch, the current (already-ready) + * client is kept on screen until the new one has settled via `whenReady()`, so the swap moves + * straight from the old connection to the new one without flashing a reconnecting state. + * + * Client disposal (tearing down wallet-standard registry listeners) is split by who last owns the + * client: a published client is disposed when a successor replaces it (or the provider unmounts), + * while a client superseded *before* it was published is disposed by its own effect's cleanup. The + * latter must not live in the `whenReady()` continuation: a client stuck mid-warm-up never settles + * `whenReady` on its own — it is the *disposal* that settles it. Which is also why the `cancelled` + * guard in the continuation is load-bearing: once the cleanup disposes a superseded client, its + * `whenReady()` resolves and the continuation runs — the guard is what keeps the disposed client + * from being published. + */ +export function WalletClientProvider({ children }: Props) { + const { chain } = useContext(ChainContext); + const [client, setClient] = useState(null); + // The client currently published to the tree, tracked outside React state so the effects + // below can dispose hand-over-hand without extra renders. + const activeClientRef = useRef(null); + + useLayoutEffect(() => { + const next = buildWalletClient(chain); + let cancelled = false; + const publish = () => { + activeClientRef.current?.[Symbol.dispose](); + activeClientRef.current = next; + setClient(next); + }; + if (activeClientRef.current === null) { + // First client: publish before paint. WalletReadyGate keeps the wallet-dependent + // parts hidden until this client's warm-up settles. + publish(); + } else { + // Chain switch: keep showing the current, already-ready client until the new one has + // finished warming up, then swap (disposing the one it replaces). `whenReady()` also + // settles when `next` is disposed by the cleanup below, so this continuation always + // runs eventually — `cancelled` keeps a disposed client from being published. + void next.wallet.whenReady().then(() => { + if (!cancelled) { + publish(); + } + }); + } + return () => { + cancelled = true; + if (activeClientRef.current !== next) { + // Superseded before it was published; nothing else owns it, so dispose it here. + next[Symbol.dispose](); + } + }; + }, [chain]); + + // Dispose the published client when the provider unmounts. (StrictMode's simulated unmount + // trips this once at mount in dev, disposing the first client early — the effect above then + // re-runs with an empty activeClientRef and publishes a fresh client immediately.) + useEffect( + () => () => { + activeClientRef.current?.[Symbol.dispose](); + activeClientRef.current = null; + }, + [], + ); + + if (!client) { + // Only the pre-layout-effect render pass lands here; it is never painted. + return null; + } + return {children}; +} diff --git a/examples/react-app/src/main.tsx b/examples/react-app/src/main.tsx index 669ce10aa..ec78e4145 100644 --- a/examples/react-app/src/main.tsx +++ b/examples/react-app/src/main.tsx @@ -1,40 +1,42 @@ import './index.css'; import '@radix-ui/themes/styles.css'; -import { Flex, Section, Theme } from '@radix-ui/themes'; -import { SelectedWalletAccountContextProvider } from '@solana/react'; -import type { UiWallet } from '@wallet-standard/react'; +import { Flex, Section, Spinner, Text, Theme } from '@radix-ui/themes'; +import { WalletReadyGate } from '@solana/kit-plugin-wallet/react'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { Nav } from './components/Nav.tsx'; import { ChainContextProvider } from './context/ChainContextProvider.tsx'; import { RpcContextProvider } from './context/RpcContextProvider.tsx'; +import { WalletClientProvider } from './context/WalletClientProvider.tsx'; import Root from './routes/root.tsx'; -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), -}; - const rootNode = document.getElementById('root')!; const root = createRoot(rootNode); root.render( - true} stateSync={stateSync}> - + +