FRE-600: Fix code review blockers

- Consolidated duplicate UndoManagers to single instance
- Fixed connection promise to only resolve on 'connected' status
- Fixed WebSocketProvider import (WebsocketProvider)
- Added proper doc.destroy() cleanup
- Renamed isPresenceInitialized property to avoid conflict

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-25 00:08:01 -04:00
parent 65b552bb08
commit 7c684a42cc
48450 changed files with 5679671 additions and 383 deletions

View File

@@ -0,0 +1,19 @@
import { Connection, type ConnectionConfig } from '@solana/web3.js';
import React, { type FC, type ReactNode, useMemo } from 'react';
import { ConnectionContext } from './useConnection.js';
export interface ConnectionProviderProps {
children: ReactNode;
endpoint: string;
config?: ConnectionConfig;
}
export const ConnectionProvider: FC<ConnectionProviderProps> = ({
children,
endpoint,
config = { commitment: 'confirmed' },
}) => {
const connection = useMemo(() => new Connection(endpoint, config), [endpoint, config]);
return <ConnectionContext.Provider value={{ connection }}>{children}</ConnectionContext.Provider>;
};

View File

@@ -0,0 +1,173 @@
import {
createDefaultAddressSelector,
createDefaultAuthorizationResultCache,
createDefaultWalletNotFoundHandler,
SolanaMobileWalletAdapter,
SolanaMobileWalletAdapterWalletName,
} from '@solana-mobile/wallet-adapter-mobile';
import { type Adapter, type WalletError, type WalletName } from '@solana/wallet-adapter-base';
import { useStandardWalletAdapters } from '@solana/wallet-standard-wallet-adapter-react';
import React, { type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
import getEnvironment, { Environment } from './getEnvironment.js';
import getInferredClusterFromEndpoint from './getInferredClusterFromEndpoint.js';
import { useConnection } from './useConnection.js';
import { useLocalStorage } from './useLocalStorage.js';
import { WalletProviderBase } from './WalletProviderBase.js';
export interface WalletProviderProps {
children: ReactNode;
wallets: Adapter[];
autoConnect?: boolean | ((adapter: Adapter) => Promise<boolean>);
localStorageKey?: string;
onError?: (error: WalletError, adapter?: Adapter) => void;
}
let _userAgent: string | null;
function getUserAgent() {
if (_userAgent === undefined) {
_userAgent = globalThis.navigator?.userAgent ?? null;
}
return _userAgent;
}
function getIsMobile(adapters: Adapter[]) {
const userAgentString = getUserAgent();
return getEnvironment({ adapters, userAgentString }) === Environment.MOBILE_WEB;
}
function getUriForAppIdentity() {
const location = globalThis.location;
if (!location) return;
return `${location.protocol}//${location.host}`;
}
export function WalletProvider({
children,
wallets: adapters,
autoConnect,
localStorageKey = 'walletName',
onError,
}: WalletProviderProps) {
const { connection } = useConnection();
const adaptersWithStandardAdapters = useStandardWalletAdapters(adapters);
const mobileWalletAdapter = useMemo(() => {
if (!getIsMobile(adaptersWithStandardAdapters)) {
return null;
}
const existingMobileWalletAdapter = adaptersWithStandardAdapters.find(
(adapter) => adapter.name === SolanaMobileWalletAdapterWalletName
);
if (existingMobileWalletAdapter) {
return existingMobileWalletAdapter;
}
return new SolanaMobileWalletAdapter({
addressSelector: createDefaultAddressSelector(),
appIdentity: {
uri: getUriForAppIdentity(),
},
authorizationResultCache: createDefaultAuthorizationResultCache(),
cluster: getInferredClusterFromEndpoint(connection?.rpcEndpoint),
onWalletNotFound: createDefaultWalletNotFoundHandler(),
});
}, [adaptersWithStandardAdapters, connection?.rpcEndpoint]);
const adaptersWithMobileWalletAdapter = useMemo(() => {
if (mobileWalletAdapter == null || adaptersWithStandardAdapters.indexOf(mobileWalletAdapter) !== -1) {
return adaptersWithStandardAdapters;
}
return [mobileWalletAdapter, ...adaptersWithStandardAdapters];
}, [adaptersWithStandardAdapters, mobileWalletAdapter]);
const [walletName, setWalletName] = useLocalStorage<WalletName | null>(localStorageKey, null);
const adapter = useMemo(
() => adaptersWithMobileWalletAdapter.find((a) => a.name === walletName) ?? null,
[adaptersWithMobileWalletAdapter, walletName]
);
const changeWallet = useCallback(
(nextWalletName: WalletName<string> | null) => {
if (walletName === nextWalletName) return;
if (
adapter &&
// Selecting a wallet other than the mobile wallet adapter is not
// sufficient reason to call `disconnect` on the mobile wallet adapter.
// Calling `disconnect` on the mobile wallet adapter causes the entire
// authorization store to be wiped.
adapter.name !== SolanaMobileWalletAdapterWalletName
) {
adapter.disconnect();
}
setWalletName(nextWalletName);
},
[adapter, setWalletName, walletName]
);
useEffect(() => {
if (!adapter) return;
function handleDisconnect() {
if (isUnloadingRef.current) return;
setWalletName(null);
}
adapter.on('disconnect', handleDisconnect);
return () => {
adapter.off('disconnect', handleDisconnect);
};
}, [adapter, adaptersWithStandardAdapters, setWalletName, walletName]);
const hasUserSelectedAWallet = useRef(false);
const handleAutoConnectRequest = useMemo(() => {
if (!autoConnect || !adapter) return;
return async () => {
// If autoConnect is true or returns true, use the default autoConnect behavior.
if (autoConnect === true || (await autoConnect(adapter))) {
if (hasUserSelectedAWallet.current) {
await adapter.connect();
} else {
await adapter.autoConnect();
}
}
};
}, [autoConnect, adapter]);
const isUnloadingRef = useRef(false);
useEffect(() => {
if (walletName === SolanaMobileWalletAdapterWalletName && getIsMobile(adaptersWithStandardAdapters)) {
isUnloadingRef.current = false;
return;
}
function handleBeforeUnload() {
isUnloadingRef.current = true;
}
/**
* Some wallets fire disconnection events when the window unloads. Since there's no way to
* distinguish between a disconnection event received because a user initiated it, and one
* that was received because they've closed the window, we have to track window unload
* events themselves. Downstream components use this information to decide whether to act
* upon or drop wallet events and errors.
*/
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [adaptersWithStandardAdapters, walletName]);
const handleConnectError = useCallback(() => {
if (adapter) {
// If any error happens while connecting, unset the adapter.
changeWallet(null);
}
}, [adapter, changeWallet]);
const selectWallet = useCallback(
(walletName: WalletName | null) => {
hasUserSelectedAWallet.current = true;
changeWallet(walletName);
},
[changeWallet]
);
return (
<WalletProviderBase
wallets={adaptersWithMobileWalletAdapter}
adapter={adapter}
isUnloadingRef={isUnloadingRef}
onAutoConnectRequest={handleAutoConnectRequest}
onConnectError={handleConnectError}
onError={onError}
onSelectWallet={selectWallet}
>
{children}
</WalletProviderBase>
);
}

View File

@@ -0,0 +1,309 @@
import {
type Adapter,
type MessageSignerWalletAdapterProps,
type SignerWalletAdapterProps,
type SignInMessageSignerWalletAdapterProps,
type WalletAdapterProps,
type WalletError,
type WalletName,
WalletNotConnectedError,
WalletNotReadyError,
WalletReadyState,
} from '@solana/wallet-adapter-base';
import { type PublicKey } from '@solana/web3.js';
import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { WalletNotSelectedError } from './errors.js';
import { WalletContext } from './useWallet.js';
export interface WalletProviderBaseProps {
children: ReactNode;
wallets: Adapter[];
adapter: Adapter | null;
isUnloadingRef: React.RefObject<boolean>;
// NOTE: The presence/absence of this handler implies that auto-connect is enabled/disabled.
onAutoConnectRequest?: () => Promise<void>;
onConnectError: () => void;
onError?: (error: WalletError, adapter?: Adapter) => void;
onSelectWallet: (walletName: WalletName | null) => void;
}
export function WalletProviderBase({
children,
wallets: adapters,
adapter,
isUnloadingRef,
onAutoConnectRequest,
onConnectError,
onError,
onSelectWallet,
}: WalletProviderBaseProps) {
const isConnectingRef = useRef(false);
const [connecting, setConnecting] = useState(false);
const isDisconnectingRef = useRef(false);
const [disconnecting, setDisconnecting] = useState(false);
const [publicKey, setPublicKey] = useState(() => adapter?.publicKey ?? null);
const [connected, setConnected] = useState(() => adapter?.connected ?? false);
/**
* Store the error handlers as refs so that a change in the
* custom error handler does not recompute other dependencies.
*/
const onErrorRef = useRef(onError);
useEffect(() => {
onErrorRef.current = onError;
return () => {
onErrorRef.current = undefined;
};
}, [onError]);
const handleErrorRef = useRef((error: WalletError, adapter?: Adapter) => {
if (!isUnloadingRef.current) {
if (onErrorRef.current) {
onErrorRef.current(error, adapter);
} else {
console.error(error, adapter);
if (error instanceof WalletNotReadyError && typeof window !== 'undefined' && adapter) {
window.open(adapter.url, '_blank');
}
}
}
return error;
});
// Wrap adapters to conform to the `Wallet` interface
const [wallets, setWallets] = useState(() =>
adapters
.map((adapter) => ({
adapter,
readyState: adapter.readyState,
}))
.filter(({ readyState }) => readyState !== WalletReadyState.Unsupported)
);
// When the adapters change, start to listen for changes to their `readyState`
useEffect(() => {
// When the adapters change, wrap them to conform to the `Wallet` interface
setWallets((wallets) =>
adapters
.map((adapter, index) => {
const wallet = wallets[index];
// If the wallet hasn't changed, return the same instance
return wallet && wallet.adapter === adapter && wallet.readyState === adapter.readyState
? wallet
: {
adapter: adapter,
readyState: adapter.readyState,
};
})
.filter(({ readyState }) => readyState !== WalletReadyState.Unsupported)
);
function handleReadyStateChange(this: Adapter, readyState: WalletReadyState) {
setWallets((prevWallets) => {
const index = prevWallets.findIndex(({ adapter }) => adapter === this);
if (index === -1) return prevWallets;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { adapter } = prevWallets[index]!;
return [
...prevWallets.slice(0, index),
{ adapter, readyState },
...prevWallets.slice(index + 1),
].filter(({ readyState }) => readyState !== WalletReadyState.Unsupported);
});
}
adapters.forEach((adapter) => adapter.on('readyStateChange', handleReadyStateChange, adapter));
return () => {
adapters.forEach((adapter) => adapter.off('readyStateChange', handleReadyStateChange, adapter));
};
}, [adapter, adapters]);
const wallet = useMemo(() => wallets.find((wallet) => wallet.adapter === adapter) ?? null, [adapter, wallets]);
// Setup and teardown event listeners when the adapter changes
useEffect(() => {
if (!adapter) return;
const handleConnect = (publicKey: PublicKey) => {
setPublicKey(publicKey);
isConnectingRef.current = false;
setConnecting(false);
setConnected(true);
isDisconnectingRef.current = false;
setDisconnecting(false);
};
const handleDisconnect = () => {
if (isUnloadingRef.current) return;
setPublicKey(null);
isConnectingRef.current = false;
setConnecting(false);
setConnected(false);
isDisconnectingRef.current = false;
setDisconnecting(false);
};
const handleError = (error: WalletError) => {
handleErrorRef.current(error, adapter);
};
adapter.on('connect', handleConnect);
adapter.on('disconnect', handleDisconnect);
adapter.on('error', handleError);
return () => {
adapter.off('connect', handleConnect);
adapter.off('disconnect', handleDisconnect);
adapter.off('error', handleError);
handleDisconnect();
};
}, [adapter, isUnloadingRef]);
// When the adapter changes, clear the `autoConnect` tracking flag
const didAttemptAutoConnectRef = useRef(false);
useEffect(() => {
return () => {
didAttemptAutoConnectRef.current = false;
};
}, [adapter]);
// If auto-connect is enabled, request to connect when the adapter changes and is ready
useEffect(() => {
if (
didAttemptAutoConnectRef.current ||
isConnectingRef.current ||
connected ||
!onAutoConnectRequest ||
!(wallet?.readyState === WalletReadyState.Installed || wallet?.readyState === WalletReadyState.Loadable)
)
return;
isConnectingRef.current = true;
setConnecting(true);
didAttemptAutoConnectRef.current = true;
(async function () {
try {
await onAutoConnectRequest();
} catch {
onConnectError();
// Drop the error. It will be caught by `handleError` anyway.
} finally {
setConnecting(false);
isConnectingRef.current = false;
}
})();
}, [connected, onAutoConnectRequest, onConnectError, wallet]);
// Send a transaction using the provided connection
const sendTransaction: WalletAdapterProps['sendTransaction'] = useCallback(
async (transaction, connection, options) => {
if (!adapter) throw handleErrorRef.current(new WalletNotSelectedError());
if (!connected) throw handleErrorRef.current(new WalletNotConnectedError(), adapter);
return await adapter.sendTransaction(transaction, connection, options);
},
[adapter, connected]
);
// Sign a transaction if the wallet supports it
const signTransaction: SignerWalletAdapterProps['signTransaction'] | undefined = useMemo(
() =>
adapter && 'signTransaction' in adapter
? async (transaction) => {
if (!connected) throw handleErrorRef.current(new WalletNotConnectedError(), adapter);
return await adapter.signTransaction(transaction);
}
: undefined,
[adapter, connected]
);
// Sign multiple transactions if the wallet supports it
const signAllTransactions: SignerWalletAdapterProps['signAllTransactions'] | undefined = useMemo(
() =>
adapter && 'signAllTransactions' in adapter
? async (transactions) => {
if (!connected) throw handleErrorRef.current(new WalletNotConnectedError(), adapter);
return await adapter.signAllTransactions(transactions);
}
: undefined,
[adapter, connected]
);
// Sign an arbitrary message if the wallet supports it
const signMessage: MessageSignerWalletAdapterProps['signMessage'] | undefined = useMemo(
() =>
adapter && 'signMessage' in adapter
? async (message) => {
if (!connected) throw handleErrorRef.current(new WalletNotConnectedError(), adapter);
return await adapter.signMessage(message);
}
: undefined,
[adapter, connected]
);
// Sign in if the wallet supports it
const signIn: SignInMessageSignerWalletAdapterProps['signIn'] | undefined = useMemo(
() =>
adapter && 'signIn' in adapter
? async (input) => {
return await adapter.signIn(input);
}
: undefined,
[adapter]
);
const handleConnect = useCallback(async () => {
if (isConnectingRef.current || isDisconnectingRef.current || wallet?.adapter.connected) return;
if (!wallet) throw handleErrorRef.current(new WalletNotSelectedError());
const { adapter, readyState } = wallet;
if (!(readyState === WalletReadyState.Installed || readyState === WalletReadyState.Loadable))
throw handleErrorRef.current(new WalletNotReadyError(), adapter);
isConnectingRef.current = true;
setConnecting(true);
try {
await adapter.connect();
} catch (e) {
onConnectError();
throw e;
} finally {
setConnecting(false);
isConnectingRef.current = false;
}
}, [onConnectError, wallet]);
const handleDisconnect = useCallback(async () => {
if (isDisconnectingRef.current) return;
if (!adapter) return;
isDisconnectingRef.current = true;
setDisconnecting(true);
try {
await adapter.disconnect();
} finally {
setDisconnecting(false);
isDisconnectingRef.current = false;
}
}, [adapter]);
return (
<WalletContext.Provider
value={{
autoConnect: !!onAutoConnectRequest,
wallets,
wallet,
publicKey,
connected,
connecting,
disconnecting,
select: onSelectWallet,
connect: handleConnect,
disconnect: handleDisconnect,
sendTransaction,
signTransaction,
signAllTransactions,
signMessage,
signIn,
}}
>
{children}
</WalletContext.Provider>
);
}

View File

@@ -0,0 +1,33 @@
import { BaseWalletAdapter, WalletReadyState } from '@solana/wallet-adapter-base';
import { act } from 'react';
export abstract class MockWalletAdapter extends BaseWalletAdapter {
connectedValue = false;
get connected() {
return this.connectedValue;
}
readyStateValue: WalletReadyState = WalletReadyState.Installed;
get readyState() {
return this.readyStateValue;
}
connecting = false;
connect = jest.fn(async () => {
this.connecting = true;
this.connecting = false;
this.connectedValue = true;
act(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.emit('connect', this.publicKey!);
});
});
disconnect = jest.fn(async () => {
this.connecting = false;
this.connectedValue = false;
act(() => {
this.emit('disconnect');
});
});
sendTransaction = jest.fn();
supportedTransactionVersions = null;
autoConnect = jest.fn();
}

View File

@@ -0,0 +1,462 @@
/**
* @jest-environment jsdom
*/
'use strict';
import {
type Adapter,
BaseWalletAdapter,
WalletError,
type WalletName,
WalletNotReadyError,
WalletReadyState,
} from '@solana/wallet-adapter-base';
import { PublicKey } from '@solana/web3.js';
import React, { act, createRef, forwardRef, useImperativeHandle } from 'react';
import { createRoot } from 'react-dom/client';
import { useWallet, type WalletContextState } from '../useWallet.js';
import { WalletProviderBase, type WalletProviderBaseProps } from '../WalletProviderBase.js';
type TestRefType = {
getWalletContextState(): WalletContextState;
};
const TestComponent = forwardRef(function TestComponentImpl(_props, ref) {
const wallet = useWallet();
useImperativeHandle(
ref,
() => ({
getWalletContextState() {
return wallet;
},
}),
[wallet]
);
return null;
});
describe('WalletProviderBase', () => {
let ref: React.RefObject<TestRefType | null>;
let root: ReturnType<typeof createRoot>;
let container: HTMLElement;
let fooWalletAdapter: MockWalletAdapter;
let barWalletAdapter: MockWalletAdapter;
let bazWalletAdapter: MockWalletAdapter;
let adapters: Adapter[];
let isUnloading: React.MutableRefObject<boolean>;
function renderTest(
props: Omit<
WalletProviderBaseProps,
'children' | 'wallets' | 'isUnloadingRef' | 'onConnectError' | 'onSelectWallet'
>
) {
act(() => {
root.render(
<WalletProviderBase
wallets={adapters}
isUnloadingRef={isUnloading}
onConnectError={jest.fn()}
onSelectWallet={jest.fn()}
{...props}
>
<TestComponent ref={ref} />
</WalletProviderBase>
);
});
}
abstract class MockWalletAdapter extends BaseWalletAdapter {
connectionPromise: null | Promise<void> = null;
disconnectionPromise: null | Promise<void> = null;
connectedValue = false;
get connected() {
return this.connectedValue;
}
readyStateValue: WalletReadyState = WalletReadyState.Installed;
get readyState() {
return this.readyStateValue;
}
connecting = false;
connect = jest.fn(async () => {
this.connecting = true;
if (this.connectionPromise) {
await this.connectionPromise;
}
this.connecting = false;
this.connectedValue = true;
act(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.emit('connect', this.publicKey!);
});
});
disconnect = jest.fn(async () => {
this.connecting = false;
if (this.disconnectionPromise) {
await this.disconnectionPromise;
}
this.connectedValue = false;
act(() => {
this.emit('disconnect');
});
});
sendTransaction = jest.fn();
supportedTransactionVersions = null;
}
class FooWalletAdapter extends MockWalletAdapter {
name = 'FooWallet' as WalletName<'FooWallet'>;
url = 'https://foowallet.com';
icon = 'foo.png';
publicKey = new PublicKey('Foo11111111111111111111111111111111111111111');
}
class BarWalletAdapter extends MockWalletAdapter {
name = 'BarWallet' as WalletName<'BarWallet'>;
url = 'https://barwallet.com';
icon = 'bar.png';
publicKey = new PublicKey('Bar11111111111111111111111111111111111111111');
}
class BazWalletAdapter extends MockWalletAdapter {
name = 'BazWallet' as WalletName<'BazWallet'>;
url = 'https://bazwallet.com';
icon = 'baz.png';
publicKey = new PublicKey('Baz11111111111111111111111111111111111111111');
}
beforeEach(() => {
jest.resetAllMocks();
container = document.createElement('div');
document.body.appendChild(container);
isUnloading = { current: false };
root = createRoot(container);
ref = createRef();
fooWalletAdapter = new FooWalletAdapter();
barWalletAdapter = new BarWalletAdapter();
bazWalletAdapter = new BazWalletAdapter();
adapters = [fooWalletAdapter, barWalletAdapter, bazWalletAdapter];
});
afterEach(() => {
if (root) {
act(() => {
root.unmount();
});
}
});
describe('given a selected wallet', () => {
beforeEach(async () => {
fooWalletAdapter.readyStateValue = WalletReadyState.NotDetected;
renderTest({ adapter: fooWalletAdapter });
expect(ref.current?.getWalletContextState().wallet?.readyState).toBe(WalletReadyState.NotDetected);
});
describe('that then becomes ready', () => {
beforeEach(() => {
act(() => {
fooWalletAdapter.readyStateValue = WalletReadyState.Installed;
fooWalletAdapter.emit('readyStateChange', WalletReadyState.Installed);
});
});
it('sets `ready` to true', () => {
expect(ref.current?.getWalletContextState().wallet?.readyState).toBe(WalletReadyState.Installed);
});
});
describe('when the wallet disconnects of its own accord', () => {
beforeEach(() => {
act(() => {
fooWalletAdapter.disconnect();
});
});
it('clears out the state', () => {
expect(ref.current?.getWalletContextState()).toMatchObject({
connected: false,
connecting: false,
publicKey: null,
});
});
});
describe('when the wallet disconnects as a consequence of the window unloading', () => {
beforeEach(() => {
act(() => {
isUnloading.current = true;
fooWalletAdapter.disconnect();
});
});
it('should not clear out the state', () => {
expect(ref.current?.getWalletContextState().wallet?.adapter).toBe(fooWalletAdapter);
expect(ref.current?.getWalletContextState().publicKey).not.toBeNull();
});
});
});
describe('given the presence of an unsupported wallet', () => {
beforeEach(() => {
bazWalletAdapter.readyStateValue = WalletReadyState.Unsupported;
renderTest({ adapter: fooWalletAdapter });
});
it('filters out the unsupported wallet', () => {
const adapters = ref.current?.getWalletContextState().wallets.map(({ adapter }) => adapter);
expect(adapters).not.toContain(bazWalletAdapter);
});
});
describe('when auto connect is disabled', () => {
beforeEach(() => {
renderTest({ onAutoConnectRequest: undefined, adapter: fooWalletAdapter });
});
it('`autoConnect` is `false` on state', () => {
expect(ref.current?.getWalletContextState().autoConnect).toBe(false);
});
});
describe('and auto connect is enabled', () => {
let onAutoConnectRequest: jest.Mock;
beforeEach(() => {
onAutoConnectRequest = jest.fn();
fooWalletAdapter.readyStateValue = WalletReadyState.NotDetected;
renderTest({ adapter: fooWalletAdapter, onAutoConnectRequest });
});
it('`autoConnect` is `true` on state', () => {
expect(ref.current?.getWalletContextState().autoConnect).toBe(true);
});
describe('before the adapter is ready', () => {
it('does not call `connect` on the adapter', () => {
expect(fooWalletAdapter.connect).not.toHaveBeenCalled();
});
describe('once the adapter becomes ready', () => {
beforeEach(async () => {
await act(async () => {
fooWalletAdapter.readyStateValue = WalletReadyState.Installed;
fooWalletAdapter.emit('readyStateChange', WalletReadyState.Installed);
await Promise.resolve(); // Flush all promises in effects after calling `select()`.
});
});
it('calls `onAutoConnectRequest`', () => {
expect(onAutoConnectRequest).toHaveBeenCalledTimes(1);
});
describe('when switching to another adapter', () => {
beforeEach(async () => {
jest.clearAllMocks();
renderTest({ adapter: barWalletAdapter, onAutoConnectRequest });
});
it('calls `onAutoConnectRequest` despite having called it once before on the old adapter', () => {
expect(onAutoConnectRequest).toHaveBeenCalledTimes(1);
});
});
describe('once the adapter connects', () => {
beforeEach(async () => {
await act(async () => {
await fooWalletAdapter.connect();
});
});
describe('then disconnects', () => {
beforeEach(async () => {
jest.clearAllMocks();
await act(async () => {
await fooWalletAdapter.disconnect();
});
});
it('does not make a second attempt to auto connect', () => {
expect(onAutoConnectRequest).not.toHaveBeenCalled();
});
});
});
});
});
});
describe('custom error handler', () => {
const errorToEmit = new WalletError();
let onError: jest.Mock;
beforeEach(async () => {
onError = jest.fn();
renderTest({ adapter: fooWalletAdapter, onError });
});
it('gets called in response to adapter errors', () => {
act(() => {
fooWalletAdapter.emit('error', errorToEmit);
});
expect(onError).toBeCalledWith(errorToEmit, fooWalletAdapter);
});
it('does not get called if the window is unloading', () => {
const errorToEmit = new WalletError();
act(() => {
isUnloading.current = true;
fooWalletAdapter.emit('error', errorToEmit);
});
expect(onError).not.toBeCalled();
});
describe('when a wallet is connected', () => {
beforeEach(async () => {
await act(() => {
ref.current?.getWalletContextState().connect();
});
expect(ref.current?.getWalletContextState()).toMatchObject({
connected: true,
});
});
describe('then the `onError` function changes', () => {
beforeEach(async () => {
const differentOnError = jest.fn(); /* Some function, different from the one above */
renderTest({ adapter: fooWalletAdapter, onError: differentOnError });
});
it('does not cause state to be cleared when it changes', () => {
// Regression test for https://github.com/anza-xyz/wallet-adapter/issues/636
expect(ref.current?.getWalletContextState()).toMatchObject({
connected: true,
});
});
});
});
});
describe('connect()', () => {
describe('given an adapter that is not ready', () => {
beforeEach(async () => {
window.open = jest.fn();
fooWalletAdapter.readyStateValue = WalletReadyState.NotDetected;
renderTest({ adapter: fooWalletAdapter });
expect(ref.current?.getWalletContextState().wallet?.readyState).toBe(WalletReadyState.NotDetected);
act(() => {
expect(ref.current?.getWalletContextState().connect()).rejects.toThrow();
});
});
it("opens the wallet's URL in a new window", () => {
expect(window.open).toBeCalledWith('https://foowallet.com', '_blank');
});
it('throws a `WalletNotReady` error', () => {
act(() => {
expect(ref.current?.getWalletContextState().connect()).rejects.toThrow(new WalletNotReadyError());
});
});
});
describe('given an adapter that is ready', () => {
let commitConnection: () => void;
beforeEach(async () => {
renderTest({ adapter: fooWalletAdapter });
fooWalletAdapter.connectionPromise = new Promise<void>((resolve) => {
commitConnection = resolve;
});
await act(() => {
ref.current?.getWalletContextState().connect();
});
});
it('calls connect on the adapter', () => {
expect(fooWalletAdapter.connect).toHaveBeenCalled();
});
it('updates state tracking variables appropriately', () => {
expect(ref.current?.getWalletContextState()).toMatchObject({
connected: false,
connecting: true,
});
});
describe('once connected', () => {
beforeEach(async () => {
await act(() => {
commitConnection();
});
});
it('updates state tracking variables appropriately', () => {
expect(ref.current?.getWalletContextState()).toMatchObject({
connected: true,
connecting: false,
});
});
});
});
});
describe('disconnect()', () => {
describe('when there is already an adapter supplied', () => {
let commitDisconnection: () => void;
beforeEach(async () => {
window.open = jest.fn();
renderTest({ adapter: fooWalletAdapter });
await act(() => {
ref.current?.getWalletContextState().connect();
});
fooWalletAdapter.disconnectionPromise = new Promise<void>((resolve) => {
commitDisconnection = resolve;
});
await act(() => {
ref.current?.getWalletContextState().disconnect();
});
});
it('updates state tracking variables appropriately', () => {
expect(ref.current?.getWalletContextState()).toMatchObject({
connected: true,
});
});
describe('once disconnected', () => {
beforeEach(async () => {
await act(() => {
commitDisconnection();
});
});
it('clears out the state', () => {
expect(ref.current?.getWalletContextState()).toMatchObject({
connected: false,
connecting: false,
publicKey: null,
});
});
});
});
});
describe('when there is no adapter supplied', () => {
beforeEach(() => {
renderTest({ adapter: null });
});
describe('and one becomes supplied', () => {
beforeEach(() => {
renderTest({ adapter: fooWalletAdapter });
});
it('sets the state tracking variables', () => {
expect(ref.current?.getWalletContextState()).toMatchObject({
wallet: { adapter: fooWalletAdapter, readyState: fooWalletAdapter.readyState },
connected: false,
connecting: false,
publicKey: null,
});
});
});
});
describe('when there is already an adapter supplied', () => {
let commitFooWalletDisconnection: () => void;
beforeEach(async () => {
fooWalletAdapter.disconnectionPromise = new Promise<void>((resolve) => {
commitFooWalletDisconnection = resolve;
});
renderTest({ adapter: fooWalletAdapter });
});
describe('when you null out the adapter', () => {
beforeEach(() => {
renderTest({ adapter: null });
});
it('clears out the state', () => {
expect(ref.current?.getWalletContextState()).toMatchObject({
wallet: null,
connected: false,
connecting: false,
publicKey: null,
});
});
});
describe('and a different adapter becomes supplied', () => {
beforeEach(async () => {
renderTest({ adapter: barWalletAdapter });
});
it('the adapter of the new wallet should be set in state', () => {
expect(ref.current?.getWalletContextState().wallet?.adapter).toBe(barWalletAdapter);
});
/**
* Regression test: a race condition in the wallet name setter could result in the
* wallet reverting back to an old value, depending on the cadence of the previous
* wallets' disconnect operation.
*/
describe('then a different one becomes supplied before the first one has disconnected', () => {
beforeEach(async () => {
renderTest({ adapter: bazWalletAdapter });
act(() => {
commitFooWalletDisconnection();
});
});
it('the wallet you selected last should be set in state', () => {
expect(ref.current?.getWalletContextState().wallet?.adapter).toBe(bazWalletAdapter);
});
});
});
});
});

