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 8db73a8e8c..3102087b8e 100644 --- a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js +++ b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js @@ -618,4 +618,30 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { expect(root).toMatchRenderedOutput('A1B1'); }); }); + + test('Infinite loop if getSnapshot keeps returning new reference', () => { + const store = createExternalStore({}); + + function App() { + const text = useSyncExternalStore(store.subscribe, () => ({})); + return ; + } + + spyOnDev(console, 'error'); + + expect(() => { + act(() => { + createRoot(); + }); + }).toThrow( + 'Maximum update depth exceeded. This can happen when a component repeatedly ' + + 'calls setState inside componentWillUpdate or componentDidUpdate. React limits ' + + 'the number of nested updates to prevent infinite loops.', + ); + if (__DEV__) { + expect(console.error.calls.argsFor(0)[0]).toMatch( + 'The result of getSnapshot should be cached to avoid an infinite loop', + ); + } + }); }); diff --git a/packages/use-sync-external-store/src/useSyncExternalStore.js b/packages/use-sync-external-store/src/useSyncExternalStore.js index 55853f6e9b..8607c915eb 100644 --- a/packages/use-sync-external-store/src/useSyncExternalStore.js +++ b/packages/use-sync-external-store/src/useSyncExternalStore.js @@ -29,6 +29,7 @@ export const useSyncExternalStore = builtInAPI !== undefined ? builtInAPI : useSyncExternalStore_shim; let didWarnOld18Alpha = false; +let didWarnUncachedGetSnapshot = false; // Disclaimer: This shim breaks many of the rules of React, and only works // because of a very particular set of implementation details and assumptions @@ -63,6 +64,16 @@ function useSyncExternalStore_shim( // implementation details, most importantly that updates are // always synchronous. const value = getSnapshot(); + if (__DEV__) { + if (!didWarnUncachedGetSnapshot) { + if (value !== getSnapshot()) { + console.error( + 'The result of getSnapshot should be cached to avoid an infinite loop', + ); + didWarnUncachedGetSnapshot = true; + } + } + } // Because updates are synchronous, we don't queue them. Instead we force a // re-render whenever the subscribed state changes by updating an some