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 diff --git a/packages/react/README.md b/packages/react/README.md index b6ba94811..5ae00298c 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 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 + +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 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 filtered wallets using the function 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 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 + +```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), + }} + > + + + ); +} +``` diff --git a/packages/react/package.json b/packages/react/package.json index d0bf57545..5aa2efab1 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" }, @@ -90,11 +91,13 @@ "@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", + "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 new file mode 100644 index 000000000..96d12a821 --- /dev/null +++ b/packages/react/src/SelectedWalletAccountContextProvider.tsx @@ -0,0 +1,147 @@ +import type { UiWallet, UiWalletAccount } from '@wallet-standard/react'; +import { + getUiWalletAccountStorageKey, + uiWalletAccountBelongsToUiWallet, + uiWalletAccountsAreSame, + useWallets, +} from '@wallet-standard/react'; +import React from 'react'; + +import { SelectedWalletAccountContext, SelectedWalletAccountState } from './selectedWalletAccountContext'; + +export type SelectedWalletAccountContextProviderProps = { + filterWallets: (wallet: UiWallet) => boolean; + stateSync: { + deleteSelectedWallet: () => void; + getSelectedWallet: () => string | null; + storeSelectedWallet: (accountKey: string) => void; + }; +} & { children: React.ReactNode }; + +/** + * Returns the saved wallet account when its corresponding wallet, and account is available. + * @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[], + 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 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, + filterWallets, + stateSync, +}: SelectedWalletAccountContextProviderProps) { + const wallets = useWallets(); + const filteredWallets = React.useMemo(() => wallets.filter(filterWallets), [wallets, filterWallets]); + const wasSetterInvokedRef = React.useRef(false); + + 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.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; + + if (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) return; + const savedWalletKey = stateSync.getSelectedWallet(); + const savedAccount = findSavedWalletAccount(filteredWallets, savedWalletKey); + if (savedAccount && selectedWalletAccount && uiWalletAccountsAreSame(savedAccount, selectedWalletAccount)) { + return; + } + if (savedAccount) { + setSelectedWalletAccountInternal(savedAccount); + } + }, [filteredWallets, 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). + // Cleanup shouldn't be run if user has made a selection or selectedWalletAccount/walletAccount are loading or undefined + if (!selectedWalletAccount) return; //still loading ... + if (wasSetterInvokedRef.current) return; //user made a selection + if (!walletAccount) { + setSelectedWalletAccountInternal(undefined); + } + }, [selectedWalletAccount, walletAccount]); + + return ( + + {children} + + ); +} diff --git a/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx b/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx new file mode 100644 index 000000000..5bdf3e448 --- /dev/null +++ b/packages/react/src/__tests__/SelectedWalletAccountContextProvider-test.browser.tsx @@ -0,0 +1,325 @@ +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', () => { + return { + // The provider itself uses only getUiWalletAccountStorageKey, uiWalletAccountsAreSame, uiWalletAccountBelongsToUiWallet + getUiWalletAccountStorageKey: jest.fn(), + + uiWalletAccountBelongsToUiWallet: jest.fn(), + uiWalletAccountsAreSame: jest.fn(), + useWallets: jest.fn(), + }; +}); + +import { + getUiWalletAccountStorageKey, + UiWallet, + UiWalletAccount, + uiWalletAccountBelongsToUiWallet, + uiWalletAccountsAreSame, + useWallets, +} from '@wallet-standard/react'; + +function makeWallet(name: string, accounts: string[]) { + return { + accounts: accounts.map(addr => ({ address: addr, walletName: name })), + name, + }; +} + +/** 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 [walletAccount, setWalletAccount, filteredWallets] = useSelectedWalletAccount(); + return ( +
+
{walletAccount ? walletAccount.address : 'none'}
+ + +
{filteredWallets.map(w => w.name).join(', ')}
+
+ ); +} + +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('only filtered wallets are usable', () => { + const stateSync = { + deleteSelectedWallet: jest.fn(), + getSelectedWallet: jest.fn(), + storeSelectedWallet: 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: 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'); + + 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 = { + deleteSelectedWallet: jest.fn(), + getSelectedWallet: jest.fn().mockReturnValue('WalletA:123'), + storeSelectedWallet: jest.fn(), + }; + + const walletA = makeWallet('WalletA', ['123', '456']); + const walletB = makeWallet('WalletB', ['abc']); + + const mockWallets = [walletA, walletB]; + (useWallets as jest.Mock).mockReturnValue(mockWallets); + + const allowWallets = () => true; + + render( + + + , + ); + + expect(screen.getByTestId('selected').textContent).toBe('123'); + 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 = { + deleteSelectedWallet: jest.fn(), + getSelectedWallet: jest.fn().mockReturnValue(null), + storeSelectedWallet: jest.fn(), + }; + + 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(stateSync.getSelectedWallet).toHaveBeenCalled(); + }); + + test('initializes with selected wallet when make a selection from the available wallets', () => { + const stateSync = { + deleteSelectedWallet: jest.fn(), + getSelectedWallet: jest.fn().mockReturnValue(null), + storeSelectedWallet: jest.fn((_accountKey: string) => {}), + }; + + 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(stateSync.getSelectedWallet).toHaveBeenCalled(); + + //Make a selection + act(() => { + fireEvent.click(screen.getByTestId('pick-b')); + }); + + expect(screen.getByTestId('selected').textContent).toBe('abc'); + expect(stateSync.storeSelectedWallet).toHaveBeenCalledWith('WalletB:abc'); + }); + + test('allows changing and clearing selection', async () => { + const stateSync = { + deleteSelectedWallet: jest.fn(), + getSelectedWallet: jest.fn().mockReturnValue(null), + storeSelectedWallet: jest.fn((_accountKey: string) => {}), + }; + + 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'); + + //Pick B + fireEvent.click(screen.getByTestId('pick-b')); + + expect(screen.getByTestId('selected').textContent).toBe('abc'); + await waitFor(() => { + expect(stateSync.storeSelectedWallet).toHaveBeenCalledWith('WalletB:abc'); + }); + + //Clear + fireEvent.click(screen.getByTestId('clear')); + + expect(screen.getByTestId('selected').textContent).toBe('none'); + await waitFor(() => expect(stateSync.deleteSelectedWallet).toHaveBeenCalled()); + }); + + test('auto-restores saved wallet when it appears later', () => { + const getSelectedWallet = jest.fn().mockReturnValue('WalletA:123'); + const storeSelectedWallet = jest.fn(); + const deleteSelectedWallet = jest.fn(); + + const allowWallets = () => true; + + //First render with no wallets + const mockWallets: UiWallet[] = []; + 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', ['123']), makeWallet('WalletB', ['abc'])]; + useWalletsMock.mockReturnValue(mockWalletsUpdated); + + act(() => { + rerender( + + + , + ); + }); + + expect(screen.getByTestId('selected').textContent).toBe('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 useWalletsMock = useWallets as jest.Mock; + useWalletsMock.mockReturnValue(mockWallets); + + const allowWallets = () => true; + + const { rerender } = render( + + + , + ); + + //WalletA:123 is selected + expect(screen.getByTestId('selected').textContent).toBe('123'); + expect(getSelectedWallet).toHaveBeenCalled(); + + //Now update wallets to remove WalletA + const mockWalletsUpdated = [makeWallet('WalletB', ['abc'])]; + useWalletsMock.mockReturnValue(mockWalletsUpdated); + + act(() => { + rerender( + + + , + ); + }); + + expect(screen.getByTestId('selected').textContent).toBe('none'); + }); +}); diff --git a/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts b/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts new file mode 100644 index 000000000..8118ce817 --- /dev/null +++ b/packages/react/src/__typetests__/selectedWalletAccountContextProvider-typetest.ts @@ -0,0 +1,72 @@ +import type { UiWallet, UiWalletAccount } from '@wallet-standard/react'; +import React from 'react'; + +import type { useSelectedWalletAccount } from '../selectedWalletAccountContext'; +import { SelectedWalletAccountContextProvider } from '../SelectedWalletAccountContextProvider'; + +// [DESCRIBE] SelectedWalletAccountContextProvider +{ + // It accepts correct props + { + React.createElement(SelectedWalletAccountContextProvider, { + children: React.createElement('div', null), + filterWallets: (_wallet: UiWallet) => true, + stateSync: { + deleteSelectedWallet: () => {}, + getSelectedWallet: () => null, + storeSelectedWallet: (_accountKey: string) => {}, + }, + }); + } + + // It requires `filterWallet` to return a boolean + { + React.createElement(SelectedWalletAccountContextProvider, { + children: React.createElement('div', null), + // @ts-expect-error filterWallet must return a boolean + filterWallets: (_wallet: UiWallet) => 'not a boolean', + stateSync: { + deleteSelectedWallet: () => {}, + getSelectedWallet: () => null, + storeSelectedWallet: (_accountKey: string) => {}, + }, + }); + } +} + +// [DESCRIBE] useSelectedWalletAccount +{ + type CtxValue = ReturnType; + + // It returns selected wallet account + { + type SelectedWallet = CtxValue[0]; + undefined satisfies SelectedWallet; + null as unknown as UiWalletAccount satisfies SelectedWallet; + } + + // It returns a setter + { + type SetSelected = CtxValue[1]; + + 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', + ); + } + + // It returns filtered wallets + { + type FilteredWallets = CtxValue[2]; + const filteredWallets = null as unknown as FilteredWallets; + filteredWallets satisfies UiWallet[]; + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 2a0f41358..1125a2f7a 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'; +export * from './selectedWalletAccountContext'; diff --git a/packages/react/src/selectedWalletAccountContext.ts b/packages/react/src/selectedWalletAccountContext.ts new file mode 100644 index 000000000..35c361aa4 --- /dev/null +++ b/packages/react/src/selectedWalletAccountContext.ts @@ -0,0 +1,21 @@ +import { UiWallet, UiWalletAccount } from '@wallet-standard/react'; +import React, { createContext } from 'react'; + +export type SelectedWalletAccountState = UiWalletAccount | undefined; + +export type SelectedWalletAccountContextValue = readonly [ + selectedWalletAccount: SelectedWalletAccountState, + setSelectedWalletAccount: React.Dispatch>, + filteredWallets: UiWallet[], +]; + +export const SelectedWalletAccountContext = /*#__PURE__*/ createContext([ + undefined, + () => {}, + [], +]); + +export function useSelectedWalletAccount() { + const ctx = React.useContext(SelectedWalletAccountContext); + return ctx; +} 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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22a626a75..2e36025de 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.3(react@19.2.3))(react@19.2.3) '@wallet-standard/ui': specifier: ^1.0.1 version: 1.0.1 @@ -890,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.3(react@19.2.3))(react@19.2.3) '@types/react': specifier: ^19.2.1 version: 19.2.7 @@ -899,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: @@ -4504,6 +4513,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'} @@ -4520,6 +4548,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==} @@ -5035,6 +5066,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'} @@ -5521,6 +5555,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'} @@ -6702,6 +6739,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==} @@ -7204,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: @@ -7215,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==} @@ -7248,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==} @@ -11388,6 +11434,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.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.3(react@19.2.3) + 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': {} @@ -11398,6 +11465,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 @@ -11893,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) @@ -11900,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 @@ -12027,6 +12115,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: @@ -12509,6 +12601,8 @@ snapshots: dependencies: path-type: 4.0.0 + dom-accessibility-api@0.5.16: {} + domexception@4.0.0: dependencies: webidl-conversions: 7.0.0 @@ -14194,6 +14288,8 @@ snapshots: lunr@2.3.9: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -14789,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 @@ -14798,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: @@ -14827,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: {}