View File

@@ -0,0 +1,322 @@
/**
* @jest-environment jsdom
*/
'use strict';
import {
type AddressSelector,
type AuthorizationResultCache,
SolanaMobileWalletAdapter,
} from '@solana-mobile/wallet-adapter-mobile';
import { type Adapter, WalletError, type WalletName, WalletReadyState } from '@solana/wallet-adapter-base';
import { PublicKey } from '@solana/web3.js';
import 'jest-localstorage-mock';
import React, { act, createRef, forwardRef, useImperativeHandle } from 'react';
import { createRoot } from 'react-dom/client';
import { MockWalletAdapter } from '../__mocks__/MockWalletAdapter.js';
import { useWallet, type WalletContextState } from '../useWallet.js';
import { WalletProvider, type WalletProviderProps } from '../WalletProvider.js';
jest.mock('../getEnvironment.js', () => ({
...jest.requireActual('../getEnvironment.js'),
__esModule: true,
default: () => jest.requireActual('../getEnvironment.js').Environment.DESKTOP_WEB,
}));
type TestRefType = {
getWalletContextState(): WalletContextState;
};
const TestComponent = forwardRef(function TestComponentImpl(_props, ref) {
const wallet = useWallet();
useImperativeHandle(
ref,
() => ({
getWalletContextState() {
return wallet;
},
}),
[wallet]
);
return null;
});
const WALLET_NAME_CACHE_KEY = 'cachedWallet';
/**
* NOTE: If you add a test to this suite, also add it to `WalletProviderMobile-test.tsx`.
*
* You may be wondering why these suites haven't been designed as one suite with a procedurally
* generated `describe` block that mocks `getEnvironment` differently on each pass. The reason has
* to do with the way `jest.resetModules()` plays havoc with the React test renderer. If you have
* a solution, please do send a PR.
*/
describe('WalletProvider when the environment is `DESKTOP_WEB`', () => {
let ref: React.RefObject<TestRefType | null>;
let root: ReturnType<typeof createRoot>;
let container: HTMLElement;
let fooWalletAdapter: MockWalletAdapter;
let adapters: Adapter[];
function renderTest(props: Omit<WalletProviderProps, 'appIdentity' | 'children' | 'cluster' | 'wallets'>) {
act(() => {
root.render(
<WalletProvider {...props} localStorageKey={WALLET_NAME_CACHE_KEY} wallets={adapters}>
<TestComponent ref={ref} />
</WalletProvider>
);
});
}
class FooWalletAdapter extends MockWalletAdapter {
name = 'FooWallet' as WalletName<'FooWallet'>;
url = 'https://foowallet.com';
icon = 'foo.png';
publicKey = new PublicKey('Foo11111111111111111111111111111111111111111');
}
beforeEach(() => {
localStorage.clear();
jest.clearAllMocks().resetModules();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
ref = createRef();
fooWalletAdapter = new FooWalletAdapter();
adapters = [fooWalletAdapter];
});
afterEach(() => {
if (root) {
act(() => {
root.unmount();
});
}
});
describe('given a selected wallet', () => {
beforeEach(async () => {
fooWalletAdapter.readyStateValue = WalletReadyState.NotDetected;
renderTest({});
await act(async () => {
ref.current?.getWalletContextState().select('FooWallet' as WalletName<'FooWallet'>);
await Promise.resolve(); // Flush all promises in effects after calling `select()`.
});
expect(ref.current?.getWalletContextState().wallet?.readyState).toBe(WalletReadyState.NotDetected);
});
it('should store the wallet name', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
WALLET_NAME_CACHE_KEY,
JSON.stringify(fooWalletAdapter.name)
);
});
describe('when the wallet disconnects of its own accord', () => {
beforeEach(() => {
jest.clearAllMocks();
act(() => {
fooWalletAdapter.disconnect();
});
});
it('should clear the stored wallet name', () => {
expect(localStorage.removeItem).toHaveBeenCalledWith(WALLET_NAME_CACHE_KEY);
});
});
describe('when the wallet disconnects as a consequence of the window unloading', () => {
beforeEach(() => {
jest.clearAllMocks();
act(() => {
window.dispatchEvent(new Event('beforeunload'));
fooWalletAdapter.disconnect();
});
});
it('should not clear the stored wallet name', () => {
expect(localStorage.removeItem).not.toHaveBeenCalledWith(WALLET_NAME_CACHE_KEY);
});
});
});
describe('when there is no mobile wallet adapter in the adapters array', () => {
it('does not create a new mobile wallet adapter', () => {
renderTest({});
expect(jest.mocked(SolanaMobileWalletAdapter).mock.instances).toHaveLength(0);
});
});
describe('when a custom mobile wallet adapter is supplied in the adapters array', () => {
let customAdapter: Adapter;
const CUSTOM_APP_IDENTITY = {
uri: 'https://custom.com',
};
const CUSTOM_CLUSTER = 'devnet';
beforeEach(() => {
customAdapter = new SolanaMobileWalletAdapter({
addressSelector: jest.fn() as unknown as AddressSelector,
appIdentity: CUSTOM_APP_IDENTITY,
authorizationResultCache: jest.fn() as unknown as AuthorizationResultCache,
cluster: CUSTOM_CLUSTER,
onWalletNotFound: jest.fn(),
});
adapters.push(customAdapter);
jest.clearAllMocks();
});
it('does not load the custom mobile wallet adapter into state as the default', () => {
renderTest({});
expect(ref.current?.getWalletContextState().wallet?.adapter).not.toBe(customAdapter);
});
});
describe('when there exists no stored wallet name', () => {
beforeEach(() => {
(localStorage.getItem as jest.Mock).mockReturnValue(null);
});
it('loads no wallet into state', () => {
renderTest({});
expect(ref.current?.getWalletContextState().wallet).toBeNull();
});
it('loads no public key into state', () => {
renderTest({});
expect(ref.current?.getWalletContextState().publicKey).toBeNull();
});
});
describe('when there exists a stored wallet name', () => {
beforeEach(() => {
(localStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify('FooWallet'));
});
it('loads the corresponding adapter into state', () => {
renderTest({});
expect(ref.current?.getWalletContextState().wallet?.adapter).toBeInstanceOf(FooWalletAdapter);
});
it('loads the corresponding public key into state', () => {
renderTest({});
expect(ref.current?.getWalletContextState().publicKey).toBe(fooWalletAdapter.publicKey);
});
it('sets state tracking variables to defaults', () => {
renderTest({});
expect(ref.current?.getWalletContextState()).toMatchObject({
connected: false,
connecting: false,
});
});
});
describe('autoConnect', () => {
beforeEach(async () => {
renderTest({});
await act(async () => {
ref.current?.getWalletContextState().select('FooWallet' as WalletName<'FooWallet'>);
await Promise.resolve(); // Flush all promises in effects after calling `select()`.
});
});
describe('when autoConnect is disabled', () => {
beforeEach(() => {
renderTest({ autoConnect: false });
});
it('does not call `autoConnect`', () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const adapter = ref.current!.getWalletContextState().wallet!.adapter;
expect(adapter.connect).not.toHaveBeenCalled();
expect(adapter.autoConnect).not.toHaveBeenCalled();
});
});
describe('when autoConnect is enabled', () => {
beforeEach(() => {
renderTest({ autoConnect: true });
});
it('calls `connect`', () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const adapter = ref.current!.getWalletContextState().wallet!.adapter;
expect(adapter.connect).toHaveBeenCalled();
expect(adapter.autoConnect).not.toHaveBeenCalled();
});
});
});
describe('onError', () => {
let onError: jest.Mock;
let errorThrown: WalletError;
beforeEach(() => {
errorThrown = new WalletError('o no');
onError = jest.fn();
renderTest({ onError });
});
describe('when the wallet emits an error', () => {
let adapter: Adapter;
beforeEach(() => {
act(() => {
adapter = ref.current?.getWalletContextState().wallet?.adapter as Adapter;
adapter.emit('error', errorThrown);
});
});
it('should fire the `onError` callback', () => {
expect(onError).toHaveBeenCalledWith(errorThrown, adapter);
});
});
describe('when window `beforeunload` event fires', () => {
beforeEach(() => {
act(() => {
window.dispatchEvent(new Event('beforeunload'));
});
});
describe('then the wallet emits an error', () => {
beforeEach(() => {
act(() => {
const adapter = ref.current?.getWalletContextState().wallet?.adapter as Adapter;
adapter.emit('error', errorThrown);
});
});
it('should not fire the `onError` callback', () => {
expect(onError).not.toHaveBeenCalled();
});
});
});
});
describe('disconnect()', () => {
describe('when there is already a wallet connected', () => {
beforeEach(async () => {
window.open = jest.fn();
renderTest({});
await act(async () => {
ref.current?.getWalletContextState().select('FooWallet' as WalletName<'FooWallet'>);
await Promise.resolve(); // Flush all promises in effects after calling `select()`.
});
await act(() => {
ref.current?.getWalletContextState().connect();
});
});
describe('and you select a different wallet', () => {
beforeEach(async () => {
await act(async () => {
ref.current?.getWalletContextState().select('BarWallet' as WalletName<'BarWallet'>);
await Promise.resolve(); // Flush all promises in effects after calling `select()`.
});
});
it('should disconnect the old wallet', () => {
expect(fooWalletAdapter.disconnect).toHaveBeenCalled();
});
});
describe('and you select the same wallet', () => {
beforeEach(async () => {
await act(async () => {
ref.current?.getWalletContextState().select('FooWallet' as WalletName<'FooWallet'>);
await Promise.resolve(); // Flush all promises in effects after calling `select()`.
});
});
it('should not disconnect the old wallet', () => {
expect(fooWalletAdapter.disconnect).not.toHaveBeenCalled();
});
});
describe('once disconnected', () => {
beforeEach(async () => {
jest.clearAllMocks();
ref.current?.getWalletContextState().disconnect();
await Promise.resolve(); // Flush all promises in effects after calling `disconnect()`.
});
it('should clear the stored wallet name', () => {
expect(localStorage.removeItem).toHaveBeenCalledWith(WALLET_NAME_CACHE_KEY);
});
it('sets state tracking variables to defaults', () => {
renderTest({});
expect(ref.current?.getWalletContextState()).toMatchObject({
connected: false,
connecting: false,
publicKey: null,
});
});
});
});
});
});

