Skip to content

Commit

Permalink
A hook that you can use to share a value (like a Promise) across your…
Browse files Browse the repository at this point in the history
… entire app, synchronously, as you would a ref (#57)

This positively abominable hook creates a _global_ ref that multiple components can obtain and mutate. Mutating the value of `current` in one component not only mutates the value of `current` in all of them, but it forces a rerender of every participating component. Read the tests for more on how this works.

This hook will be used in https://app.graphite.dev/github/pr/anza-xyz/wallet-standard/58 to share the connect/disconnect promises across an entire app. Even if multiple components `useConnectFeature()` or `useDisconnectFeature()` they will operate on a single, unified connect/disconnect promise per wallet. This prevents things like the `isConnecting` property getting out of sync between multiple components, or multiple components calling `connect()` at the same time.
  • Loading branch information
steveluscher authored Jun 7, 2024
2 parents c88d687 + 20167a6 commit 7dcc681
Show file tree
Hide file tree
Showing 2 changed files with 144 additions and 0 deletions.
73 changes: 73 additions & 0 deletions packages/react/core/src/__tests__/useWeakRef-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from 'react';
import { act, create } from 'react-test-renderer';

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

function TestComponent({
keyObject,
thiefRef,
}: {
keyObject: WeakKey;
thiefRef?: React.MutableRefObject<React.MutableRefObject<unknown> | undefined>;
}) {
const weakRef = useWeakRef(keyObject);
if (thiefRef) {
thiefRef.current = weakRef;
}
return <>{weakRef.current}</>;
}

describe('useWeakRef', () => {
it('rerenders every component in which it is used when the value of `current` is mutated', async () => {
expect.assertions(2);
const keyObject = {};
const thiefRef: React.MutableRefObject<React.MutableRefObject<unknown> | undefined> = { current: undefined }; // We use this to reach in and grab the ref produced.
let testRenderer;
await act(() => {
testRenderer = create(
<>
<TestComponent keyObject={keyObject} thiefRef={thiefRef} />
{'/'}
<TestComponent keyObject={keyObject} />
</>
);
});
expect(testRenderer).toMatchInlineSnapshot(`"/"`);
await act(() => {
const weakRefFromRendererA = thiefRef.current!;
// Merely mutating this ref should cause both values to update.
weakRefFromRendererA.current = 'updated value';
});
expect(testRenderer).toMatchInlineSnapshot(`
[
"updated value",
"/",
"updated value",
]
`);
});
it('vends the latest value of the ref to new components that mount', async () => {
expect.assertions(1);
// STEP 1: Set a value on the ref.
const keyObject = {};
let result: ReturnType<typeof renderHook>['result'];
await act(() => {
({ result } = renderHook(() => useWeakRef(keyObject)));
if (result.__type === 'error') {
fail('`useWeakRef()` threw an unexpected exception');
}
});
await act(() => {
const weakRefFromRenderer = result.current! as React.MutableRefObject<unknown>;
// Mutating this value should cause a re-render of the hook from above.
weakRefFromRenderer.current = 'initial value';
});
// STEP 2: Ensure that a brand new component receives that value on mount
let testRenderer;
await act(() => {
testRenderer = create(<TestComponent keyObject={keyObject} />);
});
expect(testRenderer).toMatchInlineSnapshot(`"initial value"`);
});
});
71 changes: 71 additions & 0 deletions packages/react/core/src/useWeakRef.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type React from 'react';
import { useCallback, useRef, useSyncExternalStore } from 'react';

const values = new WeakMap();
const subscribers = new Map<WeakKey, Set<() => void>>();

function getServerSnapshot() {
return { current: undefined };
}

function createMutableRef<TValue>(keyObj: WeakKey): React.MutableRefObject<TValue | undefined> {
return {
get current(): TValue | undefined {
return values.get(keyObj) as TValue | undefined;
},
set current(newValue: TValue | undefined) {
if (newValue === values.get(keyObj)) {
return;
}
if (newValue === undefined) {
values.delete(keyObj);
} else {
values.set(keyObj, newValue);
}
subscribers.get(keyObj)?.forEach((cb) => cb());
},
};
}

/**
* Given an object as a key, this hook will vend you a mutable ref that causes all of its consumers
* to rerender whenever the `current` property is mutated. At all times, the value of `current`
* points to the latest value.
*
* This is particularly useful when you need to share a `Promise` across your entire application and
* you need every consumer to have access to the latest value of it synchronously, without having to
* wait for a rerender. Use this hook to create a ref related to some stable object, then store your
* promise in that ref.
*
* Note that the value related to the key object will persist even when every component that uses
* this hook unmounts. The next component to mount and call this hook with the same `keyObj` will
* recieve the same value. The value will only be released when `keyObj` is garbage collected.
*/
export function useWeakRef<TValue>(keyObj: WeakKey): React.MutableRefObject<TValue | undefined> {
const subscribe = useCallback(
(onStoreChange: () => void) => {
let callbacks = subscribers.get(keyObj);
if (callbacks == null) {
subscribers.set(keyObj, (callbacks = new Set()));
}
function handleChange() {
// This is super subtle; When there's a change, we want to create a new ref object
// so that `useSyncExternalStore()` perceives it as changed and triggers a rerender.
ref.current = createMutableRef(keyObj);
onStoreChange();
}
callbacks.add(handleChange);
return () => {
ref.current = undefined;
callbacks.delete(handleChange);
if (callbacks.size === 0) {
subscribers.delete(keyObj);
}
};
},
[keyObj]
);
const ref = useRef<React.MutableRefObject<TValue | undefined>>();
const getSnapshot = useCallback(() => (ref.current ||= createMutableRef<TValue>(keyObj)), [keyObj]);
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

0 comments on commit 7dcc681

Please sign in to comment.