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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lovely-mugs-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@solana/react': minor
---

Add a context provider `<SelectedWalletAccountContext>` and `useSelectedWalletAccount` to persist a selected wallet account
80 changes: 80 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div>No wallet selected</div>;
}

return (
<div>
<p>Address: {selectedAccount.address}</p>

<button onClick={() => setSelectedAccount(undefined)}>Clear selection</button>

<p>Available wallets: {filteredWallets.length}</p>
</div>
);
}
```

### `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 (
<SelectedWalletAccountContextProvider
filterWallet={(wallet: UiWallet) => wallet.accounts.length > 0}
stateSync={{
getSelectedWallet: () => localStorage.getItem(STORAGE_KEY),
storeSelectedWallet: accountKey => localStorage.setItem(STORAGE_KEY, accountKey),
deleteSelectedWallet: () => localStorage.removeItem(STORAGE_KEY),
}}
>
<WalletInfo />
</SelectedWalletAccountContextProvider>
);
}
```
5 changes: 4 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,21 @@
"@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"
},
"devDependencies": {
"@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"
Expand Down
147 changes: 147 additions & 0 deletions packages/react/src/SelectedWalletAccountContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<SelectedWalletAccountState>(() => {
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<SelectedWalletAccountState>> =
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 (
<SelectedWalletAccountContext.Provider value={[walletAccount, setSelectedWalletAccount, filteredWallets]}>
{children}
</SelectedWalletAccountContext.Provider>
);
}
Loading
Loading