View File

@@ -0,0 +1,455 @@
/**
* @jest-environment jsdom
*/
'use strict';
import {
type AddressSelector,
type AuthorizationResultCache,
SolanaMobileWalletAdapter,
SolanaMobileWalletAdapterWalletName,
} from '@solana-mobile/wallet-adapter-mobile';
import { type Adapter, WalletError, type WalletName, WalletReadyState } from '@solana/wallet-adapter-base';
import { type Connection, PublicKey } from '@solana/web3.js';
import 'jest-localstorage-mock';
import React, { act, createRef, forwardRef, useImperativeHandle } from 'react';
import { createRoot } from 'react-dom/client';
import { MockWalletAdapter } from '../__mocks__/MockWalletAdapter.js';
import { useConnection } from '../useConnection.js';
import { useWallet, type WalletContextState } from '../useWallet.js';
import { WalletProvider, type WalletProviderProps } from '../WalletProvider.js';
jest.mock('../getEnvironment.js', () => ({
...jest.requireActual('../getEnvironment.js'),
__esModule: true,
default: () => jest.requireActual('../getEnvironment.js').Environment.MOBILE_WEB,
}));
jest.mock('../getInferredClusterFromEndpoint.js', () => ({
...jest.requireActual('../getInferredClusterFromEndpoint.js'),
__esModule: true,
default: (endpoint?: string) => {
switch (endpoint) {
case 'https://fake-endpoint-for-test.com':
return 'fake-cluster-for-test';
default:
return 'mainnet-beta';
}
},
}));
jest.mock('../useConnection.js');
type TestRefType = {
getWalletContextState(): WalletContextState;
};
const TestComponent = forwardRef(function TestComponentImpl(_props, ref) {
const wallet = useWallet();
useImperativeHandle(
ref,
() => ({
getWalletContextState() {
return wallet;
},
}),
[wallet]
);
return null;
});
const WALLET_NAME_CACHE_KEY = 'cachedWallet';
/**
* NOTE: If you add a test to this suite, also add it to `WalletProviderDesktop-test.tsx`.
*
* You may be wondering why these suites haven't been designed as one suite with a procedurally
* generated `describe` block that mocks `getEnvironment` differently on each pass. The reason has
* to do with the way `jest.resetModules()` plays havoc with the React test renderer. If you have
* a solution, please do send a PR.
*/
describe('WalletProvider when the environment is `MOBILE_WEB`', () => {
let ref: React.RefObject<TestRefType | null>;
let root: ReturnType<typeof createRoot>;
let container: HTMLElement;
let fooWalletAdapter: MockWalletAdapter;
let adapters: Adapter[];
function renderTest(props: Omit<WalletProviderProps, 'appIdentity' | 'children' | 'cluster' | 'wallets'>) {
act(() => {
root.render(
<WalletProvider {...props} localStorageKey={WALLET_NAME_CACHE_KEY} wallets={adapters}>
<TestComponent ref={ref} />
</WalletProvider>
);
});
}
class FooWalletAdapter extends MockWalletAdapter {
name = 'FooWallet' as WalletName<'FooWallet'>;
url = 'https://foowallet.com';
icon = 'foo.png';
publicKey = new PublicKey('Foo11111111111111111111111111111111111111111');
}
beforeEach(() => {
localStorage.clear();
jest.clearAllMocks().resetModules();
jest.mocked(useConnection).mockImplementation(() => ({
connection: {
rpcEndpoint: 'https://fake-endpoint-for-test.com',
} as Connection,
}));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
ref = createRef();
fooWalletAdapter = new FooWalletAdapter();
adapters = [fooWalletAdapter];
});
afterEach(() => {
if (root) {
act(() => {
root.unmount();
});
}
});
describe('given a selected wallet', () => {
beforeEach(async () => {
fooWalletAdapter.readyStateValue = WalletReadyState.NotDetected;
renderTest({});
await act(async () => {
ref.current?.getWalletContextState().select('FooWallet' as WalletName<'FooWallet'>);
await Promise.resolve(); // Flush all promises in effects after calling `select()`.
});
expect(ref.current?.getWalletContextState().wallet?.readyState).toBe(WalletReadyState.NotDetected);
});
it('should store the wallet name', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
WALLET_NAME_CACHE_KEY,
JSON.stringify(fooWalletAdapter.name)
);
});
describe('when the wallet disconnects of its own accord', () => {
beforeEach(() => {
jest.clearAllMocks();
act(() => {
fooWalletAdapter.disconnect();
});
});
it('should clear the stored wallet name', () => {
expect(localStorage.removeItem).toHaveBeenCalledWith(WALLET_NAME_CACHE_KEY);
});
});
describe('when the wallet disconnects as a consequence of the window unloading', () => {
beforeEach(() => {
jest.clearAllMocks();
act(() => {
window.dispatchEvent(new Event('beforeunload'));
fooWalletAdapter.disconnect();
});
});
it('should not clear the stored wallet name', () => {
expect(localStorage.removeItem).not.toHaveBeenCalledWith(WALLET_NAME_CACHE_KEY);
});
});
});
describe('when there is no mobile wallet adapter in the adapters array', () => {
it("creates a new mobile wallet adapter with the document's host as the uri of the `appIdentity`", () => {
renderTest({});
expect(jest.mocked(SolanaMobileWalletAdapter).mock.instances).toHaveLength(1);
expect(jest.mocked(SolanaMobileWalletAdapter).mock.calls[0][0].appIdentity.uri).toBe(
`${document.location.protocol}//${document.location.host}`
);
});
it('creates a new mobile wallet adapter with the appropriate cluster for the given endpoint', () => {
renderTest({});
expect(jest.mocked(SolanaMobileWalletAdapter).mock.instances).toHaveLength(1);
// @ts-expect-error // HACK: SolanaMobileWalletAdapter has a `cluster` property but it's not declared.
expect(jest.mocked(SolanaMobileWalletAdapter).mock.calls[0][0].cluster).toBe('fake-cluster-for-test');
});
});
describe('when a custom mobile wallet adapter is supplied in the adapters array', () => {
let customAdapter: Adapter;
const CUSTOM_APP_IDENTITY = {
uri: 'https://custom.com',
};
const CUSTOM_CLUSTER = 'devnet';
beforeEach(() => {
customAdapter = new SolanaMobileWalletAdapter({
addressSelector: jest.fn() as unknown as AddressSelector,
appIdentity: CUSTOM_APP_IDENTITY,
authorizationResultCache: jest.fn() as unknown as AuthorizationResultCache,
cluster: CUSTOM_CLUSTER,
onWalletNotFound: jest.fn(),
});
adapters.push(customAdapter);
jest.clearAllMocks();
});
it('does not load the custom mobile wallet adapter into state as the default', () => {
renderTest({});
expect(ref.current?.getWalletContextState().wallet?.adapter).not.toBe(customAdapter);
});
it('does not construct any further mobile wallet adapters', () => {
renderTest({});
expect(jest.mocked(SolanaMobileWalletAdapter).mock.calls.length).toBe(0);
});
});
describe('when there exists no stored wallet name', () => {
beforeEach(() => {
(localStorage.getItem as jest.Mock).mockReturnValue(null);
});
it('loads no wallet into state', () => {
renderTest({});
expect(ref.current?.getWalletContextState().wallet).toBeNull();
});
it('loads no public key into state', () => {
renderTest({});
expect(ref.current?.getWalletContextState().publicKey).toBeNull();
});
});
describe('when there exists a stored wallet name', () => {
beforeEach(() => {
(localStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify('FooWallet'));
});
it('loads the corresponding adapter into state', () => {
renderTest({});
expect(ref.current?.getWalletContextState().wallet?.adapter).toBeInstanceOf(FooWalletAdapter);
});
it('loads the corresponding public key into state', () => {
renderTest({});
expect(ref.current?.getWalletContextState().publicKey).toBe(fooWalletAdapter.publicKey);
});
it('sets state tracking variables to defaults', () => {
renderTest({});
expect(ref.current?.getWalletContextState()).toMatchObject({
connected: false,
connecting: false,
});
});
});
describe('autoConnect', () => {
describe('given a mobile wallet adapter is connected', () => {
beforeEach(async () => {
renderTest({});
await act(async () => {
ref.current?.getWalletContextState().select(SolanaMobileWalletAdapterWalletName);
await Promise.resolve(); // Flush all promises in effects after calling `select()`.
});
});
describe('when autoConnect is disabled', () => {
beforeEach(() => {
renderTest({ autoConnect: false });
});
it('does not call `connect`', () => {
const adapter = ref.current?.getWalletContextState().wallet?.adapter as SolanaMobileWalletAdapter;
expect(adapter.connect).not.toHaveBeenCalled();
expect(adapter.autoConnect).not.toHaveBeenCalled();
});
});
describe('when autoConnect is enabled', () => {
beforeEach(() => {
renderTest({ autoConnect: true });
});
it('calls the connect method on the mobile wallet adapter', () => {
const adapter = ref.current?.getWalletContextState().wallet?.adapter as SolanaMobileWalletAdapter;
expect(adapter.connect).toHaveBeenCalled();
expect(adapter.autoConnect).not.toHaveBeenCalled();
});
});
});
describe('given a non-mobile wallet adapter is connected', () => {
beforeEach(async () => {
renderTest({});
await act(async () => {
ref.current?.getWalletContextState().select('FooWallet' as WalletName<'FooWallet'>);
await Promise.resolve(); // Flush all promises in effects after calling `select()`.
});
});
describe('when autoConnect is disabled', () => {
beforeEach(() => {
renderTest({ autoConnect: false });
});
it('does not call `autoConnect`', () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const adapter = ref.current!.getWalletContextState().wallet!.adapter;
expect(adapter.connect).not.toHaveBeenCalled();
expect(adapter.autoConnect).not.toHaveBeenCalled();
});
});
describe('when autoConnect is enabled', () => {
beforeEach(() => {
renderTest({ autoConnect: true });
});
it('calls `connect`', () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const adapter = ref.current!.getWalletContextState().wallet!.adapter;
expect(adapter.connect).toHaveBeenCalled();
expect(adapter.autoConnect).not.toHaveBeenCalled();
});
});
});
});
describe('onError', () => {
let onError: jest.Mock;
let errorThrown: WalletError;
beforeEach(() => {
errorThrown = new WalletError('o no');
onError = jest.fn();
renderTest({ onError });
});
describe('when the wallet emits an error', () => {
let adapter: Adapter;
beforeEach(() => {
act(() => {
adapter = ref.current?.getWalletContextState().wallet?.adapter as SolanaMobileWalletAdapter;
adapter.emit('error', errorThrown);
});
});
it('should fire the `onError` callback', () => {
expect(onError).toHaveBeenCalledWith(errorThrown, adapter);
});
});
describe('when window `beforeunload` event fires', () => {
beforeEach(() => {
act(() => {
window.dispatchEvent(new Event('beforeunload'));
});
});
describe('then the wallet emits an error', () => {
let adapter: Adapter;
beforeEach(() => {
act(() => {
adapter = ref.current?.getWalletContextState().wallet?.adapter as SolanaMobileWalletAdapter;
adapter.emit('error', errorThrown);
});
});
it('should not fire the `onError` callback', () => {
expect(onError).not.toHaveBeenCalled();
});
});
});
});
describe('disconnect()', () => {
describe('when there is already a wallet connected', () => {
beforeEach(async () => {
window.open = jest.fn();
renderTest({});
await act(async () => {
ref.current?.getWalletContextState().select('FooWallet' as WalletName<'FooWallet'>);
await Promise.resolve(); // Flush all promises in effects after calling `select()`.
});
await act(() => {
ref.current?.getWalletContextState().connect();
});
});
describe('and you select a different wallet', () => {
beforeEach(async () => {
await act(async () => {
ref.current?.getWalletContextState().select('BarWallet' as WalletName<'BarWallet'>);
await Promise.resolve(); // Flush all promises in effects after calling `select()`.
});
});
it('should disconnect the old wallet', () => {
expect(fooWalletAdapter.disconnect).toHaveBeenCalled();
});
});
describe('and you select the same wallet', () => {
beforeEach(async () => {
await act(async () => {
ref.current?.getWalletContextState().select('FooWallet' as WalletName<'FooWallet'>);
await Promise.resolve(); // Flush all promises in effects after calling `select()`.
});
});
it('should not disconnect the old wallet', () => {
expect(fooWalletAdapter.disconnect).not.toHaveBeenCalled();
});
});
describe('once disconnected', () => {
beforeEach(async () => {
jest.clearAllMocks();
act(() => {
ref.current?.getWalletContextState().disconnect();
});
await Promise.resolve(); // Flush all promises in effects after calling `disconnect()`.
});
it('should clear the stored wallet name', () => {
expect(localStorage.removeItem).toHaveBeenCalledWith(WALLET_NAME_CACHE_KEY);
});
it('sets state tracking variables to defaults', () => {
renderTest({});
expect(ref.current?.getWalletContextState()).toMatchObject({
connected: false,
connecting: false,
publicKey: null,
});
});
});
});
describe('given a mobile wallet adapter is connected', () => {
let mobileWalletAdapter: Adapter;
beforeEach(async () => {
renderTest({});
await act(async () => {
ref.current?.getWalletContextState().select(SolanaMobileWalletAdapterWalletName);
await Promise.resolve(); // Flush all promises in effects after calling `select()`.
});
mobileWalletAdapter = jest.mocked(SolanaMobileWalletAdapter).mock.results[0].value;
await act(() => {
ref.current?.getWalletContextState().connect();
});
});
describe('then a non-mobile wallet adapter is selected', () => {
beforeEach(async () => {
renderTest({});
await act(async () => {
ref.current?.getWalletContextState().select('FooWallet' as WalletName<'FooWallet'>);
await Promise.resolve(); // Flush all promises in effects after calling `select()`.
});
});
it('does not call `disconnect` on the mobile wallet adapter', () => {
expect(mobileWalletAdapter.disconnect).not.toHaveBeenCalled();
});
it('should not clear the stored wallet name', () => {
expect(localStorage.removeItem).not.toHaveBeenCalled();
});
});
describe('when the wallet disconnects of its own accord', () => {
beforeEach(() => {
jest.clearAllMocks();
act(() => {
mobileWalletAdapter.disconnect();
});
});
it('should clear the stored wallet name', () => {
expect(localStorage.removeItem).toHaveBeenCalledWith(WALLET_NAME_CACHE_KEY);
});
});
describe('when window beforeunload event fires', () => {
beforeEach(() => {
jest.clearAllMocks();
act(() => {
window.dispatchEvent(new Event('beforeunload'));
});
});
describe('then the wallet disconnects of its own accord', () => {
beforeEach(() => {
jest.clearAllMocks();
act(() => {
mobileWalletAdapter.disconnect();
});
});
it('should clear the stored wallet name', () => {
expect(localStorage.removeItem).toHaveBeenCalledWith(WALLET_NAME_CACHE_KEY);
});
it('should clear out the state', () => {
expect(ref.current?.getWalletContextState()).toMatchObject({
connected: false,
connecting: false,
publicKey: null,
});
});
});
});
});
});
});

