From 07af449c4704f27ee54a47978bf047198a391983 Mon Sep 17 00:00:00 2001 From: soloclz Date: Sun, 19 Apr 2026 12:05:51 +0800 Subject: [PATCH] [verified] fix(reconciler): recheck store snapshot after subscribe --- .../react-reconciler/src/ReactFiberHooks.js | 14 ++++- .../__tests__/useSyncExternalStore-test.js | 60 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 29c83c7d7263..8ab5f7ed7a4c 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1869,8 +1869,18 @@ function subscribeToStore( forceStoreRerender(fiber); } }; - // Subscribe to the store and return a clean-up function. - return subscribe(handleStoreChange); + // Subscribe to the store. + const unsubscribe = subscribe(handleStoreChange); + + // Something may have been mutated before the subscription finished but after + // the last snapshot read during render. Check one more time now that the + // subscription is active. + if (checkIfSnapshotChanged(inst)) { + forceStoreRerender(fiber); + } + + // Return a clean-up function. + return unsubscribe; } function checkIfSnapshotChanged(inst: StoreInstance): boolean { diff --git a/packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js b/packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js index eb5bc6ed98cd..2be80aeaea1e 100644 --- a/packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js +++ b/packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js @@ -25,6 +25,7 @@ let waitFor; let waitForAll; let assertLog; let Suspense; +let StrictMode; let useMemo; let textCache; @@ -49,6 +50,7 @@ describe('useSyncExternalStore', () => { useSyncExternalStore = React.useSyncExternalStore; startTransition = React.startTransition; Suspense = React.Suspense; + StrictMode = React.StrictMode; useMemo = React.useMemo; textCache = new Map(); const InternalTestUtils = require('internal-test-utils'); @@ -352,6 +354,64 @@ describe('useSyncExternalStore', () => { }, ); + it('re-checks the snapshot after re-subscribing following a StrictMode Suspense remount', async () => { + let currentState = 0; + let shouldMutateOnSubscribe = false; + + const store = { + subscribe(listener) { + if (shouldMutateOnSubscribe) { + shouldMutateOnSubscribe = false; + currentState = 1; + } + // We intentionally do not notify through the listener here; the point + // of this test is that subscribe-time silent mutations still need the + // post-subscribe snapshot repair path. + return () => {}; + }, + getState() { + return currentState; + }, + }; + + function StoreText({text}) { + const value = useSyncExternalStore(store.subscribe, store.getState); + const resolvedText = readText(text); + return ; + } + + function App({text}) { + return ( + + }> + + + + ); + } + + resolveText('A'); + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + assertLog(['A0']); + expect(root).toMatchRenderedOutput('A0'); + + await act(async () => { + shouldMutateOnSubscribe = true; + root.render(); + }); + assertLog(['Loading...']); + expect(root).toMatchRenderedOutput('Loading...'); + + await act(() => { + resolveText('B'); + }); + assertLog(['B0', 'B1']); + expect(root).toMatchRenderedOutput('B1'); + }); + it('regression: does not infinite loop for only changing store reference in render', async () => { let store = {value: {}}; let listeners = [];