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..81c224743 100644 --- a/examples/react-app/package.json +++ b/examples/react-app/package.json @@ -17,11 +17,12 @@ "@radix-ui/react-dropdown-menu": "2.1.6", "@radix-ui/react-icons": "1.3.2", "@radix-ui/themes": "3.3.0", - "@solana-program/system": "^0.12.2", + "@solana-program/system": "^0.13.0", "@solana/kit": "workspace:*", + "@solana/kit-plugin-wallet": "0.14.0", "@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..231dcc1c2 100644 --- a/examples/react-app/src/components/ConnectWalletMenu.tsx +++ b/examples/react-app/src/components/ConnectWalletMenu.tsx @@ -1,15 +1,15 @@ 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 { useWallets } from '@solana/kit-plugin-wallet/react'; +import { useClient } from '@solana/react'; +import type { UiWallet } from '@wallet-standard/ui'; +import { useContext, useRef, useState } from 'react'; +import { ChainContext } from '../context/ChainContext'; +import type { AppClient } from '../context/WalletClientProvider'; +import { useDisplayedWallet } from '../hooks/useDisplayedWallet'; import { ConnectWalletMenuItem } from './ConnectWalletMenuItem'; import { ErrorDialog } from './ErrorDialog'; -import { UnconnectableWalletMenuItem } from './UnconnectableWalletMenuItem'; import { WalletAccountIcon } from './WalletAccountIcon'; type Props = Readonly<{ @@ -18,49 +18,41 @@ 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 client = useClient(); + const wallets = useWallets(client); + const { connected, isStale } = useDisplayedWallet(); 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/GatedRoot.tsx b/examples/react-app/src/components/GatedRoot.tsx new file mode 100644 index 000000000..8094df043 --- /dev/null +++ b/examples/react-app/src/components/GatedRoot.tsx @@ -0,0 +1,27 @@ +import { Container, Flex, Section, Spinner, Text } from '@radix-ui/themes'; + +import { useHasWalletSettled } from '../hooks/useHasWalletSettled'; +import Root from '../routes/root'; + +/** + * The wallet-dependent view, held behind a lightweight placeholder only until the wallet first + * settles its initial auto-reconnect ({@link useHasWalletSettled}). Later chain-switch warm-ups are + * handled by per-cell dimming inside `Root`, not by re-showing this gate. + */ +export function GatedRoot() { + const hasSettled = useHasWalletSettled(); + return ( +
+ + {hasSettled ? ( + + ) : ( + + + Connecting to your wallet… + + )} + +
+ ); +} diff --git a/examples/react-app/src/components/Nav.tsx b/examples/react-app/src/components/Nav.tsx index 0301e1e85..1650207d4 100644 --- a/examples/react-app/src/components/Nav.tsx +++ b/examples/react-app/src/components/Nav.tsx @@ -1,4 +1,5 @@ import { Badge, Box, DropdownMenu, Flex, Heading } from '@radix-ui/themes'; +import type { SolanaChain } from '@solana/wallet-standard-chains'; import { useContext } from 'react'; import { ChainContext } from '../context/ChainContext'; @@ -33,7 +34,7 @@ export function Nav() { { - setChain(value as 'solana:${string}'); + setChain(value as SolanaChain); }} value={chain} > diff --git a/examples/react-app/src/components/SignInMenu.tsx b/examples/react-app/src/components/SignInMenu.tsx index 4d78ac8ea..19abed150 100644 --- a/examples/react-app/src/components/SignInMenu.tsx +++ b/examples/react-app/src/components/SignInMenu.tsx @@ -1,14 +1,14 @@ import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { Button, Callout, DropdownMenu } from '@radix-ui/themes'; -import { useSelectedWalletAccount } from '@solana/react'; +import { useIsWalletReady, useWallets } from '@solana/kit-plugin-wallet/react'; +import { useClient } from '@solana/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 type { AppClient } from '../context/WalletClientProvider'; import { ErrorDialog } from './ErrorDialog'; import { SignInMenuItem } from './SignInMenuItem'; -import { UnconnectableWalletMenuItem } from './UnconnectableWalletMenuItem'; type Props = Readonly<{ children: React.ReactNode; @@ -16,24 +16,19 @@ type Props = Readonly<{ export function SignInMenu({ children }: Props) { const { current: NO_ERROR } = useRef(Symbol()); - const [, setSelectedWalletAccount, wallets] = useSelectedWalletAccount(); + const client = useClient(); + const wallets = useWallets(client); + const isReady = useIsWalletReady(client); 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 = []; @@ -46,7 +41,15 @@ export function SignInMenu({ children }: Props) { <> - diff --git a/examples/react-app/src/components/SignInMenuItem.tsx b/examples/react-app/src/components/SignInMenuItem.tsx index 886c5e63e..6fb3995e5 100644 --- a/examples/react-app/src/components/SignInMenuItem.tsx +++ b/examples/react-app/src/components/SignInMenuItem.tsx @@ -1,41 +1,44 @@ 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 { useClient } from '@solana/react'; +import type { UiWallet } from '@wallet-standard/ui'; +import type { MouseEvent } from 'react'; +import type { AppClient } from '../context/WalletClientProvider'; 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 client = useClient(); + const signIn = useSignIn(client); + 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/SlotIndicatorPanel.tsx b/examples/react-app/src/components/SlotIndicatorPanel.tsx new file mode 100644 index 000000000..8188291a1 --- /dev/null +++ b/examples/react-app/src/components/SlotIndicatorPanel.tsx @@ -0,0 +1,22 @@ +import { Flex, Heading, Text } from '@radix-ui/themes'; +import type { SolanaChain } from '@solana/wallet-standard-chains'; +import { ErrorBoundary } from 'react-error-boundary'; + +import { SlotIndicator } from './SlotIndicator'; + +/** + * The network's current slot, updates the instant the chain changes. The + * `key={chain}` resets its error boundary on a chain switch. + */ +export function SlotIndicatorPanel({ chain }: { chain: SolanaChain }) { + return ( + + + Slot + + –} key={chain}> + + + + ); +} diff --git a/examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx b/examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx index c8d2193a4..23c405c50 100644 --- a/examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx +++ b/examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx @@ -11,31 +11,35 @@ 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, useClient } 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'; import { ChainContext } from '../context/ChainContext'; import { RpcContext } from '../context/RpcContext'; +import type { AppClient } from '../context/WalletClientProvider'; 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,13 +49,14 @@ 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 }), [rpc, rpcSubscriptions], ); - const wallets = useWallets(); + const client = useClient(); + const wallets = useWallets(client); const [solQuantityString, setSolQuantityString] = useState(''); const [recipientAccountStorageKey, setRecipientAccountStorageKey] = useState(); const recipientAccount = useMemo(() => { @@ -66,7 +71,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 +89,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 +115,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..5670327a7 100644 --- a/examples/react-app/src/components/SolanaSignAndSendTransactionFeaturePanel.tsx +++ b/examples/react-app/src/components/SolanaSignAndSendTransactionFeaturePanel.tsx @@ -10,25 +10,30 @@ 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, useClient } 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 type { AppClient } from '../context/WalletClientProvider'; 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 client = useClient(); + const wallets = useWallets(client); const [solQuantityString, setSolQuantityString] = useState(''); const [recipientAccountStorageKey, setRecipientAccountStorageKey] = useState(); const recipientAccount = useMemo(() => { @@ -43,10 +48,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 +72,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..079f5f827 100644 --- a/examples/react-app/src/components/SolanaSignMessageFeaturePanel.tsx +++ b/examples/react-app/src/components/SolanaSignMessageFeaturePanel.tsx @@ -1,9 +1,9 @@ -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 { useClient } from '@solana/react'; +import type { UiWalletAccount } from '@wallet-standard/ui'; +import type { AppClient } from '../context/WalletClientProvider'; +import { assertCanSignMessages } from '../walletCapability'; import { BaseSignMessageFeaturePanel } from './BaseSignMessageFeaturePanel'; type Props = Readonly<{ @@ -11,22 +11,10 @@ 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 client = useClient(); + const { dispatchAsync } = useSignMessage(client); + // 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..73c1ef400 100644 --- a/examples/react-app/src/components/SolanaSignTransactionFeaturePanel.tsx +++ b/examples/react-app/src/components/SolanaSignTransactionFeaturePanel.tsx @@ -12,29 +12,34 @@ 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, useClient } 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 type { AppClient } from '../context/WalletClientProvider'; 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 }), [rpc, rpcSubscriptions], ); - const wallets = useWallets(); + const client = useClient(); + const wallets = useWallets(client); const [solQuantityString, setSolQuantityString] = useState(''); const [recipientAccountStorageKey, setRecipientAccountStorageKey] = useState(); const recipientAccount = useMemo(() => { @@ -49,10 +54,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 +73,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..6a4cdaf1f 100644 --- a/examples/react-app/src/components/WalletAccountIcon.tsx +++ b/examples/react-app/src/components/WalletAccountIcon.tsx @@ -1,14 +1,19 @@ -import type { UiWalletAccount } from '@wallet-standard/react'; -import { uiWalletAccountBelongsToUiWallet, useWallets } from '@wallet-standard/react'; +import { useWallets } from '@solana/kit-plugin-wallet/react'; +import { useClient } from '@solana/react'; +import type { UiWalletAccount } from '@wallet-standard/ui'; +import { uiWalletAccountBelongsToUiWallet } from '@wallet-standard/ui'; import React from 'react'; +import type { AppClient } from '../context/WalletClientProvider'; + type Props = React.ComponentProps<'img'> & Readonly<{ account: UiWalletAccount; }>; export function WalletAccountIcon({ account, ...imgProps }: Props) { - const wallets = useWallets(); + const client = useClient(); + const wallets = useWallets(client); let icon; if (account.icon) { icon = account.icon; 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/components/__tests__/ConnectWalletMenu-test.browser.tsx b/examples/react-app/src/components/__tests__/ConnectWalletMenu-test.browser.tsx new file mode 100644 index 000000000..56f5b5c70 --- /dev/null +++ b/examples/react-app/src/components/__tests__/ConnectWalletMenu-test.browser.tsx @@ -0,0 +1,65 @@ +import { Theme } from '@radix-ui/themes'; +import { useWallets } from '@solana/kit-plugin-wallet/react'; +import type { ReactNode } from 'react'; + +import { render } from '../../__test-utils__/render'; +import { ChainContext, DEFAULT_CHAIN_CONFIG } from '../../context/ChainContext'; +import { useDisplayedWallet } from '../../hooks/useDisplayedWallet'; +import { ConnectWalletMenu } from '../ConnectWalletMenu'; + +jest.mock('@solana/react', () => ({ useClient: jest.fn(() => ({})) })); +jest.mock('@solana/kit-plugin-wallet/react', () => ({ useWallets: jest.fn(() => []) })); +jest.mock('../../hooks/useDisplayedWallet', () => ({ useDisplayedWallet: jest.fn() })); +jest.mock('../WalletAccountIcon', () => ({ WalletAccountIcon: () => null })); + +const mockUseDisplayedWallet = useDisplayedWallet as jest.Mock; +// The repo's shared Jest config sets `resetMocks: true`, which strips even the initial +// `jest.fn(() => [])` implementation supplied to the factory above before every test runs — so +// `useWallets` must be given its return value explicitly here rather than relying on the factory. +const mockUseWallets = useWallets as jest.Mock; + +function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function connection(address: string) { + return { account: { address }, signer: {}, wallet: {} }; +} + +describe('ConnectWalletMenu', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseWallets.mockReturnValue([]); + }); + + it('shows the connected account, enabled, when ready', () => { + mockUseDisplayedWallet.mockReturnValue({ connected: connection('ABCDEFGH1234'), isStale: false }); + const { container } = render(Connect Wallet, { wrapper: Wrapper }); + const button = container.querySelector('button')!; + expect(container.textContent).toContain('ABCDEFGH'); + expect(button.disabled).toBe(false); + expect(button.style.opacity).toBe(''); + }); + + it('holds the account, disabled and dimmed, while stale — never flashing "Connect Wallet"', () => { + mockUseDisplayedWallet.mockReturnValue({ connected: connection('ABCDEFGH1234'), isStale: true }); + const { container } = render(Connect Wallet, { wrapper: Wrapper }); + const button = container.querySelector('button')!; + expect(container.textContent).toContain('ABCDEFGH'); + expect(container.textContent).not.toContain('Connect Wallet'); + expect(button.disabled).toBe(true); + expect(button.style.opacity).toBe('0.5'); + expect(button.style.pointerEvents).toBe('none'); + }); + + it('shows the connect affordance when settled and disconnected', () => { + mockUseDisplayedWallet.mockReturnValue({ connected: null, isStale: false }); + const { container } = render(Connect Wallet, { wrapper: Wrapper }); + expect(container.textContent).toContain('Connect Wallet'); + expect(container.querySelector('button')!.disabled).toBe(false); + }); +}); diff --git a/examples/react-app/src/components/__tests__/Dimmable-test.browser.tsx b/examples/react-app/src/components/__tests__/Dimmable-test.browser.tsx new file mode 100644 index 000000000..d61d6ad05 --- /dev/null +++ b/examples/react-app/src/components/__tests__/Dimmable-test.browser.tsx @@ -0,0 +1,28 @@ +import { Theme } from '@radix-ui/themes'; +import type { ReactNode } from 'react'; + +import { render } from '../../__test-utils__/render'; +import { Dimmable } from '../Dimmable'; + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe('Dimmable', () => { + it('renders children and is not busy when busy=false', () => { + const { container, getByText } = render(content, { wrapper: Wrapper }); + getByText('content'); + const box = container.querySelector('[aria-busy]') as HTMLElement; + expect(box.getAttribute('aria-busy')).toBe('false'); + expect(box.style.opacity).toBe('1'); + expect(box.style.pointerEvents).toBe(''); + }); + + it('marks busy and disables pointer events while busy', () => { + const { container } = render(content, { wrapper: Wrapper }); + const box = container.querySelector('[aria-busy="true"]') as HTMLElement; + expect(box).not.toBeNull(); + expect(box.style.opacity).toBe('0.5'); + expect(box.style.pointerEvents).toBe('none'); + }); +}); diff --git a/examples/react-app/src/components/__tests__/GatedRoot-test.browser.tsx b/examples/react-app/src/components/__tests__/GatedRoot-test.browser.tsx new file mode 100644 index 000000000..c8445472f --- /dev/null +++ b/examples/react-app/src/components/__tests__/GatedRoot-test.browser.tsx @@ -0,0 +1,32 @@ +import { Theme } from '@radix-ui/themes'; +import type { ReactNode } from 'react'; + +import { render } from '../../__test-utils__/render'; +import { useHasWalletSettled } from '../../hooks/useHasWalletSettled'; +import { GatedRoot } from '../GatedRoot'; + +jest.mock('../../hooks/useHasWalletSettled', () => ({ useHasWalletSettled: jest.fn() })); +jest.mock('../../routes/root', () => ({ __esModule: true, default: () =>
root
})); + +const mockUseHasWalletSettled = useHasWalletSettled as jest.Mock; + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe('GatedRoot', () => { + beforeEach(() => jest.clearAllMocks()); + + it('shows the connecting placeholder (not Root) before the wallet first settles', () => { + mockUseHasWalletSettled.mockReturnValue(false); + const { queryByTestId, container } = render(, { wrapper: Wrapper }); + expect(queryByTestId('root-view')).toBeNull(); + expect(container.textContent).toContain('Connecting to your wallet'); + }); + + it('renders Root once the wallet has settled', () => { + mockUseHasWalletSettled.mockReturnValue(true); + const { queryByTestId } = render(, { wrapper: Wrapper }); + expect(queryByTestId('root-view')).not.toBeNull(); + }); +}); 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..148400f55 --- /dev/null +++ b/examples/react-app/src/context/WalletClientProvider.tsx @@ -0,0 +1,49 @@ +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, useLayoutEffect, 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 on chain change. + * + * Each wallet plugin is bound to a single chain, so switching chains builds a fresh client. + * The previous client is disposed by this effect's cleanup, which also disposes the dev + * double-build under StrictMode. + */ +export function WalletClientProvider({ children }: Props) { + const { chain } = useContext(ChainContext); + const [client, setClient] = useState(null); + useLayoutEffect(() => { + const next = buildWalletClient(chain); + // Publishing `next` synchronously here (rather than deriving it from state) is deliberate: + // it lets the client be built/disposed alongside the external resource it wraps, with the + // effect cleanup owning disposal. + // eslint-disable-next-line react-hooks/set-state-in-effect + setClient(next); + return () => next[Symbol.dispose](); + }, [chain]); + 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/context/__tests__/WalletClientProvider-test.browser.tsx b/examples/react-app/src/context/__tests__/WalletClientProvider-test.browser.tsx new file mode 100644 index 000000000..2d3c266f0 --- /dev/null +++ b/examples/react-app/src/context/__tests__/WalletClientProvider-test.browser.tsx @@ -0,0 +1,70 @@ +import { act } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +import { render } from '../../__test-utils__/render'; +import { ChainContext, DEFAULT_CHAIN_CONFIG } from '../ChainContext'; + +// Each built client is a fresh disposable; the mock records published ones so the test can assert +// disposal. +const mockPublishedClients: unknown[] = []; + +jest.mock('@solana/kit', () => ({ + createClient: () => ({ + use: () => ({ [Symbol.dispose]: jest.fn() }), + }), + // `ChainContext` (imported below for its `DEFAULT_CHAIN_CONFIG`) also pulls `devnet` from + // `@solana/kit`; since this mock replaces the whole module, `devnet` must be provided too. + devnet: (url: string) => url, +})); +jest.mock('@solana/kit-plugin-wallet', () => ({ walletSigner: () => ({}) })); +jest.mock('@solana/react', () => ({ + ClientProvider: ({ children, client }: { children: ReactNode; client: unknown }) => { + mockPublishedClients.push(client); + return children; + }, +})); + +// Import after the mocks are registered. +import { WalletClientProvider } from '../WalletClientProvider'; + +function tree(chain: string) { + return ( + + +
child
+
+
+ ); +} + +describe('WalletClientProvider', () => { + beforeEach(() => { + mockPublishedClients.length = 0; + }); + + it('publishes a client immediately on mount without disposing it', () => { + render(tree('solana:devnet')); + const live = mockPublishedClients[mockPublishedClients.length - 1] as { [Symbol.dispose]: jest.Mock }; + expect(live).toBeDefined(); + expect(live[Symbol.dispose]).not.toHaveBeenCalled(); + }); + + it('publishes a new client and disposes the previous one on a chain change', () => { + const { rerender } = render(tree('solana:devnet')); + const beforeSwitch = mockPublishedClients[mockPublishedClients.length - 1] as { [Symbol.dispose]: jest.Mock }; + act(() => { + rerender(tree('solana:testnet')); + }); + const afterSwitch = mockPublishedClients[mockPublishedClients.length - 1] as { [Symbol.dispose]: jest.Mock }; + expect(afterSwitch).not.toBe(beforeSwitch); // a new client was published immediately + expect(beforeSwitch[Symbol.dispose]).toHaveBeenCalled(); // the old one was disposed + expect(afterSwitch[Symbol.dispose]).not.toHaveBeenCalled(); // the live one is not disposed + }); + + it('disposes the published client on unmount', () => { + const { unmount } = render(tree('solana:devnet')); + const live = mockPublishedClients[mockPublishedClients.length - 1] as { [Symbol.dispose]: jest.Mock }; + unmount(); + expect(live[Symbol.dispose]).toHaveBeenCalled(); + }); +}); diff --git a/examples/react-app/src/hooks/__tests__/useDisplayedWallet-test.browser.tsx b/examples/react-app/src/hooks/__tests__/useDisplayedWallet-test.browser.tsx new file mode 100644 index 000000000..99e2ec8ec --- /dev/null +++ b/examples/react-app/src/hooks/__tests__/useDisplayedWallet-test.browser.tsx @@ -0,0 +1,67 @@ +import { useConnectedWallet, useIsWalletReady } from '@solana/kit-plugin-wallet/react'; + +import { renderHook } from '../../__test-utils__/render'; +import { useDisplayedWallet } from '../useDisplayedWallet'; + +jest.mock('@solana/react', () => ({ useClient: jest.fn(() => ({})) })); +jest.mock('@solana/kit-plugin-wallet/react', () => ({ + useConnectedWallet: jest.fn(), + useIsWalletReady: jest.fn(), +})); + +const mockUseConnectedWallet = useConnectedWallet as jest.Mock; +const mockUseIsWalletReady = useIsWalletReady as jest.Mock; + +// Minimal shape — the hook only ever reads `.account.address` downstream. +function connection(address: string) { + return { account: { address }, signer: {}, wallet: {} } as unknown as ReturnType; +} + +describe('useDisplayedWallet', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns the live connection with isStale=false when ready', () => { + mockUseConnectedWallet.mockReturnValue(connection('AAAA')); + mockUseIsWalletReady.mockReturnValue(true); + const { result } = renderHook(() => useDisplayedWallet()); + expect(result.current.connected?.account.address).toBe('AAAA'); + expect(result.current.isStale).toBe(false); + }); + + it('holds the last settled connection with isStale=true while warming', () => { + mockUseConnectedWallet.mockReturnValue(connection('AAAA')); + mockUseIsWalletReady.mockReturnValue(true); + const { result, rerender } = renderHook(() => useDisplayedWallet()); + // Chain switch: new client is warming — connected goes null, not ready. + mockUseConnectedWallet.mockReturnValue(null); + mockUseIsWalletReady.mockReturnValue(false); + rerender(); + expect(result.current.connected?.account.address).toBe('AAAA'); // retained + expect(result.current.isStale).toBe(true); + }); + + it('adopts the new connection once warm-up completes', () => { + mockUseConnectedWallet.mockReturnValue(connection('AAAA')); + mockUseIsWalletReady.mockReturnValue(true); + const { result, rerender } = renderHook(() => useDisplayedWallet()); + mockUseConnectedWallet.mockReturnValue(null); + mockUseIsWalletReady.mockReturnValue(false); + rerender(); + mockUseConnectedWallet.mockReturnValue(connection('BBBB')); + mockUseIsWalletReady.mockReturnValue(true); + rerender(); + expect(result.current.connected?.account.address).toBe('BBBB'); + expect(result.current.isStale).toBe(false); + }); + + it('reflects a genuine disconnect once settled', () => { + mockUseConnectedWallet.mockReturnValue(connection('AAAA')); + mockUseIsWalletReady.mockReturnValue(true); + const { result, rerender } = renderHook(() => useDisplayedWallet()); + mockUseConnectedWallet.mockReturnValue(null); + mockUseIsWalletReady.mockReturnValue(true); // settled + disconnected + rerender(); + expect(result.current.connected).toBeNull(); + expect(result.current.isStale).toBe(false); + }); +}); diff --git a/examples/react-app/src/hooks/__tests__/useHasWalletSettled-test.browser.tsx b/examples/react-app/src/hooks/__tests__/useHasWalletSettled-test.browser.tsx new file mode 100644 index 000000000..6f5259a7a --- /dev/null +++ b/examples/react-app/src/hooks/__tests__/useHasWalletSettled-test.browser.tsx @@ -0,0 +1,30 @@ +import { useIsWalletReady } from '@solana/kit-plugin-wallet/react'; + +import { renderHook } from '../../__test-utils__/render'; +import { useHasWalletSettled } from '../useHasWalletSettled'; + +jest.mock('@solana/react', () => ({ useClient: jest.fn(() => ({})) })); +jest.mock('@solana/kit-plugin-wallet/react', () => ({ useIsWalletReady: jest.fn() })); + +const mockUseIsWalletReady = useIsWalletReady as jest.Mock; + +describe('useHasWalletSettled', () => { + beforeEach(() => jest.clearAllMocks()); + + it('is false before the first ready', () => { + mockUseIsWalletReady.mockReturnValue(false); + const { result } = renderHook(() => useHasWalletSettled()); + expect(result.current).toBe(false); + }); + + it('latches true on the first ready and never returns to false', () => { + mockUseIsWalletReady.mockReturnValue(false); + const { result, rerender } = renderHook(() => useHasWalletSettled()); + mockUseIsWalletReady.mockReturnValue(true); + rerender(); + expect(result.current).toBe(true); + mockUseIsWalletReady.mockReturnValue(false); // a later chain-switch warm-up + rerender(); + expect(result.current).toBe(true); + }); +}); diff --git a/examples/react-app/src/hooks/useDisplayedWallet.ts b/examples/react-app/src/hooks/useDisplayedWallet.ts new file mode 100644 index 000000000..0df1939eb --- /dev/null +++ b/examples/react-app/src/hooks/useDisplayedWallet.ts @@ -0,0 +1,32 @@ +/* eslint-disable react-hooks/refs */ +import { useConnectedWallet, useIsWalletReady } from '@solana/kit-plugin-wallet/react'; +import { useClient } from '@solana/react'; +import { useRef } from 'react'; + +import type { AppClient } from '../context/WalletClientProvider'; + +/** + * The connection to *display*, held stable across the wallet's warm-up. + * + * A chain switch rebuilds the client, and the new client reports `useConnectedWallet` as `null` + * while it silently reconnects. Rather than flash a disconnected state, this hook returns the last + * *settled* connection (retained in a ref that survives the client instance being swapped) and flags + * the window with `isStale`. Because the retained value belongs to the previous, now-disposed client, + * callers must render it read-only and block interaction while `isStale` (e.g. `disabled`, + * {@link Dimmable}). + * + * @returns `{ connected, isStale }` — `connected` is live when ready, otherwise the last settled + * value; `isStale` is `true` during warm-up. + * + * @see {@link useHasWalletSettled} — the first-load latch, deliberately a separate hook. + */ +export function useDisplayedWallet() { + const client = useClient(); + const connected = useConnectedWallet(client); + const isReady = useIsWalletReady(client); + const lastSettled = useRef(connected); + if (isReady) { + lastSettled.current = connected; + } + return { connected: isReady ? connected : lastSettled.current, isStale: !isReady }; +} diff --git a/examples/react-app/src/hooks/useHasWalletSettled.ts b/examples/react-app/src/hooks/useHasWalletSettled.ts new file mode 100644 index 000000000..58977f27d --- /dev/null +++ b/examples/react-app/src/hooks/useHasWalletSettled.ts @@ -0,0 +1,27 @@ +/* eslint-disable react-hooks/refs */ +import { useIsWalletReady } from '@solana/kit-plugin-wallet/react'; +import { useClient } from '@solana/react'; +import { useRef } from 'react'; + +import type { AppClient } from '../context/WalletClientProvider'; + +/** + * Whether the wallet has settled its initial auto-reconnect *at least once since this component + * mounted*. + * + * Latches `true` on the first ready and never reverts, so a first-load placeholder can show during + * the initial warm-up while later chain-switch warm-ups (handled by {@link useDisplayedWallet}'s + * dimming) never re-trigger it. Kept separate from `useDisplayedWallet` on purpose: this is a + * session-level latch with a different lifetime, and keeping it out lets `useDisplayedWallet` map + * cleanly onto a future plugin `reconnectingTo` primitive. + * + * @returns `false` until the wallet first becomes ready, then permanently `true`. + */ +export function useHasWalletSettled(): boolean { + const isReady = useIsWalletReady(useClient()); + const hasSettled = useRef(false); + if (isReady) { + hasSettled.current = true; + } + return hasSettled.current; +} diff --git a/examples/react-app/src/main.tsx b/examples/react-app/src/main.tsx index 669ce10aa..89f962aac 100644 --- a/examples/react-app/src/main.tsx +++ b/examples/react-app/src/main.tsx @@ -1,23 +1,15 @@ 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, Theme } from '@radix-ui/themes'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { GatedRoot } from './components/GatedRoot.tsx'; import { Nav } from './components/Nav.tsx'; import { ChainContextProvider } from './context/ChainContextProvider.tsx'; import { RpcContextProvider } from './context/RpcContextProvider.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), -}; +import { WalletClientProvider } from './context/WalletClientProvider.tsx'; const rootNode = document.getElementById('root')!; const root = createRoot(rootNode); @@ -25,16 +17,14 @@ root.render( - true} stateSync={stateSync}> - + +