From 1ba30bd830c793d1c4d8614d3420b835df072a23 Mon Sep 17 00:00:00 2001 From: Callum Date: Tue, 14 Jul 2026 16:47:14 +0000 Subject: [PATCH] Add the RPC plugin to the react-app example --- examples/react-app/package.json | 1 + .../src/components/AirdropButton.tsx | 6 +- examples/react-app/src/components/Balance.tsx | 13 ++-- .../src/components/ConnectWalletMenu.tsx | 2 +- .../src/components/ConnectWalletMenuItem.tsx | 2 +- .../react-app/src/components/SignInMenu.tsx | 2 +- .../src/components/SignInMenuItem.tsx | 2 +- .../src/components/SlotIndicator.tsx | 6 +- ...lanaPartialSignTransactionFeaturePanel.tsx | 7 +- ...lanaSignAndSendTransactionFeaturePanel.tsx | 5 +- .../SolanaSignMessageFeaturePanel.tsx | 2 +- .../SolanaSignTransactionFeaturePanel.tsx | 7 +- .../src/components/WalletAccountIcon.tsx | 2 +- .../__tests__/Balance-test.browser.tsx | 64 ++++++++++++++++-- .../__tests__/SlotIndicator-test.browser.tsx | 6 +- .../react-app/src/context/ClientProvider.tsx | 66 +++++++++++++++++++ examples/react-app/src/context/RpcContext.tsx | 11 ---- .../src/context/RpcContextProvider.tsx | 26 -------- .../src/context/WalletClientProvider.tsx | 49 -------------- ...er.tsx => ClientProvider-test.browser.tsx} | 26 ++++++-- .../react-app/src/hooks/useDisplayedWallet.ts | 2 +- .../src/hooks/useHasWalletSettled.ts | 2 +- examples/react-app/src/main.tsx | 17 ++--- examples/react-app/src/routes/root.tsx | 10 ++- pnpm-lock.yaml | 22 +++++++ 25 files changed, 216 insertions(+), 142 deletions(-) create mode 100644 examples/react-app/src/context/ClientProvider.tsx delete mode 100644 examples/react-app/src/context/RpcContext.tsx delete mode 100644 examples/react-app/src/context/RpcContextProvider.tsx delete mode 100644 examples/react-app/src/context/WalletClientProvider.tsx rename examples/react-app/src/context/__tests__/{WalletClientProvider-test.browser.tsx => ClientProvider-test.browser.tsx} (74%) diff --git a/examples/react-app/package.json b/examples/react-app/package.json index 81c224743..9c35417d1 100644 --- a/examples/react-app/package.json +++ b/examples/react-app/package.json @@ -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", diff --git a/examples/react-app/src/components/AirdropButton.tsx b/examples/react-app/src/components/AirdropButton.tsx index ac56844ca..e451f5d59 100644 --- a/examples/react-app/src/components/AirdropButton.tsx +++ b/examples/react-app/src/components/AirdropButton.tsx @@ -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(); const isMainnet = chain === 'solana:mainnet'; diff --git a/examples/react-app/src/components/Balance.tsx b/examples/react-app/src/components/Balance.tsx index 2f95a2be1..3bf1399c1 100644 --- a/examples/react-app/src/components/Balance.tsx +++ b/examples/react-app/src/components/Balance.tsx @@ -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<{ @@ -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(); const accountAddress = useMemo(() => address(account.address), [account.address]); const spec = useMemo( () => ({ diff --git a/examples/react-app/src/components/ConnectWalletMenu.tsx b/examples/react-app/src/components/ConnectWalletMenu.tsx index 231dcc1c2..04245895f 100644 --- a/examples/react-app/src/components/ConnectWalletMenu.tsx +++ b/examples/react-app/src/components/ConnectWalletMenu.tsx @@ -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'; diff --git a/examples/react-app/src/components/ConnectWalletMenuItem.tsx b/examples/react-app/src/components/ConnectWalletMenuItem.tsx index e0d09e334..2802aa4eb 100644 --- a/examples/react-app/src/components/ConnectWalletMenuItem.tsx +++ b/examples/react-app/src/components/ConnectWalletMenuItem.tsx @@ -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<{ diff --git a/examples/react-app/src/components/SignInMenu.tsx b/examples/react-app/src/components/SignInMenu.tsx index 19abed150..308de54d4 100644 --- a/examples/react-app/src/components/SignInMenu.tsx +++ b/examples/react-app/src/components/SignInMenu.tsx @@ -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'; diff --git a/examples/react-app/src/components/SignInMenuItem.tsx b/examples/react-app/src/components/SignInMenuItem.tsx index 6fb3995e5..f1a83a267 100644 --- a/examples/react-app/src/components/SignInMenuItem.tsx +++ b/examples/react-app/src/components/SignInMenuItem.tsx @@ -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<{ diff --git a/examples/react-app/src/components/SlotIndicator.tsx b/examples/react-app/src/components/SlotIndicator.tsx index ffe84b235..775312596 100644 --- a/examples/react-app/src/components/SlotIndicator.tsx +++ b/examples/react-app/src/components/SlotIndicator.tsx @@ -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(); const { solanaExplorerClusterName } = useContext(ChainContext); const source = useMemo(() => rpcSubscriptions.slotNotifications(), [rpcSubscriptions]); const { data, error, reconnect } = useSubscription(source); diff --git a/examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx b/examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx index 23c405c50..d66e441db 100644 --- a/examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx +++ b/examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx @@ -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'; @@ -50,12 +49,12 @@ async function mockApiRequest(serializedTransaction: ReadonlyUint8Array): Promis } export function SolanaPartialSignTransactionFeaturePanel({ signer }: Props) { - const { rpc, rpcSubscriptions } = useContext(RpcContext); + const client = useClient(); + const { rpc, rpcSubscriptions } = client; const sendAndConfirmTransaction = useMemo( () => sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }), [rpc, rpcSubscriptions], ); - const client = useClient(); const wallets = useWallets(client); const [solQuantityString, setSolQuantityString] = useState(''); const [recipientAccountStorageKey, setRecipientAccountStorageKey] = useState(); diff --git a/examples/react-app/src/components/SolanaSignAndSendTransactionFeaturePanel.tsx b/examples/react-app/src/components/SolanaSignAndSendTransactionFeaturePanel.tsx index 5670327a7..f34af6368 100644 --- a/examples/react-app/src/components/SolanaSignAndSendTransactionFeaturePanel.tsx +++ b/examples/react-app/src/components/SolanaSignAndSendTransactionFeaturePanel.tsx @@ -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'; @@ -31,8 +30,8 @@ type Props = Readonly<{ }>; export function SolanaSignAndSendTransactionFeaturePanel({ signer }: Props) { - const { rpc } = useContext(RpcContext); const client = useClient(); + const { rpc } = client; const wallets = useWallets(client); const [solQuantityString, setSolQuantityString] = useState(''); const [recipientAccountStorageKey, setRecipientAccountStorageKey] = useState(); diff --git a/examples/react-app/src/components/SolanaSignMessageFeaturePanel.tsx b/examples/react-app/src/components/SolanaSignMessageFeaturePanel.tsx index 079f5f827..58a072ff0 100644 --- a/examples/react-app/src/components/SolanaSignMessageFeaturePanel.tsx +++ b/examples/react-app/src/components/SolanaSignMessageFeaturePanel.tsx @@ -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'; diff --git a/examples/react-app/src/components/SolanaSignTransactionFeaturePanel.tsx b/examples/react-app/src/components/SolanaSignTransactionFeaturePanel.tsx index 73c1ef400..7658b8ed2 100644 --- a/examples/react-app/src/components/SolanaSignTransactionFeaturePanel.tsx +++ b/examples/react-app/src/components/SolanaSignTransactionFeaturePanel.tsx @@ -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'; @@ -33,12 +32,12 @@ type Props = Readonly<{ }>; export function SolanaSignTransactionFeaturePanel({ signer }: Props) { - const { rpc, rpcSubscriptions } = useContext(RpcContext); + const client = useClient(); + const { rpc, rpcSubscriptions } = client; const sendAndConfirmTransaction = useMemo( () => sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }), [rpc, rpcSubscriptions], ); - const client = useClient(); const wallets = useWallets(client); const [solQuantityString, setSolQuantityString] = useState(''); const [recipientAccountStorageKey, setRecipientAccountStorageKey] = useState(); diff --git a/examples/react-app/src/components/WalletAccountIcon.tsx b/examples/react-app/src/components/WalletAccountIcon.tsx index 6a4cdaf1f..e88c08863 100644 --- a/examples/react-app/src/components/WalletAccountIcon.tsx +++ b/examples/react-app/src/components/WalletAccountIcon.tsx @@ -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<{ 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 a09281766..17632b477 100644 --- a/examples/react-app/src/components/__tests__/Balance-test.browser.tsx +++ b/examples/react-app/src/components/__tests__/Balance-test.browser.tsx @@ -9,6 +9,7 @@ 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'; @@ -16,7 +17,7 @@ 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; @@ -105,13 +106,12 @@ function makeWrapper({ rpc: Rpc; rpcSubscriptions: RpcSubscriptions; }) { + const client = { chain: DEFAULT_CHAIN_CONFIG.chain, rpc, rpcSubscriptions } as unknown as AppClient; return function Wrapper({ children }: { children: React.ReactNode }) { return ( new Map() }}> - - {children} - + {children} ); @@ -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) => ( + + + + + + + + + + ); + + 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(); diff --git a/examples/react-app/src/components/__tests__/SlotIndicator-test.browser.tsx b/examples/react-app/src/components/__tests__/SlotIndicator-test.browser.tsx index 0bbccd1c2..db2839303 100644 --- a/examples/react-app/src/components/__tests__/SlotIndicator-test.browser.tsx +++ b/examples/react-app/src/components/__tests__/SlotIndicator-test.browser.tsx @@ -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 }>; @@ -59,11 +60,12 @@ function makeMockRpcSubscriptions(reactiveStore: jest.Mock) { function makeWrapper(rpcSubscriptions: RpcSubscriptions) { const rpc = {} as Rpc; + const client = { rpc, rpcSubscriptions } as unknown as AppClient; return function Wrapper({ children }: { children: React.ReactNode }) { return ( - {children} + {children} ); diff --git a/examples/react-app/src/context/ClientProvider.tsx b/examples/react-app/src/context/ClientProvider.tsx new file mode 100644 index 000000000..d76294f83 --- /dev/null +++ b/examples/react-app/src/context/ClientProvider.tsx @@ -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()`) so every installed plugin's namespace (the wallet signer, `rpc`, + * `rpcSubscriptions`, …) and the `chain` are typed. + */ +export type AppClient = ReturnType; + +/** + * 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(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 {children}; +} diff --git a/examples/react-app/src/context/RpcContext.tsx b/examples/react-app/src/context/RpcContext.tsx deleted file mode 100644 index 396eb0d2d..000000000 --- a/examples/react-app/src/context/RpcContext.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { Rpc, RpcSubscriptions, SolanaRpcApiMainnet, SolanaRpcSubscriptionsApi } from '@solana/kit'; -import { createSolanaRpc, createSolanaRpcSubscriptions, devnet } from '@solana/kit'; -import { createContext } from 'react'; - -export const RpcContext = createContext<{ - rpc: Rpc; // Limit the API to only those methods found on Mainnet (ie. not `requestAirdrop`) - rpcSubscriptions: RpcSubscriptions; -}>({ - rpc: createSolanaRpc(devnet('https://api.devnet.solana.com')), - rpcSubscriptions: createSolanaRpcSubscriptions(devnet('wss://api.devnet.solana.com')), -}); diff --git a/examples/react-app/src/context/RpcContextProvider.tsx b/examples/react-app/src/context/RpcContextProvider.tsx deleted file mode 100644 index a93eb17e5..000000000 --- a/examples/react-app/src/context/RpcContextProvider.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { createSolanaRpc, createSolanaRpcSubscriptions } from '@solana/kit'; -import { ReactNode, useContext, useMemo } from 'react'; - -import { ChainContext } from './ChainContext'; -import { RpcContext } from './RpcContext'; - -type Props = Readonly<{ - children: ReactNode; -}>; - -export function RpcContextProvider({ children }: Props) { - const { solanaRpcSubscriptionsUrl, solanaRpcUrl } = useContext(ChainContext); - return ( - ({ - rpc: createSolanaRpc(solanaRpcUrl), - rpcSubscriptions: createSolanaRpcSubscriptions(solanaRpcSubscriptionsUrl), - }), - [solanaRpcSubscriptionsUrl, solanaRpcUrl], - )} - > - {children} - - ); -} diff --git a/examples/react-app/src/context/WalletClientProvider.tsx b/examples/react-app/src/context/WalletClientProvider.tsx deleted file mode 100644 index 148400f55..000000000 --- a/examples/react-app/src/context/WalletClientProvider.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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__/ClientProvider-test.browser.tsx similarity index 74% rename from examples/react-app/src/context/__tests__/WalletClientProvider-test.browser.tsx rename to examples/react-app/src/context/__tests__/ClientProvider-test.browser.tsx index 2d3c266f0..2a2dcf5d2 100644 --- a/examples/react-app/src/context/__tests__/WalletClientProvider-test.browser.tsx +++ b/examples/react-app/src/context/__tests__/ClientProvider-test.browser.tsx @@ -8,15 +8,27 @@ import { ChainContext, DEFAULT_CHAIN_CONFIG } from '../ChainContext'; // disposal. const mockPublishedClients: unknown[] = []; +// A chainable client stub: every `.use()` returns the same disposable object so the provider's +// `createClient().use(walletSigner(…)).use(solanaRpc(…))` chain resolves to one disposable client. +function makeDisposableClient() { + const client: { [Symbol.dispose]: jest.Mock; use: () => typeof client } = { + [Symbol.dispose]: jest.fn(), + use: () => client, + }; + return client; +} + jest.mock('@solana/kit', () => ({ - createClient: () => ({ - use: () => ({ [Symbol.dispose]: jest.fn() }), - }), + createClient: () => ({ use: () => makeDisposableClient() }), // `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, + // `buildClient` passes an `extendClient` plugin to `.use()`; the chainable stub above never + // invokes plugins, so this only needs to exist for the import to resolve. + extendClient: (client: unknown) => client, })); jest.mock('@solana/kit-plugin-wallet', () => ({ walletSigner: () => ({}) })); +jest.mock('@solana/kit-plugin-rpc', () => ({ solanaRpc: () => ({}) })); jest.mock('@solana/react', () => ({ ClientProvider: ({ children, client }: { children: ReactNode; client: unknown }) => { mockPublishedClients.push(client); @@ -25,19 +37,19 @@ jest.mock('@solana/react', () => ({ })); // Import after the mocks are registered. -import { WalletClientProvider } from '../WalletClientProvider'; +import { ClientProvider } from '../ClientProvider'; function tree(chain: string) { return ( - +
child
-
+
); } -describe('WalletClientProvider', () => { +describe('ClientProvider', () => { beforeEach(() => { mockPublishedClients.length = 0; }); diff --git a/examples/react-app/src/hooks/useDisplayedWallet.ts b/examples/react-app/src/hooks/useDisplayedWallet.ts index 0df1939eb..3cd15a92c 100644 --- a/examples/react-app/src/hooks/useDisplayedWallet.ts +++ b/examples/react-app/src/hooks/useDisplayedWallet.ts @@ -3,7 +3,7 @@ import { useConnectedWallet, useIsWalletReady } from '@solana/kit-plugin-wallet/ import { useClient } from '@solana/react'; import { useRef } from 'react'; -import type { AppClient } from '../context/WalletClientProvider'; +import type { AppClient } from '../context/ClientProvider'; /** * The connection to *display*, held stable across the wallet's warm-up. diff --git a/examples/react-app/src/hooks/useHasWalletSettled.ts b/examples/react-app/src/hooks/useHasWalletSettled.ts index 58977f27d..2ac03984c 100644 --- a/examples/react-app/src/hooks/useHasWalletSettled.ts +++ b/examples/react-app/src/hooks/useHasWalletSettled.ts @@ -3,7 +3,7 @@ import { useIsWalletReady } from '@solana/kit-plugin-wallet/react'; import { useClient } from '@solana/react'; import { useRef } from 'react'; -import type { AppClient } from '../context/WalletClientProvider'; +import type { AppClient } from '../context/ClientProvider'; /** * Whether the wallet has settled its initial auto-reconnect *at least once since this component diff --git a/examples/react-app/src/main.tsx b/examples/react-app/src/main.tsx index 89f962aac..2b0fbb409 100644 --- a/examples/react-app/src/main.tsx +++ b/examples/react-app/src/main.tsx @@ -8,8 +8,7 @@ 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 { WalletClientProvider } from './context/WalletClientProvider.tsx'; +import { ClientProvider } from './context/ClientProvider.tsx'; const rootNode = document.getElementById('root')!; const root = createRoot(rootNode); @@ -17,14 +16,12 @@ root.render( - - - -