Skip to content

Commit b9f4f62

Browse files
committed
[useSES/extra] Reuse old selection if possible
When you pass an unmemoized selector to useSyncExternalStoreExtra, we have to reevaluate it on every render because we don't know whether it depends on new props. However, after reevalutating it, we should still compare the result to the previous selection with `isEqual` and reuse the old one if it hasn't changed. Originally I did not implement this, because if the selector changes due to new props or state, the component is going to have to re-render anyway. However, it's still useful to return a memoized selection when possible, because it may be the input to a downstream memoization. In the test I wrote, the example I chose is selecting a list of items from the store, and passing the list as a prop to a memoized component. If the list prop is memoized, we can bail out.
1 parent 33226fa commit b9f4f62

File tree

2 files changed

+126
-3
lines changed

2 files changed

+126
-3
lines changed

packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
571571
});
572572

573573
describe('extra features implemented in user-space', () => {
574+
// The selector implementation uses the lazy ref initialization pattern
575+
// @gate !enableUseRefAccessWarning
574576
test('memoized selectors are only called once per update', async () => {
575577
const store = createExternalStore({a: 0, b: 0});
576578

@@ -610,6 +612,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
610612
expect(root).toMatchRenderedOutput('A1');
611613
});
612614

615+
// The selector implementation uses the lazy ref initialization pattern
616+
// @gate !enableUseRefAccessWarning
613617
test('Using isEqual to bailout', async () => {
614618
const store = createExternalStore({a: 0, b: 0});
615619

@@ -666,4 +670,81 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
666670
expect(root).toMatchRenderedOutput('A1B1');
667671
});
668672
});
673+
674+
// The selector implementation uses the lazy ref initialization pattern
675+
// @gate !enableUseRefAccessWarning
676+
test('compares selection to rendered selection even if selector changes', async () => {
677+
const store = createExternalStore({items: ['A', 'B']});
678+
679+
const shallowEqualArray = (a, b) => {
680+
if (a.length !== b.length) {
681+
return false;
682+
}
683+
for (let i = 0; i < a.length; i++) {
684+
if (a[i] !== b[i]) {
685+
return false;
686+
}
687+
}
688+
return true;
689+
};
690+
691+
const List = React.memo(({items}) => {
692+
return (
693+
<ul>
694+
{items.map(text => (
695+
<li key={text}>
696+
<Text key={text} text={text} />
697+
</li>
698+
))}
699+
</ul>
700+
);
701+
});
702+
703+
function App({step}) {
704+
const inlineSelector = state => {
705+
Scheduler.unstable_yieldValue('Inline selector');
706+
return [...state.items, 'C'];
707+
};
708+
const items = useSyncExternalStoreExtra(
709+
store.subscribe,
710+
store.getState,
711+
inlineSelector,
712+
shallowEqualArray,
713+
);
714+
return (
715+
<>
716+
<List items={items} />
717+
<Text text={'Sibling: ' + step} />
718+
</>
719+
);
720+
}
721+
722+
const root = createRoot();
723+
await act(() => {
724+
root.render(<App step={0} />);
725+
});
726+
expect(Scheduler).toHaveYielded([
727+
'Inline selector',
728+
'A',
729+
'B',
730+
'C',
731+
'Sibling: 0',
732+
]);
733+
734+
await act(() => {
735+
root.render(<App step={1} />);
736+
});
737+
expect(Scheduler).toHaveYielded([
738+
// We had to call the selector again because it's not memoized
739+
'Inline selector',
740+
741+
// But because the result was the same (according to isEqual) we can
742+
// bail out of rendering the memoized list. These are skipped:
743+
// 'A',
744+
// 'B',
745+
// 'C',
746+
747+
'Sibling: 1',
748+
]);
749+
});
669750
});

packages/use-sync-external-store/src/useSyncExternalStoreExtra.js

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {useSyncExternalStore} from 'use-sync-external-store';
1313

1414
// Intentionally not using named imports because Rollup uses dynamic
1515
// dispatch for CommonJS interop named imports.
16-
const {useMemo, useDebugValue} = React;
16+
const {useRef, useEffect, useMemo, useDebugValue} = React;
1717

1818
// Same as useSyncExternalStore, but supports selector and isEqual arguments.
1919
export function useSyncExternalStoreExtra<Snapshot, Selection>(
@@ -22,6 +22,19 @@ export function useSyncExternalStoreExtra<Snapshot, Selection>(
2222
selector: (snapshot: Snapshot) => Selection,
2323
isEqual?: (a: Selection, b: Selection) => boolean,
2424
): Selection {
25+
// Use this to track the rendered snapshot.
26+
const instRef = useRef(null);
27+
let inst;
28+
if (instRef.current === null) {
29+
inst = {
30+
hasValue: false,
31+
value: (null: Selection | null),
32+
};
33+
instRef.current = inst;
34+
} else {
35+
inst = instRef.current;
36+
}
37+
2538
const getSnapshotWithMemoizedSelector = useMemo(() => {
2639
// Track the memoized state using closure variables that are local to this
2740
// memoized instance of a getSnapshot function. Intentionally not using a
@@ -38,6 +51,15 @@ export function useSyncExternalStoreExtra<Snapshot, Selection>(
3851
hasMemo = true;
3952
memoizedSnapshot = nextSnapshot;
4053
const nextSelection = selector(nextSnapshot);
54+
if (isEqual !== undefined) {
55+
if (inst.hasValue) {
56+
const sharedValue = inst.value;
57+
if (isEqual(sharedValue, nextSelection)) {
58+
memoizedSelection = sharedValue;
59+
return sharedValue;
60+
}
61+
}
62+
}
4163
memoizedSelection = nextSelection;
4264
return nextSelection;
4365
}
@@ -59,18 +81,38 @@ export function useSyncExternalStoreExtra<Snapshot, Selection>(
5981
// has changed. If it hasn't, return the previous selection. That signals
6082
// to React that the selections are conceptually equal, and we can bail
6183
// out of rendering.
62-
if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
63-
return prevSelection;
84+
if (isEqual !== undefined) {
85+
if (isEqual(prevSelection, nextSelection)) {
86+
return prevSelection;
87+
}
88+
89+
// Even if the selector has changed, we should attempt to return the
90+
// previous selection, since it may be an input to a
91+
// downstream memoization.
92+
if (inst.hasValue) {
93+
const sharedValue = inst.value;
94+
if (isEqual(sharedValue, nextSelection)) {
95+
memoizedSelection = sharedValue;
96+
return sharedValue;
97+
}
98+
}
6499
}
65100

66101
memoizedSelection = nextSelection;
67102
return nextSelection;
68103
};
69104
}, [getSnapshot, selector, isEqual]);
105+
70106
const value = useSyncExternalStore(
71107
subscribe,
72108
getSnapshotWithMemoizedSelector,
73109
);
110+
111+
useEffect(() => {
112+
inst.hasValue = true;
113+
inst.value = value;
114+
}, [value]);
115+
74116
useDebugValue(value);
75117
return value;
76118
}

0 commit comments

Comments
 (0)