diff --git a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js
index a355dd15d8260..f4bcf2b72527e 100644
--- a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js
+++ b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js
@@ -571,6 +571,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
});
describe('extra features implemented in user-space', () => {
+ // The selector implementation uses the lazy ref initialization pattern
+ // @gate !(enableUseRefAccessWarning && __DEV__)
test('memoized selectors are only called once per update', async () => {
const store = createExternalStore({a: 0, b: 0});
@@ -610,6 +612,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
expect(root).toMatchRenderedOutput('A1');
});
+ // The selector implementation uses the lazy ref initialization pattern
+ // @gate !(enableUseRefAccessWarning && __DEV__)
test('Using isEqual to bailout', async () => {
const store = createExternalStore({a: 0, b: 0});
@@ -666,4 +670,81 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
expect(root).toMatchRenderedOutput('A1B1');
});
});
+
+ // The selector implementation uses the lazy ref initialization pattern
+ // @gate !(enableUseRefAccessWarning && __DEV__)
+ test('compares selection to rendered selection even if selector changes', async () => {
+ const store = createExternalStore({items: ['A', 'B']});
+
+ const shallowEqualArray = (a, b) => {
+ if (a.length !== b.length) {
+ return false;
+ }
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) {
+ return false;
+ }
+ }
+ return true;
+ };
+
+ const List = React.memo(({items}) => {
+ return (
+
+ {items.map(text => (
+ -
+
+
+ ))}
+
+ );
+ });
+
+ function App({step}) {
+ const inlineSelector = state => {
+ Scheduler.unstable_yieldValue('Inline selector');
+ return [...state.items, 'C'];
+ };
+ const items = useSyncExternalStoreExtra(
+ store.subscribe,
+ store.getState,
+ inlineSelector,
+ shallowEqualArray,
+ );
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ const root = createRoot();
+ await act(() => {
+ root.render();
+ });
+ expect(Scheduler).toHaveYielded([
+ 'Inline selector',
+ 'A',
+ 'B',
+ 'C',
+ 'Sibling: 0',
+ ]);
+
+ await act(() => {
+ root.render();
+ });
+ expect(Scheduler).toHaveYielded([
+ // We had to call the selector again because it's not memoized
+ 'Inline selector',
+
+ // But because the result was the same (according to isEqual) we can
+ // bail out of rendering the memoized list. These are skipped:
+ // 'A',
+ // 'B',
+ // 'C',
+
+ 'Sibling: 1',
+ ]);
+ });
});
diff --git a/packages/use-sync-external-store/src/useSyncExternalStoreExtra.js b/packages/use-sync-external-store/src/useSyncExternalStoreExtra.js
index 905bc67c462bd..eeff987ae8f1d 100644
--- a/packages/use-sync-external-store/src/useSyncExternalStoreExtra.js
+++ b/packages/use-sync-external-store/src/useSyncExternalStoreExtra.js
@@ -13,7 +13,7 @@ import {useSyncExternalStore} from 'use-sync-external-store';
// Intentionally not using named imports because Rollup uses dynamic
// dispatch for CommonJS interop named imports.
-const {useMemo, useDebugValue} = React;
+const {useRef, useEffect, useMemo, useDebugValue} = React;
// Same as useSyncExternalStore, but supports selector and isEqual arguments.
export function useSyncExternalStoreExtra(
@@ -22,6 +22,19 @@ export function useSyncExternalStoreExtra(
selector: (snapshot: Snapshot) => Selection,
isEqual?: (a: Selection, b: Selection) => boolean,
): Selection {
+ // Use this to track the rendered snapshot.
+ const instRef = useRef(null);
+ let inst;
+ if (instRef.current === null) {
+ inst = {
+ hasValue: false,
+ value: (null: Selection | null),
+ };
+ instRef.current = inst;
+ } else {
+ inst = instRef.current;
+ }
+
const getSnapshotWithMemoizedSelector = useMemo(() => {
// Track the memoized state using closure variables that are local to this
// memoized instance of a getSnapshot function. Intentionally not using a
@@ -38,6 +51,18 @@ export function useSyncExternalStoreExtra(
hasMemo = true;
memoizedSnapshot = nextSnapshot;
const nextSelection = selector(nextSnapshot);
+ if (isEqual !== undefined) {
+ // Even if the selector has changed, the currently rendered selection
+ // may be equal to the new selection. We should attempt to reuse the
+ // current value if possible, to preserve downstream memoizations.
+ if (inst.hasValue) {
+ const currentSelection = inst.value;
+ if (isEqual(currentSelection, nextSelection)) {
+ memoizedSelection = currentSelection;
+ return currentSelection;
+ }
+ }
+ }
memoizedSelection = nextSelection;
return nextSelection;
}
@@ -67,10 +92,17 @@ export function useSyncExternalStoreExtra(
return nextSelection;
};
}, [getSnapshot, selector, isEqual]);
+
const value = useSyncExternalStore(
subscribe,
getSnapshotWithMemoizedSelector,
);
+
+ useEffect(() => {
+ inst.hasValue = true;
+ inst.value = value;
+ }, [value]);
+
useDebugValue(value);
return value;
}