View File

@@ -0,0 +1,34 @@
import getInferredClusterFromEndpoint from '../getInferredClusterFromEndpoint.js';
describe('getInferredClusterFromEndpoint()', () => {
describe('when the endpoint is `undefined`', () => {
const endpoint = undefined;
it('creates a new mobile wallet adapter with `mainnet-beta` as the cluster', () => {
expect(getInferredClusterFromEndpoint(endpoint)).toBe('mainnet-beta');
});
});
describe('when the endpoint is the empty string', () => {
const endpoint = '';
it('creates a new mobile wallet adapter with `mainnet-beta` as the cluster', () => {
expect(getInferredClusterFromEndpoint(endpoint)).toBe('mainnet-beta');
});
});
describe("when the endpoint contains the word 'devnet'", () => {
const endpoint = 'https://foo-devnet.com';
it('creates a new mobile wallet adapter with `devnet` as the cluster', () => {
expect(getInferredClusterFromEndpoint(endpoint)).toBe('devnet');
});
});
describe("when the endpoint contains the word 'testnet'", () => {
const endpoint = 'https://foo-testnet.com';
it('creates a new mobile wallet adapter with `testnet` as the cluster', () => {
expect(getInferredClusterFromEndpoint(endpoint)).toBe('testnet');
});
});
describe("when the endpoint contains the word 'mainnet-beta'", () => {
const endpoint = 'https://foo-mainnet-beta.com';
it('creates a new mobile wallet adapter with `mainnet-beta` as the cluster', () => {
expect(getInferredClusterFromEndpoint(endpoint)).toBe('mainnet-beta');
});
});
});

