Skip to content

Commit

Permalink
feat: the useWallet() hook now causes a re-render when any wallet's…
Browse files Browse the repository at this point in the history
… change event fires
  • Loading branch information
steveluscher committed May 1, 2024
1 parent 8069d6c commit 5e4f381
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 8 deletions.
5 changes: 5 additions & 0 deletions .changeset/clean-snakes-complain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@wallet-standard/react-core': patch
---

The `useWallets()` hook will now cause a re-render any time a wallet's `'change'` event fires
1 change: 1 addition & 0 deletions packages/react/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@types/react-test-renderer": "^18",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.13",
"react-test-renderer": "^18",
"shx": "^0.3.4"
}
Expand Down
106 changes: 105 additions & 1 deletion packages/react/core/src/__tests__/useWallets-test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,107 @@
import { getWallets } from '@wallet-standard/app';
import type { Wallet, WalletVersion } from '@wallet-standard/base';
import type { StandardEventsFeature, StandardEventsListeners } from '@wallet-standard/features';
import { act } from 'react-test-renderer';

import { renderHook } from '../test-renderer.js';
import { useWallets } from '../useWallets.js';

jest.mock('@wallet-standard/app');

describe('useWallets', () => {
it.todo('Add a test');
let mockGet: jest.MockedFn<ReturnType<typeof getWallets>['get']>;
let mockOn: jest.MockedFn<ReturnType<typeof getWallets>['on']>;
let mockRegister: jest.MockedFn<ReturnType<typeof getWallets>['register']>;
beforeEach(() => {
mockGet = jest.fn().mockReturnValue([] as readonly Wallet[]);
mockOn = jest.fn();
mockRegister = jest.fn();
jest.mocked(getWallets).mockReturnValue({
get: mockGet,
on: mockOn,
register: mockRegister,
});
// Suppresses console output when an `ErrorBoundary` is hit.
// See https://stackoverflow.com/a/72632884/802047
});
it('returns a list of registered wallets', () => {
const expectedWallets = [] as readonly Wallet[];
mockGet.mockReturnValue(expectedWallets);
const { result } = renderHook(() => useWallets());
expect(result.current).toBe(expectedWallets);
});
describe.each(['register', 'unregister'])('when the %s event fires', (expectedEvent) => {
let initialWallets: readonly Wallet[];
let listeners: (((...wallets: Wallet[]) => void) | ((...wallets: Wallet[]) => void))[] = [];
beforeEach(() => {
initialWallets = [] as readonly Wallet[];
listeners = [];
mockGet.mockReturnValue(initialWallets);
mockOn.mockImplementation((event, listener) => {
if (event === expectedEvent) {
listeners.push(listener);
}
return () => {
/* unsubscribe */
};
});
mockGet.mockReturnValue(initialWallets);
});
it('updates if the wallet array has changed', () => {
const { result } = renderHook(() => useWallets());
const expectedWalletsAfterUpdate = ['new' as unknown as Wallet] as readonly Wallet[];
mockGet.mockReturnValue(expectedWalletsAfterUpdate);
act(() => {
listeners.forEach((l) => {
l(/* doesn't really matter what the listener receives */);
});
});
expect(result.current).toBe(expectedWalletsAfterUpdate);
});
it('does not update if the wallet array has not changed', () => {
const { result } = renderHook(() => useWallets());
act(() => {
listeners.forEach((l) => {
l(/* doesn't really matter what the listener receives */);
});
});
expect(result.current).toBe(initialWallets);
});
});
it('recycles the wallets array when the `change` event fires on a wallet', () => {
const listeners: StandardEventsListeners['change'][] = [];
const mockWallets = [
{
accounts: [],
chains: ['solana:mainnet'] as const,
features: {
'standard:events': {
on(event, listener) {
if (event === 'change') {
listeners.push(listener);
}
return () => {
/* unsubscribe */
};
},
version: '1.0.0' as const,
},
} as StandardEventsFeature,
icon: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAIBAAA=',
name: 'Mock Wallet',
version: '1.0.0' as WalletVersion,
} as const,
];
mockGet.mockReturnValue(mockWallets);
const { result } = renderHook(() => useWallets());
act(() => {
listeners.forEach((l) => {
l({
/* doesn't really matter what the listener receives */
});
});
});
expect(result.current).toStrictEqual(mockWallets);
expect(result.current).not.toBe(mockWallets);
});
});
70 changes: 70 additions & 0 deletions packages/react/core/src/test-renderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';

