From 93e2ec05c45bfead3f932128a6893e70e4c04a41 Mon Sep 17 00:00:00 2001 From: Swamikumar Date: Sat, 27 Dec 2025 03:33:22 +0000 Subject: [PATCH 01/17] selected wallet account context provider --- packages/react/package.json | 1 + .../selectedWalletAccountContextProvider.tsx | 118 ++++++++++++++++++ pnpm-lock.yaml | 3 + 3 files changed, 122 insertions(+) create mode 100644 packages/react/src/selectedWalletAccountContextProvider.tsx diff --git a/packages/react/package.json b/packages/react/package.json index d0bf57545..f0a38408b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -83,6 +83,7 @@ "@solana/wallet-standard-features": "^1.3.0", "@wallet-standard/base": "^1.1.0", "@wallet-standard/errors": "^0.1.1", + "@wallet-standard/react": "^1.0.1", "@wallet-standard/ui": "^1.0.1", "@wallet-standard/ui-registry": "^1.0.1" }, diff --git a/packages/react/src/selectedWalletAccountContextProvider.tsx b/packages/react/src/selectedWalletAccountContextProvider.tsx new file mode 100644 index 000000000..9582538f2 --- /dev/null +++ b/packages/react/src/selectedWalletAccountContextProvider.tsx @@ -0,0 +1,118 @@ +import { getUiWalletAccountStorageKey, UiWallet, UiWalletAccount, uiWalletAccountBelongsToUiWallet, uiWalletAccountsAreSame, useWallets } from "@wallet-standard/react"; +import React, { createContext } from "react"; + +export type SelectedWalletAccountState = UiWalletAccount | undefined; + +export type SelectedWalletAccountContextProviderProps = { children: React.ReactNode } & { + filterWallet: (wallet: UiWallet) => Boolean, + stateSync: { + storeSelectedWallet: (walletId: string) => void, + getSelectedWallet: () => string | null, + deleteSelectedWallet: () => void, + } +}; + +export const SelectedWalletAccountContext = createContext< + readonly [ + selectedWalletAccount: SelectedWalletAccountState, + setSelectedWalletAccount: React.Dispatch> + ]>([undefined, function setSelectedWalletAccount() { }]); + +function findSavedWalletAccount(wallets: readonly UiWallet[], savedWalletKey: string | null): UiWalletAccount | undefined { + if (!savedWalletKey) { + return; + } + const [savedWalletName, savedWalletAddress] = savedWalletKey.split(':'); + if (!savedWalletName || !savedWalletAddress) { + return; + } + for (const wallet of wallets) { + if (wallet.name !== savedWalletName) continue; + for (const account of wallet.accounts) { + if (account.address === savedWalletAddress) { + return account; + } + } + } +} + +/** + * Saves the selected wallet account's storage key to a persistant storage. In future + * sessions it will try to return that same wallet account, or at least one from the same brand of + * wallet if the wallet from which it came is still in the Wallet Standard registry. + * @param param0 + * @returns + */ +export function SelectedWalletAccountContextProvider( + { children, filterWallet, stateSync }: SelectedWalletAccountContextProviderProps) { + const wallets = useWallets(); + const filteredWallets = React.useMemo(() => wallets.filter(filterWallet), [wallets, filterWallet]); + + const wasSetterInvokedRef = React.useRef(false); + + const [selectedWalletAccount, setSelectedWalletAccountInternal] = React.useState( + () => { + const savedWalletKey = stateSync.getSelectedWallet(); + return findSavedWalletAccount(filteredWallets, savedWalletKey); + }); + + // Public setter: mark the per-instance ref synchronously to avoid races, then schedule state update. + // useCallback stabilises the setter for consumers. + const setSelectedWalletAccount: React.Dispatch< + React.SetStateAction + > = React.useCallback(setStateAction => { + wasSetterInvokedRef.current = true; + setSelectedWalletAccountInternal(prevSelectedWalletAccount => { + const nextWalletAccount = + typeof setStateAction === 'function' ? setStateAction(prevSelectedWalletAccount) : setStateAction; + const accountKey = nextWalletAccount ? getUiWalletAccountStorageKey(nextWalletAccount) : undefined; + if (accountKey) { + stateSync.storeSelectedWallet(accountKey); + } else { + stateSync.deleteSelectedWallet(); + } + return nextWalletAccount; + }); + }, [stateSync]); + + //Auto-restore saved wallet account if it appears later, + //and if the user hasn't made an explicit choice yet. + React.useEffect(() => { + if (wasSetterInvokedRef.current || selectedWalletAccount) return; + const savedWalletKey = stateSync.getSelectedWallet(); + const savedAccount = findSavedWalletAccount(filteredWallets, savedWalletKey); + if (savedAccount) { + setSelectedWalletAccountInternal(savedAccount); + } + }, [filteredWallets, filterWallet, stateSync, selectedWalletAccount]); + + const walletAccount = React.useMemo(() => { + if (!selectedWalletAccount) return; + for (const wallet of filteredWallets) { + for (const account of wallet.accounts) { + if (uiWalletAccountsAreSame(account, selectedWalletAccount)) { + return account; + } + } + if (uiWalletAccountBelongsToUiWallet(selectedWalletAccount, wallet) && wallet.accounts[0]) { + return wallet.accounts[0]; + } + } + }, [selectedWalletAccount, filteredWallets]); + + React.useEffect(() => { + // If there is a selected wallet account but the wallet to which it belongs has since + // disconnected, clear the selected wallet. This is an automatic cleanup and should not + // mark the 'wasSetterInvoked' ref (so we use the internal setter). + if (selectedWalletAccount && !walletAccount) { + setSelectedWalletAccountInternal(undefined); + } + }, [selectedWalletAccount, walletAccount]); + + + return ( + + {children} + + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22a626a75..82d4e5e52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -874,6 +874,9 @@ importers: '@wallet-standard/errors': specifier: ^0.1.1 version: 0.1.1 + '@wallet-standard/react': + specifier: ^1.0.1 + version: 1.0.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@wallet-standard/ui': specifier: ^1.0.1 version: 1.0.1 From f5ba008c261d69d8aa00605cddfe62c1b587b711 Mon Sep 17 00:00:00 2001 From: Swamikumar Date: Sat, 27 Dec 2025 04:39:31 +0000 Subject: [PATCH 02/17] added exports in index.ts, and added doc for findSavedWalletAccount func --- packages/react/src/index.ts | 2 ++ packages/react/src/selectedWalletAccountContextProvider.tsx | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 2a0f41358..12c4cfe6c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -10,3 +10,5 @@ export * from './useSignTransaction'; export * from './useWalletAccountMessageSigner'; export * from './useWalletAccountTransactionSigner'; export * from './useWalletAccountTransactionSendingSigner'; + +export * from './selectedWalletAccountContextProvider' diff --git a/packages/react/src/selectedWalletAccountContextProvider.tsx b/packages/react/src/selectedWalletAccountContextProvider.tsx index 9582538f2..43a4a5309 100644 --- a/packages/react/src/selectedWalletAccountContextProvider.tsx +++ b/packages/react/src/selectedWalletAccountContextProvider.tsx @@ -18,6 +18,12 @@ export const SelectedWalletAccountContext = createContext< setSelectedWalletAccount: React.Dispatch> ]>([undefined, function setSelectedWalletAccount() { }]); +/** + * Returns the saved wallet account when its corresponding wallet, and account is available. + * @param wallets + * @param savedWalletKey + * @returns + */ function findSavedWalletAccount(wallets: readonly UiWallet[], savedWalletKey: string | null): UiWalletAccount | undefined { if (!savedWalletKey) { return; From 6eee0244399449cf4556f97ea72a24098acf7785 Mon Sep 17 00:00:00 2001 From: Swamikumar Date: Sun, 28 Dec 2025 02:48:39 +0000 Subject: [PATCH 03/17] refactored formatting --- .../src/selectedWalletAccountContextProvider.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/react/src/selectedWalletAccountContextProvider.tsx b/packages/react/src/selectedWalletAccountContextProvider.tsx index 43a4a5309..23b2031bd 100644 --- a/packages/react/src/selectedWalletAccountContextProvider.tsx +++ b/packages/react/src/selectedWalletAccountContextProvider.tsx @@ -1,4 +1,11 @@ -import { getUiWalletAccountStorageKey, UiWallet, UiWalletAccount, uiWalletAccountBelongsToUiWallet, uiWalletAccountsAreSame, useWallets } from "@wallet-standard/react"; +import { + getUiWalletAccountStorageKey, + type UiWallet, + type UiWalletAccount, + uiWalletAccountBelongsToUiWallet, + uiWalletAccountsAreSame, + useWallets +} from "@wallet-standard/react"; import React, { createContext } from "react"; export type SelectedWalletAccountState = UiWalletAccount | undefined; @@ -46,7 +53,9 @@ function findSavedWalletAccount(wallets: readonly UiWallet[], savedWalletKey: st * Saves the selected wallet account's storage key to a persistant storage. In future * sessions it will try to return that same wallet account, or at least one from the same brand of * wallet if the wallet from which it came is still in the Wallet Standard registry. - * @param param0 + * @param children + * @param filterWallet + * @param stateSync * @returns */ export function SelectedWalletAccountContextProvider( From a90bca92d8b55f14e7b1619234ad9b74825996e3 Mon Sep 17 00:00:00 2001 From: Swamikumar Date: Sun, 28 Dec 2025 02:49:05 +0000 Subject: [PATCH 04/17] type test added --- ...edWalletAccountContextProvider-typetest.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts diff --git a/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts b/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts new file mode 100644 index 000000000..56e470191 --- /dev/null +++ b/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts @@ -0,0 +1,49 @@ +import React from "react"; +import type { UiWallet, UiWalletAccount } from "@wallet-standard/react"; +import { SelectedWalletAccountContext, SelectedWalletAccountContextProvider } from "../selectedWalletAccountContextProvider"; + +/** + * Positive: provider accepts correct props. + */ +React.createElement(SelectedWalletAccountContextProvider, { + children: React.createElement("div", null), + filterWallet: (_wallet: UiWallet) => true, + stateSync: { + storeSelectedWallet: (_walletId: string) => { }, + getSelectedWallet: () => null, + deleteSelectedWallet: () => { }, + }, +}); + +/** + * Negative: filterWallet must return a boolean. + */ +React.createElement(SelectedWalletAccountContextProvider, { + //@ts-expect-error filterWallet must return a boolean + filterWallet: (_wallet: UiWallet) => 'not a boolean', + stateSync: { + storeSelectedWallet: (_walletId: string) => { }, + getSelectedWallet: () => null, + deleteSelectedWallet: () => { }, + }, + children: React.createElement("div", null), +}) + +/** + * Context value: tuple shape and setter behavior. + */ +type Ctx = React.ContextType; +const ctxValue: Ctx = React.useContext(SelectedWalletAccountContext); +const [, setSelected] = ctxValue; +// Positive: setter accepts undefined +setSelected(undefined); +// Positive: setter accepts an updater function +setSelected(prev => prev); +// Positive: setter accepts a UiWalletAccount value +setSelected({} as UiWalletAccount); +// Negative: setter rejects invalid types +setSelected( + // @ts-expect-error must be of correct type + 'not a wallet account or undefined', +); + From 32cd4bf3021e2de63bb50283c1a6283924a9df03 Mon Sep 17 00:00:00 2001 From: Swamikumar Date: Sun, 28 Dec 2025 02:50:40 +0000 Subject: [PATCH 05/17] renamed provider filename --- ...extProvider.tsx => SelectedWalletAccountContextProvider.tsx} | 0 .../selectedWalletAccountContextProvider-typetest.ts | 2 +- packages/react/src/index.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/react/src/{selectedWalletAccountContextProvider.tsx => SelectedWalletAccountContextProvider.tsx} (100%) diff --git a/packages/react/src/selectedWalletAccountContextProvider.tsx b/packages/react/src/SelectedWalletAccountContextProvider.tsx similarity index 100% rename from packages/react/src/selectedWalletAccountContextProvider.tsx rename to packages/react/src/SelectedWalletAccountContextProvider.tsx diff --git a/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts b/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts index 56e470191..dd8b3068f 100644 --- a/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts +++ b/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts @@ -1,6 +1,6 @@ import React from "react"; import type { UiWallet, UiWalletAccount } from "@wallet-standard/react"; -import { SelectedWalletAccountContext, SelectedWalletAccountContextProvider } from "../selectedWalletAccountContextProvider"; +import { SelectedWalletAccountContext, SelectedWalletAccountContextProvider } from "../SelectedWalletAccountContextProvider"; /** * Positive: provider accepts correct props. diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 12c4cfe6c..fb584a166 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -11,4 +11,4 @@ export * from './useWalletAccountMessageSigner'; export * from './useWalletAccountTransactionSigner'; export * from './useWalletAccountTransactionSendingSigner'; -export * from './selectedWalletAccountContextProvider' +export * from './SelectedWalletAccountContextProvider' From b0ec04d38a9aa6592dbe9aa4ac5166b7c1d59096 Mon Sep 17 00:00:00 2001 From: Swamikumar Date: Sun, 4 Jan 2026 03:59:35 +0000 Subject: [PATCH 06/17] unit test pass --- packages/react/package.json | 1 + .../SelectedWalletAccountContextProvider.tsx | 91 +++-- ...ectedWalletAccountContextProvider-test.tsx | 313 ++++++++++++++++++ pnpm-lock.yaml | 66 ++++ 4 files changed, 444 insertions(+), 27 deletions(-) create mode 100644 packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.tsx diff --git a/packages/react/package.json b/packages/react/package.json index f0a38408b..f8e649454 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -91,6 +91,7 @@ "@solana/codecs-core": "workspace:*", "@solana/eslint-config": "workspace:*", "@solana/rpc-types": "workspace:*", + "@testing-library/react": "^16.3.1", "@types/react": "^19.2.1", "@types/react-test-renderer": "^19.1.0", "react": "^19.2.3", diff --git a/packages/react/src/SelectedWalletAccountContextProvider.tsx b/packages/react/src/SelectedWalletAccountContextProvider.tsx index 23b2031bd..8a7453941 100644 --- a/packages/react/src/SelectedWalletAccountContextProvider.tsx +++ b/packages/react/src/SelectedWalletAccountContextProvider.tsx @@ -1,7 +1,9 @@ +import type { + UiWallet, + UiWalletAccount, +} from "@wallet-standard/react"; import { getUiWalletAccountStorageKey, - type UiWallet, - type UiWalletAccount, uiWalletAccountBelongsToUiWallet, uiWalletAccountsAreSame, useWallets @@ -26,11 +28,11 @@ export const SelectedWalletAccountContext = createContext< ]>([undefined, function setSelectedWalletAccount() { }]); /** - * Returns the saved wallet account when its corresponding wallet, and account is available. - * @param wallets - * @param savedWalletKey - * @returns - */ +* Returns the saved wallet account when its corresponding wallet, and account is available. +* @param wallets +* @param savedWalletKey +* @returns +*/ function findSavedWalletAccount(wallets: readonly UiWallet[], savedWalletKey: string | null): UiWalletAccount | undefined { if (!savedWalletKey) { return; @@ -50,14 +52,14 @@ function findSavedWalletAccount(wallets: readonly UiWallet[], savedWalletKey: st } /** - * Saves the selected wallet account's storage key to a persistant storage. In future - * sessions it will try to return that same wallet account, or at least one from the same brand of - * wallet if the wallet from which it came is still in the Wallet Standard registry. - * @param children - * @param filterWallet - * @param stateSync - * @returns - */ +* Saves the selected wallet account's storage key to a persistant storage. In future +* sessions it will try to return that same wallet account, or at least one from the same brand of +* wallet if the wallet from which it came is still in the Wallet Standard registry. +* @param children +* @param filterWallet +* @param stateSync +* @returns +*/ export function SelectedWalletAccountContextProvider( { children, filterWallet, stateSync }: SelectedWalletAccountContextProviderProps) { const wallets = useWallets(); @@ -68,7 +70,9 @@ export function SelectedWalletAccountContextProvider( const [selectedWalletAccount, setSelectedWalletAccountInternal] = React.useState( () => { const savedWalletKey = stateSync.getSelectedWallet(); - return findSavedWalletAccount(filteredWallets, savedWalletKey); + const savedWalletAccount = findSavedWalletAccount(filteredWallets, savedWalletKey); + console.log("found saved wallet account, and initialized the selectedWalletAccount:", savedWalletAccount); + return savedWalletAccount; }); // Public setter: mark the per-instance ref synchronously to avoid races, then schedule state update. @@ -77,49 +81,82 @@ export function SelectedWalletAccountContextProvider( React.SetStateAction > = React.useCallback(setStateAction => { wasSetterInvokedRef.current = true; + console.log("setSelectedWalletAccount invoked with:", setStateAction); setSelectedWalletAccountInternal(prevSelectedWalletAccount => { const nextWalletAccount = typeof setStateAction === 'function' ? setStateAction(prevSelectedWalletAccount) : setStateAction; - const accountKey = nextWalletAccount ? getUiWalletAccountStorageKey(nextWalletAccount) : undefined; - if (accountKey) { - stateSync.storeSelectedWallet(accountKey); - } else { - stateSync.deleteSelectedWallet(); - } return nextWalletAccount; }); - }, [stateSync]); + }, [setSelectedWalletAccountInternal]); + + //Sync to persistant storage when selectedWalletAccount changes + React.useEffect(() => { + console.log("syncing selected account to storage:", selectedWalletAccount); + if (!wasSetterInvokedRef.current) return; + + const accountKey = selectedWalletAccount + ? getUiWalletAccountStorageKey(selectedWalletAccount) + : undefined; + + if (accountKey) { + console.log("Storing selected wallet account key:", accountKey); + stateSync.storeSelectedWallet(accountKey); + } else { + stateSync.deleteSelectedWallet(); + } + }, [selectedWalletAccount, stateSync]); + //Auto-restore saved wallet account if it appears later, //and if the user hasn't made an explicit choice yet. React.useEffect(() => { - if (wasSetterInvokedRef.current || selectedWalletAccount) return; + console.log("checking for saved wallet account to restore..."); + if (wasSetterInvokedRef.current) return; const savedWalletKey = stateSync.getSelectedWallet(); const savedAccount = findSavedWalletAccount(filteredWallets, savedWalletKey); + if (savedAccount && selectedWalletAccount && uiWalletAccountsAreSame(savedAccount, selectedWalletAccount)) { + console.log("Saved wallet account is already in selectedWalletAccount, no need to restore."); + return; + } if (savedAccount) { + console.log("Restoring saved wallet account:", savedAccount); setSelectedWalletAccountInternal(savedAccount); } - }, [filteredWallets, filterWallet, stateSync, selectedWalletAccount]); + }, [filteredWallets, stateSync, selectedWalletAccount]); const walletAccount = React.useMemo(() => { + console.log("resolving walletAccount"); + console.log("selectedWalletAccount:", selectedWalletAccount); + console.log("available filteredWallets:", filteredWallets); if (!selectedWalletAccount) return; for (const wallet of filteredWallets) { for (const account of wallet.accounts) { if (uiWalletAccountsAreSame(account, selectedWalletAccount)) { + console.log("Found matching wallet account:", account); return account; } } if (uiWalletAccountBelongsToUiWallet(selectedWalletAccount, wallet) && wallet.accounts[0]) { + console.log("Selected wallet account's wallet is available, returning first account from that wallet:", wallet.accounts[0]); return wallet.accounts[0]; } + console.log("No matching account in wallet:", wallet.name); } }, [selectedWalletAccount, filteredWallets]); + console.log("derived walletAccount:", walletAccount); + React.useEffect(() => { // If there is a selected wallet account but the wallet to which it belongs has since // disconnected, clear the selected wallet. This is an automatic cleanup and should not // mark the 'wasSetterInvoked' ref (so we use the internal setter). - if (selectedWalletAccount && !walletAccount) { + // Cleanup shouldn't be run if user has made a selection or selectedWalletAccount/walletAccount are loading or undefined + console.log("checking if selectedWalletAccount is still valid..."); + console.log("selectedWalletAccount:", selectedWalletAccount); + console.log("walletAccount:", walletAccount); + if (!selectedWalletAccount) return; //still loading ... + if (!walletAccount || !uiWalletAccountsAreSame(walletAccount, selectedWalletAccount)) { + console.log("Selected wallet account is no longer valid, clearing it."); setSelectedWalletAccountInternal(undefined); } }, [selectedWalletAccount, walletAccount]); @@ -130,4 +167,4 @@ export function SelectedWalletAccountContextProvider( {children} ) -} +} \ No newline at end of file diff --git a/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.tsx b/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.tsx new file mode 100644 index 000000000..b4982c9a7 --- /dev/null +++ b/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.tsx @@ -0,0 +1,313 @@ +import React from "react"; +import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; +import { + SelectedWalletAccountContext, + SelectedWalletAccountContextProvider +} from "../SelectedWalletAccountContextProvider"; + +// Mock wallet-standard/react exports the provider depends on +jest.mock("@wallet-standard/react", () => { + return { + useWallets: jest.fn(), + // The provider itself uses only getUiWalletAccountStorageKey, uiWalletAccountsAreSame, uiWalletAccountBelongsToUiWallet + getUiWalletAccountStorageKey: jest.fn(), + uiWalletAccountsAreSame: jest.fn(), + uiWalletAccountBelongsToUiWallet: jest.fn(), + }; +}); + +import { getUiWalletAccountStorageKey, uiWalletAccountBelongsToUiWallet, uiWalletAccountsAreSame, useWallets } from "@wallet-standard/react"; + +function makeWallet(name: string, accounts: string[]) { + return { + name, + accounts: accounts.map(addr => ({ address: addr, walletName: name })), + }; +} + +let renderCount = 0; +function Consumer() { + renderCount++; + if (renderCount > 10) { + throw new Error("Too many re-renders"); + } + const [selectedWalletAccount, setSelectedWalletAccount] = React.useContext(SelectedWalletAccountContext); + return ( +
+
{selectedWalletAccount ? selectedWalletAccount.address : 'none'}
+ + +
+ ); +} + +describe("SelectedWalletAccountContextProvider", () => { + beforeEach(() => { + jest.clearAllMocks(); + + //Mock implementations for wallet-standard/react functions + (getUiWalletAccountStorageKey as jest.Mock).mockImplementation( + (account) => `${account.walletName}:${account.address}` + ); + (uiWalletAccountsAreSame as jest.Mock).mockImplementation( + (a, b) => a?.address === b?.address + ); + (uiWalletAccountBelongsToUiWallet as jest.Mock).mockImplementation( + (account, wallet) => account?.walletName === wallet?.name + ); + + renderCount = 0; + }); + + test("initializes from saved key", () => { + //saved key matchs a wallet that is available from useWallets + const stateSync = { + getSelectedWallet: jest.fn().mockReturnValue("WalletA:0x123"), + storeSelectedWallet: jest.fn(), + deleteSelectedWallet: jest.fn(), + }; + + const walletA = makeWallet("WalletA", ["0x123", "0x456"]); + const walletB = makeWallet("WalletB", ["0xabc"]); + + const mockWallets = [ + walletA, + walletB, + ]; + (useWallets as jest.Mock).mockReturnValue(mockWallets); + + const allowWallets = () => true; + + render( + + + + ); + + expect(screen.getByTestId("selected").textContent).toBe("0x123"); + expect(stateSync.getSelectedWallet).toHaveBeenCalled(); + }); + + test("initializes with no selection when saved key is invalid", () => { + //saved key matchs a wallet that is available from useWallets + const stateSync = { + getSelectedWallet: jest.fn().mockReturnValue(null), + storeSelectedWallet: jest.fn(), + deleteSelectedWallet: jest.fn(), + }; + + const mockWallets = [ + makeWallet("WalletA", ["0x123", "0x456"]), + makeWallet("WalletB", ["0xabc"]), + ]; + (useWallets as jest.Mock).mockReturnValue(mockWallets); + + const allowWallets = () => true; + + render( + + + + ); + + expect(screen.getByTestId("selected").textContent).toBe("none"); + expect(stateSync.getSelectedWallet).toHaveBeenCalled(); + }); + + test("initializes with selected wallet when make a selection from the available wallets", () => { + const stateSync = { + getSelectedWallet: jest.fn().mockReturnValue(null), + storeSelectedWallet: jest.fn((_accountKey: string) => { }), + deleteSelectedWallet: jest.fn(), + }; + + const mockWallets = [ + makeWallet("WalletA", ["0x123", "0x456"]), + makeWallet("WalletB", ["0xabc"]), + ]; + (useWallets as jest.Mock).mockReturnValue(mockWallets); + + const allowWallets = () => true; + + render( + + + + ); + + expect(screen.getByTestId("selected").textContent).toBe("none"); + expect(stateSync.getSelectedWallet).toHaveBeenCalled(); + + //Make a selection + act(() => { + fireEvent.click(screen.getByTestId("pick-b")); + }); + + expect(screen.getByTestId("selected").textContent).toBe("0xabc"); + expect(stateSync.storeSelectedWallet).toHaveBeenCalledWith("WalletB:0xabc"); + }); + + test("allows changing and clearing selection", async () => { + const stateSync = { + getSelectedWallet: jest.fn().mockReturnValue(null), + storeSelectedWallet: jest.fn((_accountKey: string) => { }), + deleteSelectedWallet: jest.fn(), + }; + + const mockWallets = [ + makeWallet("WalletA", ["0x123", "0x456"]), + makeWallet("WalletB", ["0xabc"]), + ]; + (useWallets as jest.Mock).mockReturnValue(mockWallets); + + const allowWallets = () => true; + + render( + + + + ); + + //Initial state + expect(screen.getByTestId("selected").textContent).toBe("none"); + + //Pick B + fireEvent.click(screen.getByTestId("pick-b")); + + expect(screen.getByTestId("selected").textContent).toBe("0xabc"); + await waitFor(() => { + expect(stateSync.storeSelectedWallet).toHaveBeenCalledWith("WalletB:0xabc"); + }); + + //Clear + fireEvent.click(screen.getByTestId("clear")); + + expect(screen.getByTestId("selected").textContent).toBe("none"); + await waitFor(() => expect(stateSync.deleteSelectedWallet).toHaveBeenCalled()); + }); + + test + + test('auto-restores saved wallet when it appears later', () => { + const getSelectedWallet = jest.fn().mockReturnValue('WalletA:0x123'); + const storeSelectedWallet = jest.fn(); + const deleteSelectedWallet = jest.fn(); + + const allowWallets = () => true; + + //First render with no wallets + const mockWallets: any[] = []; + const useWalletsMock = useWallets as jest.Mock; + useWalletsMock.mockReturnValue(mockWallets); + + const { rerender } = render( + + + + ); + + //nothing selected yet + expect(screen.getByTestId('selected').textContent).toContain('none'); + expect(getSelectedWallet).toHaveBeenCalled(); + + //Now update wallets to include the saved one + const mockWalletsUpdated = [makeWallet('WalletA', ['0x123']), makeWallet('WalletB', ['0xabc'])]; + useWalletsMock.mockReturnValue(mockWalletsUpdated); + + act(() => { + rerender( + + + + ); + }); + + expect(screen.getByTestId('selected').textContent).toBe('0x123'); + }); + + test("clears in-memory selection when selected wallet disappears", () => { + const getSelectedWallet = jest.fn().mockReturnValue("WalletA:0x123"); + const storeSelectedWallet = jest.fn(); + const deleteSelectedWallet = jest.fn(); + + //First render with WalletA present + const mockWallets = [ + makeWallet("WalletA", ["0x123", "0x456"]), + makeWallet("WalletB", ["0xabc"]), + ]; + const useWalletsMock = useWallets as jest.Mock; + useWalletsMock.mockReturnValue(mockWallets); + + const allowWallets = () => true; + + const { rerender } = render( + + + + ); + + //WalletA:0x123 is selected + expect(screen.getByTestId("selected").textContent).toBe("0x123"); + expect(getSelectedWallet).toHaveBeenCalled(); + + //Now update wallets to remove WalletA + const mockWalletsUpdated = [ + makeWallet("WalletB", ["0xabc"]), + ]; + useWalletsMock.mockReturnValue(mockWalletsUpdated); + + act(() => { + rerender( + + + + ); + }); + + expect(screen.getByTestId("selected").textContent).toBe("none"); + }); +}); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82d4e5e52..e03e5ecc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -893,6 +893,9 @@ importers: '@solana/rpc-types': specifier: workspace:* version: link:../rpc-types + '@testing-library/react': + specifier: ^16.3.1 + version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@types/react': specifier: ^19.2.1 version: 19.2.7 @@ -4507,6 +4510,25 @@ packages: '@swc/types@0.1.25': resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.1': + resolution: {integrity: sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tootallnate/once@2.0.0': resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} @@ -4523,6 +4545,9 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.1': resolution: {integrity: sha512-aACu/U/omhdk15O4Nfb+fHgH/z3QsfQzpnvRZhYhThms83ZnAOZz7zZAWO7mn2yyNQaA4xTO8GLK3uqFU4bYYw==} @@ -5038,6 +5063,9 @@ packages: resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -5524,6 +5552,9 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + domexception@4.0.0: resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} engines: {node: '>=12'} @@ -6705,6 +6736,10 @@ packages: lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -11391,6 +11426,27 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@tootallnate/once@2.0.0': {} '@tsconfig/node10@1.0.11': {} @@ -11401,6 +11457,8 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.1': dependencies: '@babel/parser': 7.28.5 @@ -12030,6 +12088,10 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + array-union@2.1.0: {} ast-types@0.16.1: @@ -12512,6 +12574,8 @@ snapshots: dependencies: path-type: 4.0.0 + dom-accessibility-api@0.5.16: {} + domexception@4.0.0: dependencies: webidl-conversions: 7.0.0 @@ -14197,6 +14261,8 @@ snapshots: lunr@2.3.9: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 From 2cf00234a92a3ed60bf0a0c510e4ebf2e71e214b Mon Sep 17 00:00:00 2001 From: Swamikumar Date: Sun, 4 Jan 2026 04:15:50 +0000 Subject: [PATCH 07/17] updated provider to use walletAccount instead of selectedWalletAccount --- .../SelectedWalletAccountContextProvider.tsx | 25 +++---------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/packages/react/src/SelectedWalletAccountContextProvider.tsx b/packages/react/src/SelectedWalletAccountContextProvider.tsx index 8a7453941..581d80453 100644 --- a/packages/react/src/SelectedWalletAccountContextProvider.tsx +++ b/packages/react/src/SelectedWalletAccountContextProvider.tsx @@ -71,7 +71,6 @@ export function SelectedWalletAccountContextProvider( () => { const savedWalletKey = stateSync.getSelectedWallet(); const savedWalletAccount = findSavedWalletAccount(filteredWallets, savedWalletKey); - console.log("found saved wallet account, and initialized the selectedWalletAccount:", savedWalletAccount); return savedWalletAccount; }); @@ -81,7 +80,6 @@ export function SelectedWalletAccountContextProvider( React.SetStateAction > = React.useCallback(setStateAction => { wasSetterInvokedRef.current = true; - console.log("setSelectedWalletAccount invoked with:", setStateAction); setSelectedWalletAccountInternal(prevSelectedWalletAccount => { const nextWalletAccount = typeof setStateAction === 'function' ? setStateAction(prevSelectedWalletAccount) : setStateAction; @@ -91,7 +89,6 @@ export function SelectedWalletAccountContextProvider( //Sync to persistant storage when selectedWalletAccount changes React.useEffect(() => { - console.log("syncing selected account to storage:", selectedWalletAccount); if (!wasSetterInvokedRef.current) return; const accountKey = selectedWalletAccount @@ -99,7 +96,6 @@ export function SelectedWalletAccountContextProvider( : undefined; if (accountKey) { - console.log("Storing selected wallet account key:", accountKey); stateSync.storeSelectedWallet(accountKey); } else { stateSync.deleteSelectedWallet(); @@ -110,60 +106,45 @@ export function SelectedWalletAccountContextProvider( //Auto-restore saved wallet account if it appears later, //and if the user hasn't made an explicit choice yet. React.useEffect(() => { - console.log("checking for saved wallet account to restore..."); if (wasSetterInvokedRef.current) return; const savedWalletKey = stateSync.getSelectedWallet(); const savedAccount = findSavedWalletAccount(filteredWallets, savedWalletKey); if (savedAccount && selectedWalletAccount && uiWalletAccountsAreSame(savedAccount, selectedWalletAccount)) { - console.log("Saved wallet account is already in selectedWalletAccount, no need to restore."); return; } if (savedAccount) { - console.log("Restoring saved wallet account:", savedAccount); setSelectedWalletAccountInternal(savedAccount); } }, [filteredWallets, stateSync, selectedWalletAccount]); const walletAccount = React.useMemo(() => { - console.log("resolving walletAccount"); - console.log("selectedWalletAccount:", selectedWalletAccount); - console.log("available filteredWallets:", filteredWallets); if (!selectedWalletAccount) return; for (const wallet of filteredWallets) { for (const account of wallet.accounts) { if (uiWalletAccountsAreSame(account, selectedWalletAccount)) { - console.log("Found matching wallet account:", account); return account; } } if (uiWalletAccountBelongsToUiWallet(selectedWalletAccount, wallet) && wallet.accounts[0]) { - console.log("Selected wallet account's wallet is available, returning first account from that wallet:", wallet.accounts[0]); return wallet.accounts[0]; } - console.log("No matching account in wallet:", wallet.name); } }, [selectedWalletAccount, filteredWallets]); - console.log("derived walletAccount:", walletAccount); - React.useEffect(() => { // If there is a selected wallet account but the wallet to which it belongs has since // disconnected, clear the selected wallet. This is an automatic cleanup and should not // mark the 'wasSetterInvoked' ref (so we use the internal setter). // Cleanup shouldn't be run if user has made a selection or selectedWalletAccount/walletAccount are loading or undefined - console.log("checking if selectedWalletAccount is still valid..."); - console.log("selectedWalletAccount:", selectedWalletAccount); - console.log("walletAccount:", walletAccount); if (!selectedWalletAccount) return; //still loading ... - if (!walletAccount || !uiWalletAccountsAreSame(walletAccount, selectedWalletAccount)) { - console.log("Selected wallet account is no longer valid, clearing it."); + if (wasSetterInvokedRef.current) return; //user made a selection + if (!walletAccount) { setSelectedWalletAccountInternal(undefined); } }, [selectedWalletAccount, walletAccount]); - return ( - + {children} ) From 132775a94e3c599220194337a82db7ca4a997b26 Mon Sep 17 00:00:00 2001 From: Swamikumar Date: Sun, 4 Jan 2026 04:45:24 +0000 Subject: [PATCH 08/17] fix prettier test issues --- ...edWalletAccountContextProvider-typetest.ts | 24 ++++++++++--------- packages/react/src/index.ts | 3 +-- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts b/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts index dd8b3068f..38eb848d3 100644 --- a/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts +++ b/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts @@ -1,17 +1,20 @@ -import React from "react"; -import type { UiWallet, UiWalletAccount } from "@wallet-standard/react"; -import { SelectedWalletAccountContext, SelectedWalletAccountContextProvider } from "../SelectedWalletAccountContextProvider"; +import React from 'react'; +import type { UiWallet, UiWalletAccount } from '@wallet-standard/react'; +import { + SelectedWalletAccountContext, + SelectedWalletAccountContextProvider, +} from '../SelectedWalletAccountContextProvider'; /** * Positive: provider accepts correct props. */ React.createElement(SelectedWalletAccountContextProvider, { - children: React.createElement("div", null), + children: React.createElement('div', null), filterWallet: (_wallet: UiWallet) => true, stateSync: { - storeSelectedWallet: (_walletId: string) => { }, + storeSelectedWallet: (_walletId: string) => {}, getSelectedWallet: () => null, - deleteSelectedWallet: () => { }, + deleteSelectedWallet: () => {}, }, }); @@ -22,12 +25,12 @@ React.createElement(SelectedWalletAccountContextProvider, { //@ts-expect-error filterWallet must return a boolean filterWallet: (_wallet: UiWallet) => 'not a boolean', stateSync: { - storeSelectedWallet: (_walletId: string) => { }, + storeSelectedWallet: (_walletId: string) => {}, getSelectedWallet: () => null, - deleteSelectedWallet: () => { }, + deleteSelectedWallet: () => {}, }, - children: React.createElement("div", null), -}) + children: React.createElement('div', null), +}); /** * Context value: tuple shape and setter behavior. @@ -46,4 +49,3 @@ setSelected( // @ts-expect-error must be of correct type 'not a wallet account or undefined', ); - diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index fb584a166..dbd177373 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -10,5 +10,4 @@ export * from './useSignTransaction'; export * from './useWalletAccountMessageSigner'; export * from './useWalletAccountTransactionSigner'; export * from './useWalletAccountTransactionSendingSigner'; - -export * from './SelectedWalletAccountContextProvider' +export * from './SelectedWalletAccountContextProvider'; From a5300fa6e6d5b97953fb2bbdec53709bd24569b3 Mon Sep 17 00:00:00 2001 From: Swamikumar Date: Mon, 5 Jan 2026 05:46:14 +0000 Subject: [PATCH 09/17] fixed all issues. All test pass except node unit test due to file matching pattern --- .../SelectedWalletAccountContextProvider.tsx | 11 ++------ ...etAccountContextProvider-test.browser.tsx} | 8 +++--- ...edWalletAccountContextProvider-typetest.ts | 26 +++++++++---------- packages/react/src/index.ts | 1 + .../react/src/selectedWalletAccountContext.ts | 21 +++++++++++++++ 5 files changed, 40 insertions(+), 27 deletions(-) rename packages/react/src/__tests__/{SelectedWalletAccountContextProvider-test.tsx => SelectedWalletAccountContextProvider-test.browser.tsx} (97%) create mode 100644 packages/react/src/selectedWalletAccountContext.ts diff --git a/packages/react/src/SelectedWalletAccountContextProvider.tsx b/packages/react/src/SelectedWalletAccountContextProvider.tsx index 581d80453..e87b33521 100644 --- a/packages/react/src/SelectedWalletAccountContextProvider.tsx +++ b/packages/react/src/SelectedWalletAccountContextProvider.tsx @@ -1,3 +1,4 @@ +import React from "react"; import type { UiWallet, UiWalletAccount, @@ -8,9 +9,7 @@ import { uiWalletAccountsAreSame, useWallets } from "@wallet-standard/react"; -import React, { createContext } from "react"; - -export type SelectedWalletAccountState = UiWalletAccount | undefined; +import { SelectedWalletAccountContext, SelectedWalletAccountState } from "./selectedWalletAccountContext"; export type SelectedWalletAccountContextProviderProps = { children: React.ReactNode } & { filterWallet: (wallet: UiWallet) => Boolean, @@ -21,12 +20,6 @@ export type SelectedWalletAccountContextProviderProps = { children: React.ReactN } }; -export const SelectedWalletAccountContext = createContext< - readonly [ - selectedWalletAccount: SelectedWalletAccountState, - setSelectedWalletAccount: React.Dispatch> - ]>([undefined, function setSelectedWalletAccount() { }]); - /** * Returns the saved wallet account when its corresponding wallet, and account is available. * @param wallets diff --git a/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.tsx b/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx similarity index 97% rename from packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.tsx rename to packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx index b4982c9a7..af49b2ab5 100644 --- a/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.tsx +++ b/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx @@ -1,9 +1,7 @@ import React from "react"; import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; -import { - SelectedWalletAccountContext, - SelectedWalletAccountContextProvider -} from "../SelectedWalletAccountContextProvider"; +import { SelectedWalletAccountContextProvider } from "../SelectedWalletAccountContextProvider"; +import { useSelectedWalletAccount } from "../selectedWalletAccountContext"; // Mock wallet-standard/react exports the provider depends on jest.mock("@wallet-standard/react", () => { @@ -31,7 +29,7 @@ function Consumer() { if (renderCount > 10) { throw new Error("Too many re-renders"); } - const [selectedWalletAccount, setSelectedWalletAccount] = React.useContext(SelectedWalletAccountContext); + const [selectedWalletAccount, setSelectedWalletAccount] = useSelectedWalletAccount(); return (
{selectedWalletAccount ? selectedWalletAccount.address : 'none'}
diff --git a/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts b/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts index 38eb848d3..c62c90516 100644 --- a/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts +++ b/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts @@ -1,9 +1,8 @@ -import React from 'react'; import type { UiWallet, UiWalletAccount } from '@wallet-standard/react'; -import { - SelectedWalletAccountContext, - SelectedWalletAccountContextProvider, -} from '../SelectedWalletAccountContextProvider'; +import React from 'react'; + +import type { useSelectedWalletAccount } from '../selectedWalletAccountContext'; +import { SelectedWalletAccountContextProvider } from '../SelectedWalletAccountContextProvider'; /** * Positive: provider accepts correct props. @@ -12,9 +11,9 @@ React.createElement(SelectedWalletAccountContextProvider, { children: React.createElement('div', null), filterWallet: (_wallet: UiWallet) => true, stateSync: { - storeSelectedWallet: (_walletId: string) => {}, - getSelectedWallet: () => null, deleteSelectedWallet: () => {}, + getSelectedWallet: () => null, + storeSelectedWallet: (_walletId: string) => {}, }, }); @@ -22,22 +21,23 @@ React.createElement(SelectedWalletAccountContextProvider, { * Negative: filterWallet must return a boolean. */ React.createElement(SelectedWalletAccountContextProvider, { + children: React.createElement('div', null), //@ts-expect-error filterWallet must return a boolean filterWallet: (_wallet: UiWallet) => 'not a boolean', stateSync: { - storeSelectedWallet: (_walletId: string) => {}, - getSelectedWallet: () => null, deleteSelectedWallet: () => {}, + getSelectedWallet: () => null, + storeSelectedWallet: (_walletId: string) => {}, }, - children: React.createElement('div', null), }); /** * Context value: tuple shape and setter behavior. */ -type Ctx = React.ContextType; -const ctxValue: Ctx = React.useContext(SelectedWalletAccountContext); -const [, setSelected] = ctxValue; +type CtxValue = ReturnType; +type SetSelected = CtxValue[1]; + +declare const setSelected: SetSelected; // Positive: setter accepts undefined setSelected(undefined); // Positive: setter accepts an updater function diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index dbd177373..1125a2f7a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -11,3 +11,4 @@ export * from './useWalletAccountMessageSigner'; export * from './useWalletAccountTransactionSigner'; export * from './useWalletAccountTransactionSendingSigner'; export * from './SelectedWalletAccountContextProvider'; +export * from './selectedWalletAccountContext'; diff --git a/packages/react/src/selectedWalletAccountContext.ts b/packages/react/src/selectedWalletAccountContext.ts new file mode 100644 index 000000000..bc5ca1f71 --- /dev/null +++ b/packages/react/src/selectedWalletAccountContext.ts @@ -0,0 +1,21 @@ +import { UiWalletAccount } from '@wallet-standard/react'; +import React, { createContext } from 'react'; + +export type SelectedWalletAccountState = UiWalletAccount | undefined; + +export type SelectedWalletAccountContextValue = readonly [ + selectedWalletAccount: SelectedWalletAccountState, + setSelectedWalletAccount: React.Dispatch>, +]; + +export const SelectedWalletAccountContext = /*#__PURE__*/ createContext( + undefined, +); + +export function useSelectedWalletAccount() { + const ctx = React.useContext(SelectedWalletAccountContext); + if (!ctx) { + throw new Error('useSelectedWalletAccount must be used within a SelectedWalletAccountContextProvider'); + } + return ctx; +} From d527bd093931b2dae96078c2ee6aac9850ba9e5d Mon Sep 17 00:00:00 2001 From: Swamikumar Date: Thu, 8 Jan 2026 04:15:58 +0000 Subject: [PATCH 10/17] updated path ignore pattern to match with tsx file for node unit test --- packages/test-config/jest-unit.config.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/test-config/jest-unit.config.node.ts b/packages/test-config/jest-unit.config.node.ts index 32bd64d53..a3538572b 100644 --- a/packages/test-config/jest-unit.config.node.ts +++ b/packages/test-config/jest-unit.config.node.ts @@ -17,7 +17,7 @@ const config: Partial = { __REACTNATIVE__: false, }, setupFilesAfterEnv: [...(commonConfig.setupFilesAfterEnv ?? []), path.resolve(__dirname, 'setup-undici-fetch.ts')], - testPathIgnorePatterns: [...(commonConfig.testPathIgnorePatterns ?? []), '-test.browser.ts$'], + testPathIgnorePatterns: [...(commonConfig.testPathIgnorePatterns ?? []), '-test.browser.tsx?$'], }; export default config; From bb98d5213653a1cd87c494e8fe57d458dc7be266 Mon Sep 17 00:00:00 2001 From: Swamikumar Date: Thu, 8 Jan 2026 04:18:27 +0000 Subject: [PATCH 11/17] updated default context value, and added filtered wallets from the context return --- .../src/SelectedWalletAccountContextProvider.tsx | 2 +- packages/react/src/selectedWalletAccountContext.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/react/src/SelectedWalletAccountContextProvider.tsx b/packages/react/src/SelectedWalletAccountContextProvider.tsx index e87b33521..c66c9b3ec 100644 --- a/packages/react/src/SelectedWalletAccountContextProvider.tsx +++ b/packages/react/src/SelectedWalletAccountContextProvider.tsx @@ -137,7 +137,7 @@ export function SelectedWalletAccountContextProvider( }, [selectedWalletAccount, walletAccount]); return ( - + {children} ) diff --git a/packages/react/src/selectedWalletAccountContext.ts b/packages/react/src/selectedWalletAccountContext.ts index bc5ca1f71..35c361aa4 100644 --- a/packages/react/src/selectedWalletAccountContext.ts +++ b/packages/react/src/selectedWalletAccountContext.ts @@ -1,4 +1,4 @@ -import { UiWalletAccount } from '@wallet-standard/react'; +import { UiWallet, UiWalletAccount } from '@wallet-standard/react'; import React, { createContext } from 'react'; export type SelectedWalletAccountState = UiWalletAccount | undefined; @@ -6,16 +6,16 @@ export type SelectedWalletAccountState = UiWalletAccount | undefined; export type SelectedWalletAccountContextValue = readonly [ selectedWalletAccount: SelectedWalletAccountState, setSelectedWalletAccount: React.Dispatch>, + filteredWallets: UiWallet[], ]; -export const SelectedWalletAccountContext = /*#__PURE__*/ createContext( +export const SelectedWalletAccountContext = /*#__PURE__*/ createContext([ undefined, -); + () => {}, + [], +]); export function useSelectedWalletAccount() { const ctx = React.useContext(SelectedWalletAccountContext); - if (!ctx) { - throw new Error('useSelectedWalletAccount must be used within a SelectedWalletAccountContextProvider'); - } return ctx; } From 874732d9730f37f79779add439b81d5667ea708b Mon Sep 17 00:00:00 2001 From: Swamikumar Date: Thu, 8 Jan 2026 04:23:54 +0000 Subject: [PATCH 12/17] added type assertions in typetest, and added test for filtered wallets --- ...letAccountContextProvider-test.browser.tsx | 93 +++++++++++++------ ...edWalletAccountContextProvider-typetest.ts | 20 ++++ 2 files changed, 86 insertions(+), 27 deletions(-) diff --git a/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx b/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx index af49b2ab5..89de0f92e 100644 --- a/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx +++ b/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx @@ -23,22 +23,24 @@ function makeWallet(name: string, accounts: string[]) { }; } +/** Used to track and error out on infinite re-renders */ let renderCount = 0; function Consumer() { renderCount++; if (renderCount > 10) { throw new Error("Too many re-renders"); } - const [selectedWalletAccount, setSelectedWalletAccount] = useSelectedWalletAccount(); + const [walletAccount, setWalletAccount, filteredWallets] = useSelectedWalletAccount(); return (
-
{selectedWalletAccount ? selectedWalletAccount.address : 'none'}
- - +
{filteredWallets.map(w => w.name).join(", ")}
); } @@ -61,16 +63,53 @@ describe("SelectedWalletAccountContextProvider", () => { renderCount = 0; }); + test("only filtered wallets are usable", () => { + const stateSync = { + getSelectedWallet: jest.fn(), + storeSelectedWallet: jest.fn(), + deleteSelectedWallet: jest.fn(), + }; + const walletA = makeWallet("WalletA", ["123"]); + const walletB = makeWallet("WalletB", ["abc"]); + + const mockWallets = [ + walletA, + walletB, + ]; + (useWallets as jest.Mock).mockReturnValue(mockWallets); + const allowOnlyA = (wallet: any) => wallet.name === "WalletA"; + + render( + + + + ); + + expect(screen.getByTestId("selected").textContent).toBe("none"); + expect(screen.getByTestId("filtered-wallets").textContent).toContain("WalletA"); + expect(screen.getByTestId("filtered-wallets").textContent).not.toContain("WalletB"); + + act(()=>{ + fireEvent.click(screen.getByTestId("pick-b")); + }) + + /** Even if walletB is selected, since it is not available the provider will return 'undefined' for the walletAccount */ + expect(screen.getByTestId("selected").textContent).toBe("none"); + }); + test("initializes from saved key", () => { //saved key matchs a wallet that is available from useWallets const stateSync = { - getSelectedWallet: jest.fn().mockReturnValue("WalletA:0x123"), + getSelectedWallet: jest.fn().mockReturnValue("WalletA:123"), storeSelectedWallet: jest.fn(), deleteSelectedWallet: jest.fn(), }; - const walletA = makeWallet("WalletA", ["0x123", "0x456"]); - const walletB = makeWallet("WalletB", ["0xabc"]); + const walletA = makeWallet("WalletA", ["123", "456"]); + const walletB = makeWallet("WalletB", ["abc"]); const mockWallets = [ walletA, @@ -89,7 +128,7 @@ describe("SelectedWalletAccountContextProvider", () => { ); - expect(screen.getByTestId("selected").textContent).toBe("0x123"); + expect(screen.getByTestId("selected").textContent).toBe("123"); expect(stateSync.getSelectedWallet).toHaveBeenCalled(); }); @@ -102,8 +141,8 @@ describe("SelectedWalletAccountContextProvider", () => { }; const mockWallets = [ - makeWallet("WalletA", ["0x123", "0x456"]), - makeWallet("WalletB", ["0xabc"]), + makeWallet("WalletA", ["123", "456"]), + makeWallet("WalletB", ["abc"]), ]; (useWallets as jest.Mock).mockReturnValue(mockWallets); @@ -130,8 +169,8 @@ describe("SelectedWalletAccountContextProvider", () => { }; const mockWallets = [ - makeWallet("WalletA", ["0x123", "0x456"]), - makeWallet("WalletB", ["0xabc"]), + makeWallet("WalletA", ["123", "456"]), + makeWallet("WalletB", ["abc"]), ]; (useWallets as jest.Mock).mockReturnValue(mockWallets); @@ -154,8 +193,8 @@ describe("SelectedWalletAccountContextProvider", () => { fireEvent.click(screen.getByTestId("pick-b")); }); - expect(screen.getByTestId("selected").textContent).toBe("0xabc"); - expect(stateSync.storeSelectedWallet).toHaveBeenCalledWith("WalletB:0xabc"); + expect(screen.getByTestId("selected").textContent).toBe("abc"); + expect(stateSync.storeSelectedWallet).toHaveBeenCalledWith("WalletB:abc"); }); test("allows changing and clearing selection", async () => { @@ -166,8 +205,8 @@ describe("SelectedWalletAccountContextProvider", () => { }; const mockWallets = [ - makeWallet("WalletA", ["0x123", "0x456"]), - makeWallet("WalletB", ["0xabc"]), + makeWallet("WalletA", ["123", "456"]), + makeWallet("WalletB", ["abc"]), ]; (useWallets as jest.Mock).mockReturnValue(mockWallets); @@ -188,9 +227,9 @@ describe("SelectedWalletAccountContextProvider", () => { //Pick B fireEvent.click(screen.getByTestId("pick-b")); - expect(screen.getByTestId("selected").textContent).toBe("0xabc"); + expect(screen.getByTestId("selected").textContent).toBe("abc"); await waitFor(() => { - expect(stateSync.storeSelectedWallet).toHaveBeenCalledWith("WalletB:0xabc"); + expect(stateSync.storeSelectedWallet).toHaveBeenCalledWith("WalletB:abc"); }); //Clear @@ -203,7 +242,7 @@ describe("SelectedWalletAccountContextProvider", () => { test test('auto-restores saved wallet when it appears later', () => { - const getSelectedWallet = jest.fn().mockReturnValue('WalletA:0x123'); + const getSelectedWallet = jest.fn().mockReturnValue('WalletA:123'); const storeSelectedWallet = jest.fn(); const deleteSelectedWallet = jest.fn(); @@ -232,7 +271,7 @@ describe("SelectedWalletAccountContextProvider", () => { expect(getSelectedWallet).toHaveBeenCalled(); //Now update wallets to include the saved one - const mockWalletsUpdated = [makeWallet('WalletA', ['0x123']), makeWallet('WalletB', ['0xabc'])]; + const mockWalletsUpdated = [makeWallet('WalletA', ['123']), makeWallet('WalletB', ['abc'])]; useWalletsMock.mockReturnValue(mockWalletsUpdated); act(() => { @@ -250,18 +289,18 @@ describe("SelectedWalletAccountContextProvider", () => { ); }); - expect(screen.getByTestId('selected').textContent).toBe('0x123'); + expect(screen.getByTestId('selected').textContent).toBe('123'); }); test("clears in-memory selection when selected wallet disappears", () => { - const getSelectedWallet = jest.fn().mockReturnValue("WalletA:0x123"); + const getSelectedWallet = jest.fn().mockReturnValue("WalletA:123"); const storeSelectedWallet = jest.fn(); const deleteSelectedWallet = jest.fn(); //First render with WalletA present const mockWallets = [ - makeWallet("WalletA", ["0x123", "0x456"]), - makeWallet("WalletB", ["0xabc"]), + makeWallet("WalletA", ["123", "456"]), + makeWallet("WalletB", ["abc"]), ]; const useWalletsMock = useWallets as jest.Mock; useWalletsMock.mockReturnValue(mockWallets); @@ -281,13 +320,13 @@ describe("SelectedWalletAccountContextProvider", () => { ); - //WalletA:0x123 is selected - expect(screen.getByTestId("selected").textContent).toBe("0x123"); + //WalletA:123 is selected + expect(screen.getByTestId("selected").textContent).toBe("123"); expect(getSelectedWallet).toHaveBeenCalled(); //Now update wallets to remove WalletA const mockWalletsUpdated = [ - makeWallet("WalletB", ["0xabc"]), + makeWallet("WalletB", ["abc"]), ]; useWalletsMock.mockReturnValue(mockWalletsUpdated); diff --git a/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts b/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts index c62c90516..b1de3a6a9 100644 --- a/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts +++ b/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import type { UiWallet, UiWalletAccount } from '@wallet-standard/react'; import React from 'react'; @@ -49,3 +50,22 @@ setSelected( // @ts-expect-error must be of correct type 'not a wallet account or undefined', ); + +type SelectedWallet = CtxValue[0]; +declare const selected: SelectedWallet; + +// type-level assertion helpers +type Assert = T; +// allows undefined +// @ts-expect-error TS6196 +type _AssertAllowsUndefined = Assert; +// allows UiWalletAccount +// @ts-expect-error TS6196 +type _AssertAllowsWallet = Assert; + +// Positive: selected wallet account can be a UiWalletAccount +// @ts-expect-error TS6196 +const _selected2: UiWalletAccount = selected!; + +type FilteredWallets = CtxValue[2]; +declare const filteredWallets: FilteredWallets; From ff2b1140459abc6566ab3286e72f991e479751cd Mon Sep 17 00:00:00 2001 From: Swamikumar Date: Thu, 8 Jan 2026 08:52:50 +0000 Subject: [PATCH 13/17] updated the documentation to include the useSelectedWalletAccount, and SelectedWalletAccountContextProvider --- packages/react/README.md | 80 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/react/README.md b/packages/react/README.md index b6ba94811..2c323d21f 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -402,3 +402,83 @@ function SignAndSendTransactionsButton({ account, transactionBytes1, transaction ); } ``` + +### `useSelectedWalletAccount()` + +This hook returns the wallet account you have selected, a function to change the selection, and a list of wallets which pass a filter condition you have provided. This hook must be used in a React Component inside `SelectedWalletAccountContextProvider`. + +#### Arguments + +This hook doesn't take any arguments. + +#### Returns + +The function returns an array consisting of the following elements in the order given: + +- `SelectedWalletAccount`: This element could be a `UiWalletAccount` or `undefined`, and represents the user chosen wallet account. +- `SetSelectedWalletAccount`: A setter function to set the SelectedWalletAccount state. It takes an argument which could be a callback function `(prevState)=>newState` or `newState`. +- `filteredWallets`: List of allowed wallets on the basis provided as `filterWallet` function in `SelectedWalletAccountContextProvider` + +#### Example + +```tsx +import React from 'react'; +import { useSelectedWalletAccount } from '@solana/react'; + +function WalletInfo() { + const [selectedAccount, setSelectedAccount, filteredWallets] = useSelectedWalletAccount(); + + if (!selectedAccount) { + return
No wallet selected
; + } + + return ( +
+

Address: {selectedAccount.address}

+ + + +

Available wallets: {filteredWallets.length}

+
+ ); +} +``` + +### `SelectedWalletAccountContextProvider` + +This is a react context provider for `SelectedWalletAccountContext`. It provides its children access to the context by using either `useSelectedWalletAccount()` or `useContext(SelectedWalletAccountContext)`. + +#### Props + +The provider takes the following props: + +- `filterWallet`: a function used to filter out blocked/unwanted wallets. Its type as `(UiWallet)=>boolean`. +- `stateSync`: an object with three property as follows: + - `storeSelectedWallet`: a function used to store a selected wallet account identifier into persistent storage by using a storage key. It takes a identifier string. `(accountKey)=>void`. + - `getSelectedWallet`: a function used to retrieve the persisted wallet account identifier by using the storage key, from the persistent storage. `()=>accountKey | null` + - `deleteSelectedWallet`: clears any persisted `accountKey` from the persistent storage by using the storage key. `()=>void`. + +#### Example + +```tsx +import React from 'react'; +import { SelectedWalletAccountContextProvider } from '@solana/react'; +import type { UiWallet } from '@wallet-standard/react'; + +const STORAGE_KEY = 'solana-wallet-account-id'; + +export function App() { + return ( + wallet.accounts.length > 0} + stateSync={{ + getSelectedWallet: () => localStorage.getItem(STORAGE_KEY), + storeSelectedWallet: accountKey => localStorage.setItem(STORAGE_KEY, accountKey), + deleteSelectedWallet: () => localStorage.removeItem(STORAGE_KEY), + }} + > + + + ); +} +``` From c72df49340c87bd3229ae45178253ce620f7e697 Mon Sep 17 00:00:00 2001 From: Callum Date: Thu, 8 Jan 2026 12:53:30 +0000 Subject: [PATCH 14/17] Update typetests, lint --- .../SelectedWalletAccountContextProvider.tsx | 112 ++++----- ...letAccountContextProvider-test.browser.tsx | 223 ++++++++---------- ...edWalletAccountContextProvider-typetest.ts | 119 +++++----- 3 files changed, 217 insertions(+), 237 deletions(-) diff --git a/packages/react/src/SelectedWalletAccountContextProvider.tsx b/packages/react/src/SelectedWalletAccountContextProvider.tsx index c66c9b3ec..1a58d40ae 100644 --- a/packages/react/src/SelectedWalletAccountContextProvider.tsx +++ b/packages/react/src/SelectedWalletAccountContextProvider.tsx @@ -1,32 +1,33 @@ -import React from "react"; -import type { - UiWallet, - UiWalletAccount, -} from "@wallet-standard/react"; +import type { UiWallet, UiWalletAccount } from '@wallet-standard/react'; import { getUiWalletAccountStorageKey, uiWalletAccountBelongsToUiWallet, uiWalletAccountsAreSame, - useWallets -} from "@wallet-standard/react"; -import { SelectedWalletAccountContext, SelectedWalletAccountState } from "./selectedWalletAccountContext"; + useWallets, +} from '@wallet-standard/react'; +import React from 'react'; -export type SelectedWalletAccountContextProviderProps = { children: React.ReactNode } & { - filterWallet: (wallet: UiWallet) => Boolean, +import { SelectedWalletAccountContext, SelectedWalletAccountState } from './selectedWalletAccountContext'; + +export type SelectedWalletAccountContextProviderProps = { + filterWallet: (wallet: UiWallet) => boolean; stateSync: { - storeSelectedWallet: (walletId: string) => void, - getSelectedWallet: () => string | null, - deleteSelectedWallet: () => void, - } -}; + deleteSelectedWallet: () => void; + getSelectedWallet: () => string | null; + storeSelectedWallet: (walletId: string) => void; + }; +} & { children: React.ReactNode }; /** -* Returns the saved wallet account when its corresponding wallet, and account is available. -* @param wallets -* @param savedWalletKey -* @returns -*/ -function findSavedWalletAccount(wallets: readonly UiWallet[], savedWalletKey: string | null): UiWalletAccount | undefined { + * Returns the saved wallet account when its corresponding wallet, and account is available. + * @param wallets + * @param savedWalletKey + * @returns + */ +function findSavedWalletAccount( + wallets: readonly UiWallet[], + savedWalletKey: string | null, +): UiWalletAccount | undefined { if (!savedWalletKey) { return; } @@ -45,48 +46,52 @@ function findSavedWalletAccount(wallets: readonly UiWallet[], savedWalletKey: st } /** -* Saves the selected wallet account's storage key to a persistant storage. In future -* sessions it will try to return that same wallet account, or at least one from the same brand of -* wallet if the wallet from which it came is still in the Wallet Standard registry. -* @param children -* @param filterWallet -* @param stateSync -* @returns -*/ -export function SelectedWalletAccountContextProvider( - { children, filterWallet, stateSync }: SelectedWalletAccountContextProviderProps) { + * Saves the selected wallet account's storage key to a persistant storage. In future + * sessions it will try to return that same wallet account, or at least one from the same brand of + * wallet if the wallet from which it came is still in the Wallet Standard registry. + * @param children + * @param filterWallet + * @param stateSync + * @returns + */ +export function SelectedWalletAccountContextProvider({ + children, + filterWallet, + stateSync, +}: SelectedWalletAccountContextProviderProps) { const wallets = useWallets(); const filteredWallets = React.useMemo(() => wallets.filter(filterWallet), [wallets, filterWallet]); const wasSetterInvokedRef = React.useRef(false); - const [selectedWalletAccount, setSelectedWalletAccountInternal] = React.useState( - () => { - const savedWalletKey = stateSync.getSelectedWallet(); - const savedWalletAccount = findSavedWalletAccount(filteredWallets, savedWalletKey); - return savedWalletAccount; - }); + const [selectedWalletAccount, setSelectedWalletAccountInternal] = React.useState(() => { + const savedWalletKey = stateSync.getSelectedWallet(); + const savedWalletAccount = findSavedWalletAccount(filteredWallets, savedWalletKey); + return savedWalletAccount; + }); // Public setter: mark the per-instance ref synchronously to avoid races, then schedule state update. // useCallback stabilises the setter for consumers. - const setSelectedWalletAccount: React.Dispatch< - React.SetStateAction - > = React.useCallback(setStateAction => { - wasSetterInvokedRef.current = true; - setSelectedWalletAccountInternal(prevSelectedWalletAccount => { - const nextWalletAccount = - typeof setStateAction === 'function' ? setStateAction(prevSelectedWalletAccount) : setStateAction; - return nextWalletAccount; - }); - }, [setSelectedWalletAccountInternal]); + const setSelectedWalletAccount: React.Dispatch> = + React.useCallback( + setStateAction => { + wasSetterInvokedRef.current = true; + setSelectedWalletAccountInternal(prevSelectedWalletAccount => { + const nextWalletAccount = + typeof setStateAction === 'function' + ? setStateAction(prevSelectedWalletAccount) + : setStateAction; + return nextWalletAccount; + }); + }, + [setSelectedWalletAccountInternal], + ); //Sync to persistant storage when selectedWalletAccount changes React.useEffect(() => { if (!wasSetterInvokedRef.current) return; - const accountKey = selectedWalletAccount - ? getUiWalletAccountStorageKey(selectedWalletAccount) - : undefined; + const accountKey = selectedWalletAccount ? getUiWalletAccountStorageKey(selectedWalletAccount) : undefined; if (accountKey) { stateSync.storeSelectedWallet(accountKey); @@ -95,8 +100,7 @@ export function SelectedWalletAccountContextProvider( } }, [selectedWalletAccount, stateSync]); - - //Auto-restore saved wallet account if it appears later, + //Auto-restore saved wallet account if it appears later, //and if the user hasn't made an explicit choice yet. React.useEffect(() => { if (wasSetterInvokedRef.current) return; @@ -140,5 +144,5 @@ export function SelectedWalletAccountContextProvider( {children} - ) -} \ No newline at end of file + ); +} diff --git a/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx b/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx index 89de0f92e..1e72d1c59 100644 --- a/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx +++ b/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx @@ -1,25 +1,34 @@ -import React from "react"; -import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; -import { SelectedWalletAccountContextProvider } from "../SelectedWalletAccountContextProvider"; -import { useSelectedWalletAccount } from "../selectedWalletAccountContext"; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { useSelectedWalletAccount } from '../selectedWalletAccountContext'; +import { SelectedWalletAccountContextProvider } from '../SelectedWalletAccountContextProvider'; // Mock wallet-standard/react exports the provider depends on -jest.mock("@wallet-standard/react", () => { +jest.mock('@wallet-standard/react', () => { return { - useWallets: jest.fn(), // The provider itself uses only getUiWalletAccountStorageKey, uiWalletAccountsAreSame, uiWalletAccountBelongsToUiWallet getUiWalletAccountStorageKey: jest.fn(), - uiWalletAccountsAreSame: jest.fn(), + uiWalletAccountBelongsToUiWallet: jest.fn(), + uiWalletAccountsAreSame: jest.fn(), + useWallets: jest.fn(), }; }); -import { getUiWalletAccountStorageKey, uiWalletAccountBelongsToUiWallet, uiWalletAccountsAreSame, useWallets } from "@wallet-standard/react"; +import { + getUiWalletAccountStorageKey, + UiWallet, + UiWalletAccount, + uiWalletAccountBelongsToUiWallet, + uiWalletAccountsAreSame, + useWallets, +} from '@wallet-standard/react'; function makeWallet(name: string, accounts: string[]) { return { - name, accounts: accounts.map(addr => ({ address: addr, walletName: name })), + name, }; } @@ -28,219 +37,190 @@ let renderCount = 0; function Consumer() { renderCount++; if (renderCount > 10) { - throw new Error("Too many re-renders"); + throw new Error('Too many re-renders'); } const [walletAccount, setWalletAccount, filteredWallets] = useSelectedWalletAccount(); return (
{walletAccount ? walletAccount.address : 'none'}
- -
{filteredWallets.map(w => w.name).join(", ")}
+
{filteredWallets.map(w => w.name).join(', ')}
); } -describe("SelectedWalletAccountContextProvider", () => { +describe('SelectedWalletAccountContextProvider', () => { beforeEach(() => { jest.clearAllMocks(); //Mock implementations for wallet-standard/react functions (getUiWalletAccountStorageKey as jest.Mock).mockImplementation( - (account) => `${account.walletName}:${account.address}` - ); - (uiWalletAccountsAreSame as jest.Mock).mockImplementation( - (a, b) => a?.address === b?.address + account => `${account.walletName}:${account.address}`, ); + (uiWalletAccountsAreSame as jest.Mock).mockImplementation((a, b) => a?.address === b?.address); (uiWalletAccountBelongsToUiWallet as jest.Mock).mockImplementation( - (account, wallet) => account?.walletName === wallet?.name + (account, wallet) => account?.walletName === wallet?.name, ); renderCount = 0; }); - test("only filtered wallets are usable", () => { + test('only filtered wallets are usable', () => { const stateSync = { + deleteSelectedWallet: jest.fn(), getSelectedWallet: jest.fn(), storeSelectedWallet: jest.fn(), - deleteSelectedWallet: jest.fn(), }; - const walletA = makeWallet("WalletA", ["123"]); - const walletB = makeWallet("WalletB", ["abc"]); - - const mockWallets = [ - walletA, - walletB, - ]; + const walletA = makeWallet('WalletA', ['123']); + const walletB = makeWallet('WalletB', ['abc']); + + const mockWallets = [walletA, walletB]; (useWallets as jest.Mock).mockReturnValue(mockWallets); - const allowOnlyA = (wallet: any) => wallet.name === "WalletA"; + const allowOnlyA = (wallet: UiWallet) => wallet.name === 'WalletA'; render( - + - + , ); - expect(screen.getByTestId("selected").textContent).toBe("none"); - expect(screen.getByTestId("filtered-wallets").textContent).toContain("WalletA"); - expect(screen.getByTestId("filtered-wallets").textContent).not.toContain("WalletB"); + expect(screen.getByTestId('selected').textContent).toBe('none'); + expect(screen.getByTestId('filtered-wallets').textContent).toContain('WalletA'); + expect(screen.getByTestId('filtered-wallets').textContent).not.toContain('WalletB'); - act(()=>{ - fireEvent.click(screen.getByTestId("pick-b")); - }) + act(() => { + fireEvent.click(screen.getByTestId('pick-b')); + }); /** Even if walletB is selected, since it is not available the provider will return 'undefined' for the walletAccount */ - expect(screen.getByTestId("selected").textContent).toBe("none"); + expect(screen.getByTestId('selected').textContent).toBe('none'); }); - test("initializes from saved key", () => { + test('initializes from saved key', () => { //saved key matchs a wallet that is available from useWallets const stateSync = { - getSelectedWallet: jest.fn().mockReturnValue("WalletA:123"), - storeSelectedWallet: jest.fn(), deleteSelectedWallet: jest.fn(), + getSelectedWallet: jest.fn().mockReturnValue('WalletA:123'), + storeSelectedWallet: jest.fn(), }; - const walletA = makeWallet("WalletA", ["123", "456"]); - const walletB = makeWallet("WalletB", ["abc"]); + const walletA = makeWallet('WalletA', ['123', '456']); + const walletB = makeWallet('WalletB', ['abc']); - const mockWallets = [ - walletA, - walletB, - ]; + const mockWallets = [walletA, walletB]; (useWallets as jest.Mock).mockReturnValue(mockWallets); const allowWallets = () => true; render( - + - + , ); - expect(screen.getByTestId("selected").textContent).toBe("123"); + expect(screen.getByTestId('selected').textContent).toBe('123'); expect(stateSync.getSelectedWallet).toHaveBeenCalled(); }); - test("initializes with no selection when saved key is invalid", () => { + test('initializes with no selection when saved key is invalid', () => { //saved key matchs a wallet that is available from useWallets const stateSync = { + deleteSelectedWallet: jest.fn(), getSelectedWallet: jest.fn().mockReturnValue(null), storeSelectedWallet: jest.fn(), - deleteSelectedWallet: jest.fn(), }; - const mockWallets = [ - makeWallet("WalletA", ["123", "456"]), - makeWallet("WalletB", ["abc"]), - ]; + const mockWallets = [makeWallet('WalletA', ['123', '456']), makeWallet('WalletB', ['abc'])]; (useWallets as jest.Mock).mockReturnValue(mockWallets); const allowWallets = () => true; render( - + - + , ); - expect(screen.getByTestId("selected").textContent).toBe("none"); + expect(screen.getByTestId('selected').textContent).toBe('none'); expect(stateSync.getSelectedWallet).toHaveBeenCalled(); }); - test("initializes with selected wallet when make a selection from the available wallets", () => { + test('initializes with selected wallet when make a selection from the available wallets', () => { const stateSync = { - getSelectedWallet: jest.fn().mockReturnValue(null), - storeSelectedWallet: jest.fn((_accountKey: string) => { }), deleteSelectedWallet: jest.fn(), + getSelectedWallet: jest.fn().mockReturnValue(null), + storeSelectedWallet: jest.fn((_accountKey: string) => {}), }; - const mockWallets = [ - makeWallet("WalletA", ["123", "456"]), - makeWallet("WalletB", ["abc"]), - ]; + const mockWallets = [makeWallet('WalletA', ['123', '456']), makeWallet('WalletB', ['abc'])]; (useWallets as jest.Mock).mockReturnValue(mockWallets); const allowWallets = () => true; render( - + - + , ); - expect(screen.getByTestId("selected").textContent).toBe("none"); + expect(screen.getByTestId('selected').textContent).toBe('none'); expect(stateSync.getSelectedWallet).toHaveBeenCalled(); //Make a selection act(() => { - fireEvent.click(screen.getByTestId("pick-b")); + fireEvent.click(screen.getByTestId('pick-b')); }); - expect(screen.getByTestId("selected").textContent).toBe("abc"); - expect(stateSync.storeSelectedWallet).toHaveBeenCalledWith("WalletB:abc"); + expect(screen.getByTestId('selected').textContent).toBe('abc'); + expect(stateSync.storeSelectedWallet).toHaveBeenCalledWith('WalletB:abc'); }); - test("allows changing and clearing selection", async () => { + test('allows changing and clearing selection', async () => { const stateSync = { - getSelectedWallet: jest.fn().mockReturnValue(null), - storeSelectedWallet: jest.fn((_accountKey: string) => { }), deleteSelectedWallet: jest.fn(), + getSelectedWallet: jest.fn().mockReturnValue(null), + storeSelectedWallet: jest.fn((_accountKey: string) => {}), }; - const mockWallets = [ - makeWallet("WalletA", ["123", "456"]), - makeWallet("WalletB", ["abc"]), - ]; + const mockWallets = [makeWallet('WalletA', ['123', '456']), makeWallet('WalletB', ['abc'])]; (useWallets as jest.Mock).mockReturnValue(mockWallets); const allowWallets = () => true; render( - + - + , ); //Initial state - expect(screen.getByTestId("selected").textContent).toBe("none"); + expect(screen.getByTestId('selected').textContent).toBe('none'); //Pick B - fireEvent.click(screen.getByTestId("pick-b")); + fireEvent.click(screen.getByTestId('pick-b')); - expect(screen.getByTestId("selected").textContent).toBe("abc"); + expect(screen.getByTestId('selected').textContent).toBe('abc'); await waitFor(() => { - expect(stateSync.storeSelectedWallet).toHaveBeenCalledWith("WalletB:abc"); + expect(stateSync.storeSelectedWallet).toHaveBeenCalledWith('WalletB:abc'); }); //Clear - fireEvent.click(screen.getByTestId("clear")); + fireEvent.click(screen.getByTestId('clear')); - expect(screen.getByTestId("selected").textContent).toBe("none"); + expect(screen.getByTestId('selected').textContent).toBe('none'); await waitFor(() => expect(stateSync.deleteSelectedWallet).toHaveBeenCalled()); }); - test - test('auto-restores saved wallet when it appears later', () => { const getSelectedWallet = jest.fn().mockReturnValue('WalletA:123'); const storeSelectedWallet = jest.fn(); @@ -249,7 +229,7 @@ describe("SelectedWalletAccountContextProvider", () => { const allowWallets = () => true; //First render with no wallets - const mockWallets: any[] = []; + const mockWallets: UiWallet[] = []; const useWalletsMock = useWallets as jest.Mock; useWalletsMock.mockReturnValue(mockWallets); @@ -257,13 +237,13 @@ describe("SelectedWalletAccountContextProvider", () => { - + , ); //nothing selected yet @@ -279,29 +259,26 @@ describe("SelectedWalletAccountContextProvider", () => { - + , ); }); expect(screen.getByTestId('selected').textContent).toBe('123'); }); - test("clears in-memory selection when selected wallet disappears", () => { - const getSelectedWallet = jest.fn().mockReturnValue("WalletA:123"); + test('clears in-memory selection when selected wallet disappears', () => { + const getSelectedWallet = jest.fn().mockReturnValue('WalletA:123'); const storeSelectedWallet = jest.fn(); const deleteSelectedWallet = jest.fn(); //First render with WalletA present - const mockWallets = [ - makeWallet("WalletA", ["123", "456"]), - makeWallet("WalletB", ["abc"]), - ]; + const mockWallets = [makeWallet('WalletA', ['123', '456']), makeWallet('WalletB', ['abc'])]; const useWalletsMock = useWallets as jest.Mock; useWalletsMock.mockReturnValue(mockWallets); @@ -311,23 +288,21 @@ describe("SelectedWalletAccountContextProvider", () => { - + , ); //WalletA:123 is selected - expect(screen.getByTestId("selected").textContent).toBe("123"); + expect(screen.getByTestId('selected').textContent).toBe('123'); expect(getSelectedWallet).toHaveBeenCalled(); //Now update wallets to remove WalletA - const mockWalletsUpdated = [ - makeWallet("WalletB", ["abc"]), - ]; + const mockWalletsUpdated = [makeWallet('WalletB', ['abc'])]; useWalletsMock.mockReturnValue(mockWalletsUpdated); act(() => { @@ -335,16 +310,16 @@ describe("SelectedWalletAccountContextProvider", () => { - + , ); }); - expect(screen.getByTestId("selected").textContent).toBe("none"); + expect(screen.getByTestId('selected').textContent).toBe('none'); }); -}); \ No newline at end of file +}); diff --git a/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts b/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts index b1de3a6a9..62e3667a5 100644 --- a/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts +++ b/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts @@ -1,71 +1,72 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import type { UiWallet, UiWalletAccount } from '@wallet-standard/react'; import React from 'react'; import type { useSelectedWalletAccount } from '../selectedWalletAccountContext'; import { SelectedWalletAccountContextProvider } from '../SelectedWalletAccountContextProvider'; -/** - * Positive: provider accepts correct props. - */ -React.createElement(SelectedWalletAccountContextProvider, { - children: React.createElement('div', null), - filterWallet: (_wallet: UiWallet) => true, - stateSync: { - deleteSelectedWallet: () => {}, - getSelectedWallet: () => null, - storeSelectedWallet: (_walletId: string) => {}, - }, -}); +// [DESCRIBE] SelectedWalletAccountContextProvider +{ + // It accepts correct props + { + React.createElement(SelectedWalletAccountContextProvider, { + children: React.createElement('div', null), + filterWallet: (_wallet: UiWallet) => true, + stateSync: { + deleteSelectedWallet: () => {}, + getSelectedWallet: () => null, + storeSelectedWallet: (_walletId: string) => {}, + }, + }); + } -/** - * Negative: filterWallet must return a boolean. - */ -React.createElement(SelectedWalletAccountContextProvider, { - children: React.createElement('div', null), - //@ts-expect-error filterWallet must return a boolean - filterWallet: (_wallet: UiWallet) => 'not a boolean', - stateSync: { - deleteSelectedWallet: () => {}, - getSelectedWallet: () => null, - storeSelectedWallet: (_walletId: string) => {}, - }, -}); + // It requires `filterWallet` to return a boolean + { + React.createElement(SelectedWalletAccountContextProvider, { + children: React.createElement('div', null), + // @ts-expect-error filterWallet must return a boolean + filterWallet: (_wallet: UiWallet) => 'not a boolean', + stateSync: { + deleteSelectedWallet: () => {}, + getSelectedWallet: () => null, + storeSelectedWallet: (_walletId: string) => {}, + }, + }); + } +} -/** - * Context value: tuple shape and setter behavior. - */ -type CtxValue = ReturnType; -type SetSelected = CtxValue[1]; +// [DESCRIBE] useSelectedWalletAccount +{ + type CtxValue = ReturnType; -declare const setSelected: SetSelected; -// Positive: setter accepts undefined -setSelected(undefined); -// Positive: setter accepts an updater function -setSelected(prev => prev); -// Positive: setter accepts a UiWalletAccount value -setSelected({} as UiWalletAccount); -// Negative: setter rejects invalid types -setSelected( - // @ts-expect-error must be of correct type - 'not a wallet account or undefined', -); + // It returns selected wallet account + { + type SelectedWallet = CtxValue[0]; + undefined satisfies SelectedWallet; + null as unknown as UiWalletAccount satisfies SelectedWallet; + } -type SelectedWallet = CtxValue[0]; -declare const selected: SelectedWallet; + // It returns a setter + { + type SetSelected = CtxValue[1]; -// type-level assertion helpers -type Assert = T; -// allows undefined -// @ts-expect-error TS6196 -type _AssertAllowsUndefined = Assert; -// allows UiWalletAccount -// @ts-expect-error TS6196 -type _AssertAllowsWallet = Assert; + const setSelected = null as unknown as SetSelected; + // Positive: setter accepts undefined + setSelected(undefined); + // Positive: setter accepts an updater function + setSelected(prev => prev); + // Positive: setter accepts a UiWalletAccount value + setSelected({} as UiWalletAccount); + // Negative: setter rejects invalid types + setSelected( + // @ts-expect-error must be of correct type + 'not a wallet account or undefined', + ); + } -// Positive: selected wallet account can be a UiWalletAccount -// @ts-expect-error TS6196 -const _selected2: UiWalletAccount = selected!; - -type FilteredWallets = CtxValue[2]; -declare const filteredWallets: FilteredWallets; + // It returns filtered wallets + { + type FilteredWallets = CtxValue[2]; + const filteredWallets = null as unknown as FilteredWallets; + filteredWallets satisfies UiWallet[]; + } +} From 36bdc19528cd80484354c68383dfab087f0f0e89 Mon Sep 17 00:00:00 2001 From: Callum Date: Thu, 8 Jan 2026 13:14:28 +0000 Subject: [PATCH 15/17] Fix lockfile --- pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e03e5ecc4..4b3193244 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -876,7 +876,7 @@ importers: version: 0.1.1 '@wallet-standard/react': specifier: ^1.0.1 - version: 1.0.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.0.1(react-dom@19.2.1(react@19.2.3))(react@19.2.3) '@wallet-standard/ui': specifier: ^1.0.1 version: 1.0.1 @@ -895,7 +895,7 @@ importers: version: link:../rpc-types '@testing-library/react': specifier: ^16.3.1 - version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.3))(react@19.2.3) '@types/react': specifier: ^19.2.1 version: 19.2.7 @@ -11437,12 +11437,12 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/react@16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@testing-library/react@16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.3))(react@19.2.3)': dependencies: '@babel/runtime': 7.28.4 '@testing-library/dom': 10.4.1 - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + react: 19.2.3 + react-dom: 19.2.1(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) From a6b54251e0af95dc9d6f508677aa1aa941c34c86 Mon Sep 17 00:00:00 2001 From: Callum Date: Thu, 8 Jan 2026 16:29:47 +0000 Subject: [PATCH 16/17] Update docs + param names --- packages/react/README.md | 16 ++--- packages/react/package.json | 3 +- .../SelectedWalletAccountContextProvider.tsx | 23 ++++--- ...letAccountContextProvider-test.browser.tsx | 18 +++--- ...edWalletAccountContextProvider-typetest.ts | 8 +-- pnpm-lock.yaml | 60 ++++++++++++++----- 6 files changed, 80 insertions(+), 48 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index 2c323d21f..5ae00298c 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -405,7 +405,7 @@ function SignAndSendTransactionsButton({ account, transactionBytes1, transaction ### `useSelectedWalletAccount()` -This hook returns the wallet account you have selected, a function to change the selection, and a list of wallets which pass a filter condition you have provided. This hook must be used in a React Component inside `SelectedWalletAccountContextProvider`. +This hook returns the wallet account that is selected, a function to change the selection, and a list of wallets which pass a filter condition you have provided. This hook must be used in a React Component inside `SelectedWalletAccountContextProvider`. #### Arguments @@ -415,9 +415,9 @@ This hook doesn't take any arguments. The function returns an array consisting of the following elements in the order given: -- `SelectedWalletAccount`: This element could be a `UiWalletAccount` or `undefined`, and represents the user chosen wallet account. +- `SelectedWalletAccount`: This element could be a `UiWalletAccount` or `undefined`, and represents the selected wallet account. - `SetSelectedWalletAccount`: A setter function to set the SelectedWalletAccount state. It takes an argument which could be a callback function `(prevState)=>newState` or `newState`. -- `filteredWallets`: List of allowed wallets on the basis provided as `filterWallet` function in `SelectedWalletAccountContextProvider` +- `filteredWallets`: List of filtered wallets using the function provided as `filterWallet` function in `SelectedWalletAccountContextProvider` #### Example @@ -452,11 +452,11 @@ This is a react context provider for `SelectedWalletAccountContext`. It provides The provider takes the following props: -- `filterWallet`: a function used to filter out blocked/unwanted wallets. Its type as `(UiWallet)=>boolean`. -- `stateSync`: an object with three property as follows: - - `storeSelectedWallet`: a function used to store a selected wallet account identifier into persistent storage by using a storage key. It takes a identifier string. `(accountKey)=>void`. - - `getSelectedWallet`: a function used to retrieve the persisted wallet account identifier by using the storage key, from the persistent storage. `()=>accountKey | null` - - `deleteSelectedWallet`: clears any persisted `accountKey` from the persistent storage by using the storage key. `()=>void`. +- `filterWallet`: a function used to filter supported wallets. For example you might use this to restrict your app to wallets that support `solana:mainnet`. +- `stateSync`: an object to store the selected wallet, with these properties: + - `storeSelectedWallet`: a function used to store a selected wallet account identifier (as a string) into persistent storage. For example this might write to local storage in the browser. The string stored is `${walletName}:${accountAddress}`. + - `getSelectedWallet`: a function used to retrieve the persisted wallet account identifier from the persistent storage. + - `deleteSelectedWallet`: clears any persisted wallet account identifier from the persistent storage. #### Example diff --git a/packages/react/package.json b/packages/react/package.json index f8e649454..5aa2efab1 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -95,8 +95,9 @@ "@types/react": "^19.2.1", "@types/react-test-renderer": "^19.1.0", "react": "^19.2.3", + "react-dom": "^19.2.3", "react-error-boundary": "^5.0.0", - "react-test-renderer": "^19.2.1" + "react-test-renderer": "^19.2.3" }, "peerDependencies": { "react": ">=18" diff --git a/packages/react/src/SelectedWalletAccountContextProvider.tsx b/packages/react/src/SelectedWalletAccountContextProvider.tsx index 1a58d40ae..96d12a821 100644 --- a/packages/react/src/SelectedWalletAccountContextProvider.tsx +++ b/packages/react/src/SelectedWalletAccountContextProvider.tsx @@ -10,19 +10,19 @@ import React from 'react'; import { SelectedWalletAccountContext, SelectedWalletAccountState } from './selectedWalletAccountContext'; export type SelectedWalletAccountContextProviderProps = { - filterWallet: (wallet: UiWallet) => boolean; + filterWallets: (wallet: UiWallet) => boolean; stateSync: { deleteSelectedWallet: () => void; getSelectedWallet: () => string | null; - storeSelectedWallet: (walletId: string) => void; + storeSelectedWallet: (accountKey: string) => void; }; } & { children: React.ReactNode }; /** * Returns the saved wallet account when its corresponding wallet, and account is available. - * @param wallets - * @param savedWalletKey - * @returns + * @param wallets All wallets available to select in the app + * @param savedWalletKey The saved wallet account storage key + * @returns The saved wallet account, or undefined if not found */ function findSavedWalletAccount( wallets: readonly UiWallet[], @@ -49,19 +49,18 @@ function findSavedWalletAccount( * Saves the selected wallet account's storage key to a persistant storage. In future * sessions it will try to return that same wallet account, or at least one from the same brand of * wallet if the wallet from which it came is still in the Wallet Standard registry. - * @param children - * @param filterWallet - * @param stateSync - * @returns + * @param children The child components that will have access to the selected wallet account context + * @param filterWallets A function to filter which wallets are available in the app + * @param stateSync An object with methods to synchronize the selected wallet account state with persistent storage + * @returns A React component that provides the selected wallet account context to its children */ export function SelectedWalletAccountContextProvider({ children, - filterWallet, + filterWallets, stateSync, }: SelectedWalletAccountContextProviderProps) { const wallets = useWallets(); - const filteredWallets = React.useMemo(() => wallets.filter(filterWallet), [wallets, filterWallet]); - + const filteredWallets = React.useMemo(() => wallets.filter(filterWallets), [wallets, filterWallets]); const wasSetterInvokedRef = React.useRef(false); const [selectedWalletAccount, setSelectedWalletAccountInternal] = React.useState(() => { diff --git a/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx b/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx index 1e72d1c59..5bdf3e448 100644 --- a/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx +++ b/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx @@ -89,7 +89,7 @@ describe('SelectedWalletAccountContextProvider', () => { const allowOnlyA = (wallet: UiWallet) => wallet.name === 'WalletA'; render( - + , ); @@ -123,7 +123,7 @@ describe('SelectedWalletAccountContextProvider', () => { const allowWallets = () => true; render( - + , ); @@ -146,7 +146,7 @@ describe('SelectedWalletAccountContextProvider', () => { const allowWallets = () => true; render( - + , ); @@ -168,7 +168,7 @@ describe('SelectedWalletAccountContextProvider', () => { const allowWallets = () => true; render( - + , ); @@ -198,7 +198,7 @@ describe('SelectedWalletAccountContextProvider', () => { const allowWallets = () => true; render( - + , ); @@ -235,7 +235,7 @@ describe('SelectedWalletAccountContextProvider', () => { const { rerender } = render( { act(() => { rerender( { const { rerender } = render( { act(() => { rerender( true, + filterWallets: (_wallet: UiWallet) => true, stateSync: { deleteSelectedWallet: () => {}, getSelectedWallet: () => null, - storeSelectedWallet: (_walletId: string) => {}, + storeSelectedWallet: (_accountKey: string) => {}, }, }); } @@ -24,11 +24,11 @@ import { SelectedWalletAccountContextProvider } from '../SelectedWalletAccountCo React.createElement(SelectedWalletAccountContextProvider, { children: React.createElement('div', null), // @ts-expect-error filterWallet must return a boolean - filterWallet: (_wallet: UiWallet) => 'not a boolean', + filterWallets: (_wallet: UiWallet) => 'not a boolean', stateSync: { deleteSelectedWallet: () => {}, getSelectedWallet: () => null, - storeSelectedWallet: (_walletId: string) => {}, + storeSelectedWallet: (_accountKey: string) => {}, }, }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b3193244..2e36025de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -876,7 +876,7 @@ importers: version: 0.1.1 '@wallet-standard/react': specifier: ^1.0.1 - version: 1.0.1(react-dom@19.2.1(react@19.2.3))(react@19.2.3) + version: 1.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@wallet-standard/ui': specifier: ^1.0.1 version: 1.0.1 @@ -895,7 +895,7 @@ importers: version: link:../rpc-types '@testing-library/react': specifier: ^16.3.1 - version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.3))(react@19.2.3) + version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/react': specifier: ^19.2.1 version: 19.2.7 @@ -905,12 +905,15 @@ importers: react: specifier: ^19.2.3 version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) react-error-boundary: specifier: ^5.0.0 version: 5.0.0(react@19.2.3) react-test-renderer: - specifier: ^19.2.1 - version: 19.2.1(react@19.2.3) + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) packages/rpc: dependencies: @@ -7242,6 +7245,11 @@ packages: peerDependencies: react: ^19.2.1 + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + react-error-boundary@5.0.0: resolution: {integrity: sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ==} peerDependencies: @@ -7253,8 +7261,8 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-is@19.2.1: - resolution: {integrity: sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==} + react-is@19.2.3: + resolution: {integrity: sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==} react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} @@ -7286,10 +7294,10 @@ packages: '@types/react': optional: true - react-test-renderer@19.2.1: - resolution: {integrity: sha512-xsyf515ij+d8Rs/tsDZSJYXn+GdYO/IOw9BVFtjJbrrFR+dL1yZQQN1ChxY6otYAsCeAHhU0XsKyJUzP6omyvQ==} + react-test-renderer@19.2.3: + resolution: {integrity: sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw==} peerDependencies: - react: ^19.2.1 + react: ^19.2.3 react@19.2.3: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} @@ -11437,12 +11445,12 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/react@16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.3))(react@19.2.3)': + '@testing-library/react@16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@babel/runtime': 7.28.4 '@testing-library/dom': 10.4.1 react: 19.2.3 - react-dom: 19.2.1(react@19.2.3) + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) @@ -11954,6 +11962,18 @@ snapshots: react: 19.2.3 react-dom: 19.2.1(react@19.2.3) + '@wallet-standard/react-core@1.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@wallet-standard/app': 1.1.0 + '@wallet-standard/base': 1.1.0 + '@wallet-standard/errors': 0.1.1 + '@wallet-standard/experimental-features': 0.2.0 + '@wallet-standard/features': 1.1.0 + '@wallet-standard/ui': 1.0.1 + '@wallet-standard/ui-registry': 1.0.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + '@wallet-standard/react@1.0.1(react-dom@19.2.1(react@19.2.3))(react@19.2.3)': dependencies: '@wallet-standard/react-core': 1.0.1(react-dom@19.2.1(react@19.2.3))(react@19.2.3) @@ -11961,6 +11981,13 @@ snapshots: - react - react-dom + '@wallet-standard/react@1.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@wallet-standard/react-core': 1.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + transitivePeerDependencies: + - react + - react-dom + '@wallet-standard/ui-compare@1.0.1': dependencies: '@wallet-standard/base': 1.1.0 @@ -14858,6 +14885,11 @@ snapshots: react: 19.2.3 scheduler: 0.27.0 + react-dom@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + react-error-boundary@5.0.0(react@19.2.3): dependencies: '@babel/runtime': 7.26.10 @@ -14867,7 +14899,7 @@ snapshots: react-is@18.3.1: {} - react-is@19.2.1: {} + react-is@19.2.3: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3): dependencies: @@ -14896,10 +14928,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 - react-test-renderer@19.2.1(react@19.2.3): + react-test-renderer@19.2.3(react@19.2.3): dependencies: react: 19.2.3 - react-is: 19.2.1 + react-is: 19.2.3 scheduler: 0.27.0 react@19.2.3: {} From 7745d9f83f956801e6de5450049edcda7e627a96 Mon Sep 17 00:00:00 2001 From: Callum Date: Thu, 8 Jan 2026 16:47:06 +0000 Subject: [PATCH 17/17] Add changeset --- .changeset/lovely-mugs-flash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lovely-mugs-flash.md diff --git a/.changeset/lovely-mugs-flash.md b/.changeset/lovely-mugs-flash.md new file mode 100644 index 000000000..3d62a68f6 --- /dev/null +++ b/.changeset/lovely-mugs-flash.md @@ -0,0 +1,5 @@ +--- +'@solana/react': minor +--- + +Add a context provider `` and `useSelectedWalletAccount` to persist a selected wallet account