View File

@@ -0,0 +1,67 @@
import { type Adapter, WalletReadyState } from '@solana/wallet-adapter-base';
import getEnvironment, { Environment } from '../getEnvironment.js';
describe('getEnvironment()', () => {
[
{
description: 'on Android',
expectedEnvironmentWithInstalledAdapter: Environment.DESKTOP_WEB,
expectedEnvironmentWithNoInstalledAdapter: Environment.MOBILE_WEB,
userAgentString: 'Android',
},
{
description: 'in a webview',
expectedEnvironmentWithInstalledAdapter: Environment.DESKTOP_WEB,
expectedEnvironmentWithNoInstalledAdapter: Environment.DESKTOP_WEB,
userAgentString: 'WebView',
},
{
description: 'on desktop',
expectedEnvironmentWithInstalledAdapter: Environment.DESKTOP_WEB,
expectedEnvironmentWithNoInstalledAdapter: Environment.DESKTOP_WEB,
userAgentString:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36',
},
{
description: 'the user agent is null',
expectedEnvironmentWithInstalledAdapter: Environment.DESKTOP_WEB,
expectedEnvironmentWithNoInstalledAdapter: Environment.DESKTOP_WEB,
userAgentString: null,
},
].forEach(
({
description,
expectedEnvironmentWithInstalledAdapter,
expectedEnvironmentWithNoInstalledAdapter,
userAgentString,
}) => {
describe(`when ${description}`, () => {
describe('with no installed adapters', () => {
it(`returns \`${Environment[expectedEnvironmentWithNoInstalledAdapter]}\``, () => {
const adapters = [
{ readyState: WalletReadyState.Loadable } as Adapter,
{ readyState: WalletReadyState.NotDetected } as Adapter,
{ readyState: WalletReadyState.Unsupported } as Adapter,
];
expect(getEnvironment({ adapters, userAgentString })).toBe(
expectedEnvironmentWithNoInstalledAdapter
);
});
});
describe('with at least one installed adapter', () => {
it(`returns \`${Environment[expectedEnvironmentWithInstalledAdapter]}\``, () => {
const adapters = [
{ readyState: WalletReadyState.Loadable } as Adapter,
{ readyState: WalletReadyState.Installed } as Adapter,
{ readyState: WalletReadyState.NotDetected } as Adapter,
{ readyState: WalletReadyState.Unsupported } as Adapter,
];
expect(getEnvironment({ adapters, userAgentString })).toBe(
expectedEnvironmentWithInstalledAdapter
);
});
});
});
}
);
});