type Result<T> =
| {
__type: 'error';
current: Error;
reset: () => void;
}
| {
__type: 'result';
current?: T;
};

type TestComponentProps<THookReturn> = {
executor(): THookReturn;
resultRef: Result<THookReturn>;
};

function TestComponentHookRenderer<THookFn extends (...args: never) => unknown>({
executor,
resultRef,
}: TestComponentProps<ReturnType<THookFn>>) {
resultRef.current = executor();
return null;
}

function TestComponent<THookFn extends (...args: never) => unknown>({
executor,
resultRef,
}: TestComponentProps<ReturnType<THookFn>>) {
return (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => {
resultRef.__type = 'error';
resultRef.current = error;
(resultRef as Extract<typeof resultRef, { __type: 'error' }>).reset = resetErrorBoundary;
return null;
}}
onReset={() => {
resultRef.__type = 'result';
delete resultRef.current;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (resultRef as any).reset;
}}
>
<TestComponentHookRenderer executor={executor} resultRef={resultRef} />
</ErrorBoundary>
);
}

export function renderHook<THookReturn>(executor: () => THookReturn): {
rerenderHook(nextExecutor: () => THookReturn): void;
result: Readonly<Result<THookReturn>>;
} {
const result = { __type: 'result' } as Result<THookReturn>;
let testRenderer: ReactTestRenderer;
act(() => {
testRenderer = create(<TestComponent executor={executor} resultRef={result} />);
});
return {
rerenderHook(nextExecutor) {
act(() => {
testRenderer.update(<TestComponent executor={nextExecutor} resultRef={result} />);
});
},
result,
};
}
48 changes: 41 additions & 7 deletions packages/react/core/src/useWallets.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { getWallets } from '@wallet-standard/app';
import type { Wallet } from '@wallet-standard/base';
import { useCallback, useSyncExternalStore } from 'react';
import type { Wallet, WalletWithFeatures } from '@wallet-standard/base';
import { StandardEvents, type StandardEventsFeature } from '@wallet-standard/features';
import { useCallback, useRef, useSyncExternalStore } from 'react';

import { hasEventsFeature } from './WalletProvider.js';
import { useStable } from './useStable.js';

const NO_WALLETS: readonly Wallet[] = [];
Expand All @@ -9,19 +12,50 @@ function getServerSnapshot(): readonly Wallet[] {
return NO_WALLETS;
}

function walletHasStandardEventsFeature(wallet: Wallet): wallet is WalletWithFeatures<StandardEventsFeature> {
return hasEventsFeature(wallet.features);
}

/** TODO: docs */
export function useWallets(): readonly Wallet[] {
const { get: getSnapshot, on } = useStable(getWallets);
const { get, on } = useStable(getWallets);
const prevWallets = useRef(get());
const outputWallets = useRef(prevWallets.current);
const getSnapshot = useCallback(() => {
const nextWallets = get();
if (nextWallets !== prevWallets.current) {
// The Wallet Standard itself recyled the wallets array wrapper. Use that array.
outputWallets.current = nextWallets;
}
prevWallets.current = nextWallets;
return outputWallets.current;
}, [get]);
const subscribe = useCallback(
(callback: () => void) => {
const disposeRegisterListener = on('register', callback);
const disposeUnregisterListener = on('unregister', callback);
(onStoreChange: () => void) => {
const disposeRegisterListener = on('register', onStoreChange);
const disposeUnregisterListener = on('unregister', onStoreChange);
const disposeWalletChangeListeners = get()
.filter(walletHasStandardEventsFeature)
.map((wallet) =>
wallet.features[StandardEvents].on('change', () => {
// Despite a change in a property of a wallet, the array that contains the
// list of wallets will be reused. The wallets array before and after the
// change will be referentially equal.
//
// Here, we force a new wallets array wrapper to be created by cloning the
// array. This gives React the signal to re-render, because it will notice
// that the return value of `getSnapshot()` has changed.
outputWallets.current = [...get()];
onStoreChange();
})
);
return () => {
disposeRegisterListener();
disposeUnregisterListener();
disposeWalletChangeListeners.forEach((d) => d());
};
},
[on]
[get, on]
);
return useSyncExternalStore<readonly Wallet[]>(subscribe, getSnapshot, getServerSnapshot);
}
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 5e4f381

Please sign in to comment.