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
1 change: 1 addition & 0 deletions examples/react-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@radix-ui/themes": "3.3.0",
"@solana-program/system": "^0.13.0",
"@solana/kit": "workspace:*",
"@solana/kit-plugin-rpc": "0.13.0",
"@solana/kit-plugin-wallet": "0.14.0",
"@solana/react": "workspace:*",
"@wallet-standard/core": "^1.1.2",
Expand Down
6 changes: 3 additions & 3 deletions examples/react-app/src/components/AirdropButton.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { Blockquote, Button, Dialog, Flex, Link, Text } from '@radix-ui/themes';
import { Address, airdropFactory, lamports, Rpc, SolanaRpcApi } from '@solana/kit';
import { useAction } from '@solana/react';
import { useAction, useClient } from '@solana/react';
import { useContext, useMemo } from 'react';

import { ChainContext } from '../context/ChainContext';
import { RpcContext } from '../context/RpcContext';
import type { AppClient } from '../context/ClientProvider';
import { ErrorDialog } from './ErrorDialog';

export function AirdropButton({ address }: { address: Address }) {
const { chain, solanaExplorerClusterName } = useContext(ChainContext);
const { rpc, rpcSubscriptions } = useContext(RpcContext);
const { rpc, rpcSubscriptions } = useClient<AppClient>();

const isMainnet = chain === 'solana:mainnet';
Comment on lines 10 to 14

Expand Down
13 changes: 8 additions & 5 deletions examples/react-app/src/components/Balance.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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 { useClient } from '@solana/react';
import { useTrackedDataSWR } from '@solana/react/swr';
import type { UiWalletAccount } from '@wallet-standard/ui';
import { useContext, useMemo } from 'react';
import { useMemo } from 'react';

import { ChainContext } from '../context/ChainContext';
import { RpcContext } from '../context/RpcContext';
import type { AppClient } from '../context/ClientProvider';
import { getErrorMessage } from '../errors';

type Props = Readonly<{
Expand All @@ -16,8 +16,11 @@ type Props = Readonly<{
const solFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 5 });

export function Balance({ account }: Props) {
const { chain } = useContext(ChainContext);
const { rpc, rpcSubscriptions } = useContext(RpcContext);
// Read `chain` off the client (not `ChainContext`) so the SWR cache key below and the `rpc` that
// fills it always come from the same client instance. `ClientProvider` rebuilds the client one
// render after `ChainContext` flips, so a key derived from `ChainContext` would bind the new
// network's fetch to the previous network's rpc.
const { chain, rpc, rpcSubscriptions } = useClient<AppClient>();
const accountAddress = useMemo(() => address(account.address), [account.address]);
const spec = useMemo(
() => ({
Expand Down
2 changes: 1 addition & 1 deletion examples/react-app/src/components/ConnectWalletMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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 type { AppClient } from '../context/ClientProvider';
import { useDisplayedWallet } from '../hooks/useDisplayedWallet';
import { ConnectWalletMenuItem } from './ConnectWalletMenuItem';
import { ErrorDialog } from './ErrorDialog';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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 type { AppClient } from '../context/ClientProvider';
import { WalletMenuItemContent } from './WalletMenuItemContent';

type Props = Readonly<{
Expand Down
2 changes: 1 addition & 1 deletion examples/react-app/src/components/SignInMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { SolanaSignIn } from '@solana/wallet-standard-features';
import type { UiWallet } from '@wallet-standard/ui';
import { useRef, useState } from 'react';

import type { AppClient } from '../context/WalletClientProvider';
import type { AppClient } from '../context/ClientProvider';
import { ErrorDialog } from './ErrorDialog';
import { SignInMenuItem } from './SignInMenuItem';

Expand Down
2 changes: 1 addition & 1 deletion examples/react-app/src/components/SignInMenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useClient } from '@solana/react';
import type { UiWallet } from '@wallet-standard/ui';
import type { MouseEvent } from 'react';

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

type Props = Readonly<{
Expand Down
6 changes: 3 additions & 3 deletions examples/react-app/src/components/SlotIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Flex, IconButton, Link, Text, Tooltip } from '@radix-ui/themes';
import { useSubscription } from '@solana/react';
import { useClient, useSubscription } from '@solana/react';
import { useContext, useMemo } from 'react';

import { ChainContext } from '../context/ChainContext';
import { RpcContext } from '../context/RpcContext';
import type { AppClient } from '../context/ClientProvider';
import { getErrorMessage } from '../errors';

const slotFormatter = new Intl.NumberFormat();

export function SlotIndicator() {
const { rpcSubscriptions } = useContext(RpcContext);
const { rpcSubscriptions } = useClient<AppClient>();
const { solanaExplorerClusterName } = useContext(ChainContext);
const source = useMemo(() => rpcSubscriptions.slotNotifications(), [rpcSubscriptions]);
Comment on lines 12 to 15
const { data, error, reconnect } = useSubscription(source);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ 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 type { AppClient } from '../context/ClientProvider';
import { solStringToLamports } from '../lamports';
import signerBytes from '../signerBytes.json' with { type: 'json' };
import { assertCanSignTransactions } from '../walletCapability';
Expand All @@ -50,12 +49,12 @@ async function mockApiRequest(serializedTransaction: ReadonlyUint8Array): Promis
}

export function SolanaPartialSignTransactionFeaturePanel({ signer }: Props) {
const { rpc, rpcSubscriptions } = useContext(RpcContext);
const client = useClient<AppClient>();
const { rpc, rpcSubscriptions } = client;
const sendAndConfirmTransaction = useMemo(
() => sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }),
[rpc, rpcSubscriptions],
);
const client = useClient<AppClient>();
const wallets = useWallets(client);
const [solQuantityString, setSolQuantityString] = useState<string>('');
const [recipientAccountStorageKey, setRecipientAccountStorageKey] = useState<string | undefined>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ 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 type { AppClient } from '../context/ClientProvider';
import { solStringToLamports } from '../lamports';
import { assertCanSignAndSendTransactions } from '../walletCapability';
import { ErrorDialog } from './ErrorDialog';
Expand All @@ -31,8 +30,8 @@ type Props = Readonly<{
}>;

export function SolanaSignAndSendTransactionFeaturePanel({ signer }: Props) {
const { rpc } = useContext(RpcContext);
const client = useClient<AppClient>();
const { rpc } = client;
const wallets = useWallets(client);
const [solQuantityString, setSolQuantityString] = useState<string>('');
const [recipientAccountStorageKey, setRecipientAccountStorageKey] = useState<string | undefined>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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 type { AppClient } from '../context/ClientProvider';
import { assertCanSignMessages } from '../walletCapability';
import { BaseSignMessageFeaturePanel } from './BaseSignMessageFeaturePanel';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ 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 type { AppClient } from '../context/ClientProvider';
import { solStringToLamports } from '../lamports';
import { assertCanSignTransactions } from '../walletCapability';
import { ErrorDialog } from './ErrorDialog';
Expand All @@ -33,12 +32,12 @@ type Props = Readonly<{
}>;

export function SolanaSignTransactionFeaturePanel({ signer }: Props) {
const { rpc, rpcSubscriptions } = useContext(RpcContext);
const client = useClient<AppClient>();
const { rpc, rpcSubscriptions } = client;
const sendAndConfirmTransaction = useMemo(
() => sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }),
[rpc, rpcSubscriptions],
);
const client = useClient<AppClient>();
const wallets = useWallets(client);
const [solQuantityString, setSolQuantityString] = useState<string>('');
const [recipientAccountStorageKey, setRecipientAccountStorageKey] = useState<string | undefined>();
Expand Down
2 changes: 1 addition & 1 deletion examples/react-app/src/components/WalletAccountIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { UiWalletAccount } from '@wallet-standard/ui';
import { uiWalletAccountBelongsToUiWallet } from '@wallet-standard/ui';
import React from 'react';

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

type Props = React.ComponentProps<'img'> &
Readonly<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import type {
SolanaRpcSubscriptionsApi,
} from '@solana/kit';
import { createReactiveActionStore, createReactiveStoreFromDataPublisherFactory } from '@solana/kit';
import { ClientProvider } from '@solana/react';
import { act, waitFor } from '@testing-library/react';
import type { UiWalletAccount } from '@wallet-standard/ui';
import React from 'react';
import { SWRConfig } from 'swr';

import { render } from '../../__test-utils__/render';
import { ChainContext, DEFAULT_CHAIN_CONFIG } from '../../context/ChainContext';
import { RpcContext } from '../../context/RpcContext';
import type { AppClient } from '../../context/ClientProvider';
import { Balance } from '../Balance';

type LamportsResponse = SolanaRpcResponse<Lamports>;
Expand Down Expand Up @@ -105,13 +106,12 @@ function makeWrapper({
rpc: Rpc<SolanaRpcApiMainnet>;
rpcSubscriptions: RpcSubscriptions<SolanaRpcSubscriptionsApi>;
}) {
const client = { chain: DEFAULT_CHAIN_CONFIG.chain, rpc, rpcSubscriptions } as unknown as AppClient;
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<Theme>
<SWRConfig value={{ provider: () => new Map() }}>
<ChainContext.Provider value={DEFAULT_CHAIN_CONFIG}>
<RpcContext.Provider value={{ rpc, rpcSubscriptions }}>{children}</RpcContext.Provider>
</ChainContext.Provider>
<ClientProvider client={client}>{children}</ClientProvider>
</SWRConfig>
</Theme>
);
Expand Down Expand Up @@ -183,6 +183,62 @@ describe('Balance', () => {
await waitFor(() => expect(container.querySelector('svg')).not.toBeNull());
});

it('refetches against the new network even when the selected chain leads the client swap', async () => {
const swrCache = new Map();
const provider = () => swrCache;
const devnet = makeMockRpc();
const testnet = makeMockRpc();
const devnetSubscriptions = makeMockSubscriptions();
const testnetSubscriptions = makeMockSubscriptions();
const devnetClient = {
chain: 'solana:devnet',
rpc: devnet.rpc,
rpcSubscriptions: devnetSubscriptions.rpcSubscriptions,
} as unknown as AppClient;
const testnetClient = {
chain: 'solana:testnet',
rpc: testnet.rpc,
rpcSubscriptions: testnetSubscriptions.rpcSubscriptions,
} as unknown as AppClient;
// The real app rebuilds the client in a layout effect, so on a chain switch `ChainContext`
// (the eagerly-updated *selected* chain) leads the freshly-published client by one render.
// `contextChain` and `client` are separate props here so the test can freeze that lag: the
// middle render has `ChainContext` already on testnet while `client` is still the devnet one.
const tree = (contextChain: string, client: AppClient) => (
<Theme>
<SWRConfig value={{ provider }}>
<ChainContext.Provider
value={{ ...DEFAULT_CHAIN_CONFIG, chain: contextChain as typeof DEFAULT_CHAIN_CONFIG.chain }}
>
<ClientProvider client={client}>
<Balance account={makeAccount()} />
</ClientProvider>
</ChainContext.Provider>
</SWRConfig>
</Theme>
);

const { container, rerender } = render(tree('solana:devnet', devnetClient));
await act(async () => {
devnet.resolveGetBalance(lamportsResponse(100, 1_000_000_000n));
await jest.runAllTimersAsync();
});
await waitFor(() => expect(container.textContent).toBe('1 ◎'));

// Lag render: `ChainContext` flips to testnet, but the client is still devnet. If `Balance`
// derived its SWR key from `ChainContext`, the key would change *now* and bind the fetch to
// the stale devnet rpc — and because the key never changes again once the client catches up,
// the UI would stay stuck on the devnet value. Deriving the key from `client.chain` keeps the
// key and the rpc that fills it on the same object, so the swap happens in one step below.
rerender(tree('solana:testnet', devnetClient));
rerender(tree('solana:testnet', testnetClient));
await act(async () => {
testnet.resolveGetBalance(lamportsResponse(200, 2_000_000_000n));
await jest.runAllTimersAsync();
});
await waitFor(() => expect(container.textContent).toBe('2 ◎'));
});

it('keeps showing the last known balance when the subscription later errors', async () => {
const { rpc, resolveGetBalance } = makeMockRpc();
const { rpcSubscriptions, pushError } = makeMockSubscriptions();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Theme } from '@radix-ui/themes';
import type { Rpc, RpcSubscriptions, SolanaRpcApiMainnet, SolanaRpcSubscriptionsApi } from '@solana/kit';
import { ClientProvider } from '@solana/react';
import { act, waitFor } from '@testing-library/react';
import React from 'react';

import { render } from '../../__test-utils__/render';
import { ChainContext, DEFAULT_CHAIN_CONFIG } from '../../context/ChainContext';
import { RpcContext } from '../../context/RpcContext';
import type { AppClient } from '../../context/ClientProvider';
import { SlotIndicator } from '../SlotIndicator';

type SlotNotification = Readonly<{ parent: bigint; root: bigint; slot: bigint }>;
Expand Down Expand Up @@ -59,11 +60,12 @@ function makeMockRpcSubscriptions(reactiveStore: jest.Mock) {

function makeWrapper(rpcSubscriptions: RpcSubscriptions<SolanaRpcSubscriptionsApi>) {
const rpc = {} as Rpc<SolanaRpcApiMainnet>;
const client = { rpc, rpcSubscriptions } as unknown as AppClient;
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<Theme>
<ChainContext.Provider value={DEFAULT_CHAIN_CONFIG}>
<RpcContext.Provider value={{ rpc, rpcSubscriptions }}>{children}</RpcContext.Provider>
<ClientProvider client={client}>{children}</ClientProvider>
</ChainContext.Provider>
</Theme>
);
Expand Down
66 changes: 66 additions & 0 deletions examples/react-app/src/context/ClientProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { ClusterUrl } from '@solana/kit';
import { createClient, extendClient } from '@solana/kit';
import { solanaRpc } from '@solana/kit-plugin-rpc';
import { walletSigner } from '@solana/kit-plugin-wallet';
import { ClientProvider as KitClientProvider } 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 buildClient(chain: SolanaChain, rpcUrl: ClusterUrl) {
return (
createClient()
.use(walletSigner({ chain }))
// Only `rpcUrl` is passed; `solanaRpc` derives the subscriptions URL from it by swapping
// the protocol to `ws`/`wss`.
.use(solanaRpc({ rpcUrl }))
// Stamp the target `chain` onto the client so consumers can read it back off `useClient()`
// *in lockstep with* `rpc`/`rpcSubscriptions`. This is load-bearing, not decorative: on a
// chain switch `ChainContext` (the eagerly-updated *selected* chain) flips one render
// before the rebuilt client is published, so anything that must move with the client's rpc
// — most importantly `Balance`'s SWR cache key — has to derive `chain` from the client, not
// `ChainContext`, or it would key the new network's fetch against the old network's rpc.
.use(client => extendClient(client, { chain }))
);
}

/**
* The concrete Kit client type published by {@link ClientProvider} — a base client with the wallet
* and RPC plugins installed, plus the active `chain` via {@link extendClient}. Pass it as the type
* argument to `useClient` wherever this app reads the client from context (e.g.
* `useClient<AppClient>()`) so every installed plugin's namespace (the wallet signer, `rpc`,
* `rpcSubscriptions`, …) and the `chain` are typed.
*/
export type AppClient = ReturnType<typeof buildClient>;

/**
* Builds a Kit client with the wallet and RPC plugins installed and publishes it via
* `@solana/react`'s `ClientProvider`, rebuilding on chain change.
*
* Each wallet plugin is bound to a single chain — and each chain has its own RPC endpoints — 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 ClientProvider({ children }: Props) {
const { chain, solanaRpcUrl } = useContext(ChainContext);
const [client, setClient] = useState<AppClient | null>(null);
useLayoutEffect(() => {
const next = buildClient(chain, solanaRpcUrl);
// 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, solanaRpcUrl]);
if (!client) {
// Only the pre-layout-effect render pass lands here; it is never painted.
return null;
}
return <KitClientProvider client={client}>{children}</KitClientProvider>;
}
Loading
Loading