View File

@@ -0,0 +1,276 @@
/**
* @jest-environment jsdom
*/
'use strict';
import 'jest-localstorage-mock';
import React, { act, createRef, forwardRef, useImperativeHandle } from 'react';
import { createRoot } from 'react-dom/client';
import { useLocalStorage } from '../useLocalStorage.js';
type TestRefType = {
getPersistedValue(): string;
persistValue(value: string | null): void;
};
const DEFAULT_VALUE = 'default value';
const STORAGE_KEY = 'storageKey';
/**
* Sometimes merely accessing `localStorage` on the `window` object can result
* in a fatal error being thrown - for example in a private window in Firefox.
* Call this method to simulate this in your tests, and don't forget to call
* the cleanup function after you're done, so that other tests can run.
*/
function configureLocalStorageToFatalOnAccess(): () => void {
const savedPropertyDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage') as PropertyDescriptor;
Object.defineProperty(window, 'localStorage', {
get() {
throw new Error(
'Error: Accessing `localStorage` resulted in a fatal ' +
'(eg. accessing it in a private window in Firefox).'
);
},
});
return function restoreOldLocalStorage() {
Object.defineProperty(window, 'localStorage', savedPropertyDescriptor);
};
}
const TestComponent = forwardRef(function TestComponentImpl(_props, ref) {
const [persistedValue, setPersistedValue] = useLocalStorage<string | null>(STORAGE_KEY, DEFAULT_VALUE);
useImperativeHandle(
ref,
() => ({
getPersistedValue() {
return persistedValue;
},
persistValue(newValue: string | null) {
setPersistedValue(newValue);
},
}),
[persistedValue, setPersistedValue]
);
return null;
});
describe('useLocalStorage', () => {
let container: HTMLDivElement | null;
let root: ReturnType<typeof createRoot>;
let ref: React.RefObject<TestRefType | null>;
function renderTest() {
act(() => {
root.render(<TestComponent ref={ref} />);
});
}
beforeEach(() => {
localStorage.clear();
jest.resetAllMocks();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
ref = createRef();
});
afterEach(() => {
if (root) {
act(() => {
root.unmount();
});
}
});
describe('getting the persisted value', () => {
describe('when local storage has a value for the storage key', () => {
const PERSISTED_VALUE = 'value';
beforeEach(() => {
(localStorage.getItem as jest.Mock).mockImplementation((storageKey) => {
if (storageKey !== STORAGE_KEY) {
return null;
}
return JSON.stringify(PERSISTED_VALUE);
});
expect(renderTest).not.toThrow();
});
it('returns that value', () => {
expect(ref.current?.getPersistedValue()).toBe(PERSISTED_VALUE);
});
});
describe('when local storage has no value for the storage key', () => {
const PERSISTED_VALUE = 'value';
beforeEach(() => {
(localStorage.getItem as jest.Mock).mockReturnValue(null);
expect(renderTest).not.toThrow();
});
it('returns the default value', () => {
expect(ref.current?.getPersistedValue()).toBe(DEFAULT_VALUE);
});
});
describe('when merely accessing local storage results in a fatal error', () => {
let restoreOldLocalStorage: () => void;
beforeEach(() => {
restoreOldLocalStorage = configureLocalStorageToFatalOnAccess();
expect(renderTest).not.toThrow();
});
afterEach(() => {
restoreOldLocalStorage();
});
it('renders with the default value', () => {
expect(ref.current?.getPersistedValue()).toBe(DEFAULT_VALUE);
});
});
describe('when local storage fatals on read', () => {
beforeEach(() => {
(localStorage.getItem as jest.Mock).mockImplementation(() => {
throw new Error('Local storage derped');
});
expect(renderTest).not.toThrow();
});
it('renders with the default value', () => {
expect(ref.current?.getPersistedValue()).toBe(DEFAULT_VALUE);
});
});
describe('when local storage does not exist', () => {
let cachedLocalStorage: Storage;
beforeEach(() => {
cachedLocalStorage = localStorage;
// @ts-ignore - readonly
delete global.localStorage;
expect(renderTest).not.toThrow();
});
afterEach(() => {
// @ts-ignore - readonly
global.localStorage = cachedLocalStorage;
});
it('renders with the default value', () => {
expect(ref.current?.getPersistedValue()).toBe(DEFAULT_VALUE);
});
});
describe('when local storage contains invalid JSON', () => {
beforeEach(() => {
(localStorage.getItem as jest.Mock).mockReturnValue('' /* <- not valid JSON! */);
expect(renderTest).not.toThrow();
});
it('renders with the default value', () => {
expect(ref.current?.getPersistedValue()).toBe(DEFAULT_VALUE);
});
});
});
describe('setting the persisted value', () => {
describe('when setting to a non-null value', () => {
const NEW_VALUE = 'new value';
beforeEach(() => {
expect(renderTest).not.toThrow();
});
it('sets that value in local storage', () => {
act(() => {
ref.current?.persistValue(NEW_VALUE);
});
expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, JSON.stringify(NEW_VALUE));
});
it('re-renders the component with the new value', () => {
act(() => {
ref.current?.persistValue(NEW_VALUE);
});
expect(ref.current?.getPersistedValue()).toBe(NEW_VALUE);
});
describe('many times in a row', () => {
it('sets the new value in local storage once', () => {
act(() => {
ref.current?.persistValue(NEW_VALUE);
ref.current?.persistValue(NEW_VALUE);
});
expect(window.localStorage.setItem).toHaveBeenCalledTimes(1);
expect(window.localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, JSON.stringify(NEW_VALUE));
});
});
describe('multiple times ending with the current value', () => {
it("does not call local storage's setter", () => {
act(() => {
ref.current?.persistValue(NEW_VALUE);
ref.current?.persistValue(DEFAULT_VALUE);
});
expect(window.localStorage.setItem).toHaveBeenCalledTimes(0);
});
});
});
describe('when setting to `null`', () => {
beforeEach(() => {
expect(renderTest).not.toThrow();
});
it('removes the key from local storage', () => {
act(() => {
ref.current?.persistValue(null);
});
expect(localStorage.removeItem).toHaveBeenCalledWith(STORAGE_KEY);
});
it('re-renders the component with `null`', () => {
act(() => {
ref.current?.persistValue(null);
});
expect(ref.current?.getPersistedValue()).toBe(null);
});
});
describe('when merely accessing local storage results in a fatal error', () => {
const NEW_VALUE = 'new value';
let restoreOldLocalStorage: () => void;
beforeEach(() => {
restoreOldLocalStorage = configureLocalStorageToFatalOnAccess();
expect(renderTest).not.toThrow();
});
afterEach(() => {
restoreOldLocalStorage();
});
it('re-renders the component with the new value', () => {
act(() => {
ref.current?.persistValue(NEW_VALUE);
});
expect(ref.current?.getPersistedValue()).toBe(NEW_VALUE);
});
});
describe('when local storage fatals on write', () => {
const NEW_VALUE = 'new value';
beforeEach(() => {
(localStorage.setItem as jest.Mock).mockImplementation(() => {
throw new Error('Local storage derped');
});
expect(renderTest).not.toThrow();
});
it('re-renders the component with the new value', () => {
act(() => {
ref.current?.persistValue(NEW_VALUE);
});
expect(ref.current?.getPersistedValue()).toBe(NEW_VALUE);
});
});
describe('when local storage does not exist', () => {
let cachedLocalStorage: Storage;
beforeEach(() => {
cachedLocalStorage = localStorage;
// @ts-ignore - readonly
delete global.localStorage;
expect(renderTest).not.toThrow();
});
afterEach(() => {
// @ts-ignore - readonly
global.localStorage = cachedLocalStorage;
});
describe('when setting to a non-null value', () => {
const NEW_VALUE = 'new value';
it('re-renders the component with the new value', () => {
act(() => {
ref.current?.persistValue(NEW_VALUE);
});
expect(ref.current?.getPersistedValue()).toBe(NEW_VALUE);
});
});
describe('when setting to `null`', () => {
it('re-renders the component with `null`', () => {
act(() => {
ref.current?.persistValue(null);
});
expect(ref.current?.getPersistedValue()).toBe(null);
});
});
});
});
});

