Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slick-rabbits-admire.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions examples/react-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,20 @@
"@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",
"swr": "^2.4.2"
},
"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:*",
Expand Down
2 changes: 1 addition & 1 deletion examples/react-app/src/components/Balance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
85 changes: 37 additions & 48 deletions examples/react-app/src/components/ConnectWalletMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<{
Expand All @@ -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<AppClient>();
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.
Comment on lines +27 to +28

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

function renderItem(wallet: UiWallet) {
return (
<ErrorBoundary
fallbackRender={({ error }) => <UnconnectableWalletMenuItem error={error} wallet={wallet} />}
<ConnectWalletMenuItem
key={`wallet:${wallet.name}`}
>
<ConnectWalletMenuItem
onAccountSelect={account => {
setSelectedWalletAccount(account);
setForceClose(true);
}}
onDisconnect={wallet => {
if (selectedWalletAccount && uiWalletAccountBelongsToUiWallet(selectedWalletAccount, wallet)) {
setSelectedWalletAccount(undefined);
}
}}
onError={setError}
wallet={wallet}
/>
</ErrorBoundary>
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 (
<>
<DropdownMenu.Root open={forceClose ? false : undefined} onOpenChange={setForceClose.bind(null, false)}>
<DropdownMenu.Trigger>
<Button>
{selectedWalletAccount ? (
<Button
aria-busy={isStale}
disabled={isStale}
style={{
opacity: isStale ? 0.5 : undefined,
pointerEvents: isStale ? 'none' : undefined,
transition: 'opacity 150ms',
}}
>
{connected ? (
<>
<WalletAccountIcon account={selectedWalletAccount} width="18" height="18" />
{selectedWalletAccount.address.slice(0, 8)}
<WalletAccountIcon account={connected.account} width="18" height="18" />
{connected.account.address.slice(0, 8)}
</>
) : (
children
Expand All @@ -74,18 +66,15 @@ export function ConnectWalletMenu({ children }: Props) {
<Callout.Icon>
<ExclamationTriangleIcon />
</Callout.Icon>
<Callout.Text>This browser has no wallets installed.</Callout.Text>
{/* Wallets that don't support `standard:connect` on the current
chain are filtered out by the plugin, so an empty list doesn't
necessarily mean no wallets are installed. */}
<Callout.Text>
This browser has no wallets installed that support {currentChainName}.
</Callout.Text>
</Callout.Root>
) : (
<>
{walletsThatSupportStandardConnect.map(renderItem)}
{unconnectableWallets.length ? (
<>
<DropdownMenu.Separator />
{unconnectableWallets.map(renderItem)}
</>
) : null}
</>
wallets.map(renderItem)
)}
</DropdownMenu.Content>
</DropdownMenu.Root>
Expand Down
114 changes: 74 additions & 40 deletions examples/react-app/src/components/ConnectWalletMenuItem.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,95 @@
import { DropdownMenu } from '@radix-ui/themes';
import { useSelectedWalletAccount } from '@solana/react';
import type { UiWallet, UiWalletAccount } from '@wallet-standard/react';
import { uiWalletAccountsAreSame, useConnect, useDisconnect } from '@wallet-standard/react';
import { useCallback } from 'react';
import { isAbortError } from '@solana/kit';
import { useConnect, useConnectedWallet, useDisconnect, useSelectAccount } from '@solana/kit-plugin-wallet/react';
import { useClient } from '@solana/react';
import { StandardDisconnect } from '@wallet-standard/core';
import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui';
import { uiWalletAccountBelongsToUiWallet } from '@wallet-standard/ui';

import type { AppClient } from '../context/WalletClientProvider';
import { WalletMenuItemContent } from './WalletMenuItemContent';

type Props = Readonly<{
onAccountSelect(account: UiWalletAccount | undefined): void;
onDisconnect(wallet: UiWallet): void;
onAccountSelect(): void;
onError(err: unknown): void;
wallet: UiWallet;
}>;

export function ConnectWalletMenuItem({ onAccountSelect, onDisconnect, onError, wallet }: Props) {
const [isConnecting, connect] = useConnect(wallet);
const [isDisconnecting, disconnect] = useDisconnect(wallet);
const isPending = isConnecting || isDisconnecting;
export function ConnectWalletMenuItem({ onAccountSelect, onError, wallet }: Props) {
const client = useClient<AppClient>();
const connect = useConnect(client);
const disconnect = useDisconnect(client);
const selectAccount = useSelectAccount(client);
const connected = useConnectedWallet(client);
const isPending = connect.isRunning || disconnect.isRunning;
const isConnected = wallet.accounts.length > 0;
const [selectedWalletAccount] = useSelectedWalletAccount();
const handleConnectClick = useCallback(async () => {
// "Active" = the account currently driving the app's feature panels belongs to this wallet.
const isActiveWallet = connected != null && uiWalletAccountBelongsToUiWallet(connected.account, wallet);
// The active wallet can always be disconnected — the plugin tears the connection down locally
// even when the wallet lacks `standard:disconnect`. Deauthorizing a *non-active* wallet
// requires the feature, and without it the plugin's disconnect is a forgiving no-op — so
// don't offer an action that would do nothing.
const canDisconnect = isActiveWallet || wallet.features.includes(StandardDisconnect);

async function connectWallet() {
try {
const existingAccounts = [...wallet.accounts];
const nextAccounts = await connect();
// Try to choose the first never-before-seen account.
for (const nextAccount of nextAccounts) {
if (!existingAccounts.some(existingAccount => uiWalletAccountsAreSame(nextAccount, existingAccount))) {
onAccountSelect(nextAccount);
return;
}
}
// Failing that, choose the first account in the list.
if (nextAccounts[0]) {
onAccountSelect(nextAccounts[0]);
await connect.dispatchAsync(wallet);
// Connecting establishes the active connection
onAccountSelect();
} catch (e) {
// Filter out abort error, which just means a later connect superseded
if (!isAbortError(e)) {
onError(e);
}
}
}

function chooseAccount(account: UiWalletAccount) {
try {
// selectAccount is synchronous
selectAccount(account);
onAccountSelect();
} catch (e) {
onError(e);
}
}, [connect, onAccountSelect, onError, wallet.accounts]);
}

async function disconnectWallet() {
try {
// If this is the active wallet, it is fully disconnected; a non-active
// authorized wallet is deauthorized while the active connection stays put.
await disconnect.dispatchAsync(wallet);
} catch (e) {
if (!isAbortError(e)) {
onError(e);
}
}
}

return (
<DropdownMenu.Sub open={!isConnected ? false : undefined}>
<DropdownMenu.SubTrigger disabled={isPending} onClick={!isConnected ? handleConnectClick : undefined}>
<DropdownMenu.SubTrigger
disabled={isPending}
onClick={
!isConnected
? () => {
void connectWallet();
}
: undefined
}
>
<WalletMenuItemContent loading={isPending} wallet={wallet} />
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Label>Accounts</DropdownMenu.Label>
<DropdownMenu.RadioGroup value={selectedWalletAccount?.address}>
<DropdownMenu.RadioGroup value={isActiveWallet ? connected.account.address : undefined}>
{wallet.accounts.map(account => (
<DropdownMenu.RadioItem
key={account.address}
value={account.address}
onSelect={() => {
onAccountSelect(account);
onSelect={event => {
event.preventDefault();
chooseAccount(account);
}}
>
{account.address.slice(0, 8)}&hellip;
Expand All @@ -60,23 +98,19 @@ export function ConnectWalletMenuItem({ onAccountSelect, onDisconnect, onError,
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={async e => {
e.preventDefault();
await handleConnectClick();
onSelect={event => {
event.preventDefault();
void connectWallet();
}}
>
Connect More
</DropdownMenu.Item>
<DropdownMenu.Item
color="red"
onSelect={async e => {
e.preventDefault();
try {
await disconnect();
onDisconnect(wallet);
} catch (e) {
onError(e);
}
disabled={!canDisconnect}
onSelect={event => {
event.preventDefault();
void disconnectWallet();
}}
>
Disconnect
Expand Down
32 changes: 32 additions & 0 deletions examples/react-app/src/components/Dimmable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Box } from '@radix-ui/themes';
import type { ReactNode } from 'react';

type Props = Readonly<{
busy: boolean;
children: ReactNode;
}>;

/**
* Dims its subtree and disables pointer interaction while `busy`.
*
* Used to hold wallet-dependent UI on screen during a chain-switch warm-up: the previous account
* stays visible (via {@link useDisplayedWallet}) but greyed out. The `pointer-events: none` is
* important — the displayed connection belongs to the previous, disposed client,
* so it must not be acted on until the new client settles.
*
* @param busy - When `true`, dim to 50% and block pointer events; also sets `aria-busy`.
*/
export function Dimmable({ busy, children }: Props) {
return (
<Box
aria-busy={busy}
style={{
opacity: busy ? 0.5 : 1,
pointerEvents: busy ? 'none' : undefined,
transition: 'opacity 150ms',
}}
>
{children}
</Box>
);
}
Loading
Loading