View File

@@ -0,0 +1,28 @@
type InferValue<Prop extends PropertyKey, Desc> = Desc extends { get(): any; value: any }
? never
: Desc extends { value: infer T }
? Record<Prop, T>
: Desc extends { get(): infer T }
? Record<Prop, T>
: never;
type DefineProperty<Prop extends PropertyKey, Desc extends PropertyDescriptor> = Desc extends {
writable: any;
set(val: any): any;
}
? never
: Desc extends { writable: any; get(): any }
? never
: Desc extends { writable: false }
? Readonly<InferValue<Prop, Desc>>
: Desc extends { writable: true }
? InferValue<Prop, Desc>
: Readonly<InferValue<Prop, Desc>>;
export default function defineProperty<Obj extends object, Key extends PropertyKey, PDesc extends PropertyDescriptor>(
obj: Obj,
prop: Key,
val: PDesc
): asserts obj is Obj & DefineProperty<Key, PDesc> {
Object.defineProperty(obj, prop, val);
}

View File

@@ -0,0 +1,5 @@
import { WalletError } from '@solana/wallet-adapter-base';
export class WalletNotSelectedError extends WalletError {
name = 'WalletNotSelectedError';
}

View File

@@ -0,0 +1,49 @@
import { SolanaMobileWalletAdapterWalletName } from '@solana-mobile/wallet-adapter-mobile';
import { type Adapter, WalletReadyState } from '@solana/wallet-adapter-base';
export enum Environment {
DESKTOP_WEB,
MOBILE_WEB,
}
type Config = Readonly<{
adapters: Adapter[];
userAgentString: string | null;
}>;
function isWebView(userAgentString: string) {
return /(WebView|Version\/.+(Chrome)\/(\d+)\.(\d+)\.(\d+)\.(\d+)|; wv\).+(Chrome)\/(\d+)\.(\d+)\.(\d+)\.(\d+))/i.test(
userAgentString
);
}
export default function getEnvironment({ adapters, userAgentString }: Config): Environment {
if (
adapters.some(
(adapter) =>
adapter.name !== SolanaMobileWalletAdapterWalletName &&
adapter.readyState === WalletReadyState.Installed
)
) {
/**
* There are only two ways a browser extension adapter should be able to reach `Installed` status:
*
* 1. Its browser extension is installed.
* 2. The app is running on a mobile wallet's in-app browser.
*
* In either case, we consider the environment to be desktop-like.
*/
return Environment.DESKTOP_WEB;
}
if (
userAgentString &&
// Step 1: Check whether we're on a platform that supports MWA at all.
/android/i.test(userAgentString) &&
// Step 2: Determine that we are *not* running in a WebView.
!isWebView(userAgentString)
) {
return Environment.MOBILE_WEB;
} else {
return Environment.DESKTOP_WEB;
}
}

View File

@@ -0,0 +1,14 @@
import { type Cluster } from '@solana/web3.js';
export default function getInferredClusterFromEndpoint(endpoint?: string): Cluster {
if (!endpoint) {
return 'mainnet-beta';
}
if (/devnet/i.test(endpoint)) {
return 'devnet';
} else if (/testnet/i.test(endpoint)) {
return 'testnet';
} else {
return 'mainnet-beta';
}
}

View File

@@ -0,0 +1,7 @@
export * from './ConnectionProvider.js';
export * from './errors.js';
export * from './useAnchorWallet.js';
export * from './useConnection.js';
export * from './useLocalStorage.js';
export * from './useWallet.js';
export * from './WalletProvider.js';

View File

@@ -0,0 +1,20 @@
import { type PublicKey, type Transaction, type VersionedTransaction } from '@solana/web3.js';
import { useMemo } from 'react';
import { useWallet } from './useWallet.js';
export interface AnchorWallet {
publicKey: PublicKey;
signTransaction<T extends Transaction | VersionedTransaction>(transaction: T): Promise<T>;
signAllTransactions<T extends Transaction | VersionedTransaction>(transactions: T[]): Promise<T[]>;
}
export function useAnchorWallet(): AnchorWallet | undefined {
const { publicKey, signTransaction, signAllTransactions } = useWallet();
return useMemo(
() =>
publicKey && signTransaction && signAllTransactions
? { publicKey, signTransaction, signAllTransactions }
: undefined,
[publicKey, signTransaction, signAllTransactions]
);
}

View File

@@ -0,0 +1,12 @@
import { type Connection } from '@solana/web3.js';
import { createContext, useContext } from 'react';
export interface ConnectionContextState {
connection: Connection;
}
export const ConnectionContext = createContext<ConnectionContextState>({} as ConnectionContextState);
export function useConnection(): ConnectionContextState {
return useContext(ConnectionContext);
}

View File

@@ -0,0 +1,13 @@
import { useState } from 'react';
import { type useLocalStorage as baseUseLocalStorage } from './useLocalStorage.js';
export const useLocalStorage: typeof baseUseLocalStorage = function useLocalStorage<T>(
_key: string,
defaultState: T
): [T, React.Dispatch<React.SetStateAction<T>>] {
/**
* Until such time as we have a strategy for implementing wallet
* memorization on React Native, simply punt and return a no-op.
*/
return useState(defaultState);
};

View File

@@ -0,0 +1,38 @@
import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from 'react';
export function useLocalStorage<T>(key: string, defaultState: T): [T, Dispatch<SetStateAction<T>>] {
const state = useState<T>(() => {
try {
const value = localStorage.getItem(key);
if (value) return JSON.parse(value) as T;
} catch (error: any) {
if (typeof window !== 'undefined') {
console.error(error);
}
}
return defaultState;
});
const value = state[0];
const isFirstRenderRef = useRef(true);
useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
return;
}
try {
if (value === null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, JSON.stringify(value));
}
} catch (error: any) {
if (typeof window !== 'undefined') {
console.error(error);
}
}
}, [value, key]);
return state;
}

View File

@@ -0,0 +1,102 @@
import {
type Adapter,
type MessageSignerWalletAdapterProps,
type SignerWalletAdapterProps,
type SignInMessageSignerWalletAdapterProps,
type WalletAdapterProps,
type WalletName,
type WalletReadyState,
} from '@solana/wallet-adapter-base';
import { type PublicKey } from '@solana/web3.js';
import { createContext, useContext } from 'react';
export interface Wallet {
adapter: Adapter;
readyState: WalletReadyState;
}
export interface WalletContextState {
autoConnect: boolean;
wallets: Wallet[];
wallet: Wallet | null;
publicKey: PublicKey | null;
connecting: boolean;
connected: boolean;
disconnecting: boolean;
select(walletName: WalletName | null): void;
connect(): Promise<void>;
disconnect(): Promise<void>;
sendTransaction: WalletAdapterProps['sendTransaction'];
signTransaction: SignerWalletAdapterProps['signTransaction'] | undefined;
signAllTransactions: SignerWalletAdapterProps['signAllTransactions'] | undefined;
signMessage: MessageSignerWalletAdapterProps['signMessage'] | undefined;
signIn: SignInMessageSignerWalletAdapterProps['signIn'] | undefined;
}
const EMPTY_ARRAY: ReadonlyArray<never> = [];
const DEFAULT_CONTEXT: Partial<WalletContextState> = {
autoConnect: false,
connecting: false,
connected: false,
disconnecting: false,
select() {
logMissingProviderError('call', 'select');
},
connect() {
return Promise.reject(logMissingProviderError('call', 'connect'));
},
disconnect() {
return Promise.reject(logMissingProviderError('call', 'disconnect'));
},
sendTransaction() {
return Promise.reject(logMissingProviderError('call', 'sendTransaction'));
},
signTransaction() {
return Promise.reject(logMissingProviderError('call', 'signTransaction'));
},
signAllTransactions() {
return Promise.reject(logMissingProviderError('call', 'signAllTransactions'));
},
signMessage() {
return Promise.reject(logMissingProviderError('call', 'signMessage'));
},
signIn() {
return Promise.reject(logMissingProviderError('call', 'signIn'));
},
};
Object.defineProperty(DEFAULT_CONTEXT, 'wallets', {
get() {
logMissingProviderError('read', 'wallets');
return EMPTY_ARRAY;
},
});
Object.defineProperty(DEFAULT_CONTEXT, 'wallet', {
get() {
logMissingProviderError('read', 'wallet');
return null;
},
});
Object.defineProperty(DEFAULT_CONTEXT, 'publicKey', {
get() {
logMissingProviderError('read', 'publicKey');
return null;
},
});
function logMissingProviderError(action: string, property: string) {
const error = new Error(
`You have tried to ${action} "${property}" on a WalletContext without providing one. ` +
'Make sure to render a WalletProvider as an ancestor of the component that uses WalletContext.'
);
console.error(error);
return error;
}
export const WalletContext = createContext<WalletContextState>(DEFAULT_CONTEXT as WalletContextState);
export function useWallet(): WalletContextState {
return useContext(WalletContext);
}