diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index b20380321f8b..5b6cfabb4aad 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -476,6 +476,115 @@ describe('ReactDOMFizzServer', () => {
);
});
+ it('does not produce a hydration mismatch when context updates in an already-hydrated ancestor before a streamed boundary hydrates', async () => {
+ // Regression test: Updating a context value in an already-hydrated ancestor
+ // (via a state update on a provider with referentially stable children)
+ // before a streamed Suspense child that reads that context finishes
+ // streaming used to cause a hydration mismatch. The streamed segment was
+ // rendered on the server with the initial context value, but the client
+ // hydrated it against the updated context. React should recover without a
+ // mismatch error.
+
+ const NumberContext = React.createContext(0);
+ let setNumberExternal = null;
+
+ function NumberProvider({children}) {
+ const [number, setNumber] = React.useState(0);
+ setNumberExternal = setNumber;
+ return (
+
+ {children}
+
+ );
+ }
+
+ function DisplayNumber() {
+ const number = React.useContext(NumberContext);
+ readText('display');
+ return
Number: {number}
;
+ }
+
+ function App() {
+ return (
+
+
+ Loading...
}>
+
+
+
+
+ );
+ }
+
+ // The same context object is shared by the Fizz server and the client
+ // renderer in this single-process test, which trips the "multiple
+ // renderers" dev warning. That doesn't happen in real apps where server and
+ // client are separate processes, so filter it out here.
+ const realConsoleError = console.error;
+ console.error = (...args) => {
+ if (
+ typeof args[0] === 'string' &&
+ args[0].includes('Detected multiple renderers')
+ ) {
+ return;
+ }
+ realConsoleError.apply(console, args);
+ };
+
+ try {
+ await act(() => {
+ const {pipe} = renderToPipeableStream();
+ pipe(writable);
+ });
+
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+
+ const errors = [];
+ ReactDOMClient.hydrateRoot(container, , {
+ onRecoverableError(error) {
+ errors.push(normalizeError(error.message));
+ },
+ });
+ await waitForAll([]);
+
+ // Shell hydrated, boundary still pending.
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+
+ // Update the context value in the already-hydrated ancestor BEFORE the
+ // streamed boundary content arrives and hydrates.
+ await clientAct(() => {
+ React.startTransition(() => {
+ setNumberExternal(1);
+ });
+ });
+
+ // Now the boundary content becomes available.
+ await act(() => {
+ resolveText('display');
+ });
+ await clientAct(async () => {});
+
+ // No hydration mismatch error should have been reported, and the boundary
+ // should reflect the updated context value.
+ expect(errors).toEqual([]);
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+ } finally {
+ console.error = realConsoleError;
+ }
+ });
+
it('#23331: does not warn about hydration mismatches if something suspended in an earlier sibling', async () => {
const makeApp = () => {
let resolve;
diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js
index 4f41a70e56eb..226346577c02 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.js
@@ -3982,6 +3982,30 @@ function attemptEarlyBailoutIfNoScheduledUpdate(
const state: SuspenseState | null = workInProgress.memoizedState;
if (state !== null) {
if (state.dehydrated !== null) {
+ // Before we bail out on a dehydrated boundary, we need to check
+ // whether a parent provider's context changed. The boundary's
+ // children only exist as server-rendered HTML, so normal propagation
+ // can't find context consumers inside it. If we bail out without
+ // propagating, a context change in an already-hydrated ancestor
+ // (e.g. a `useState` update in a provider with referentially stable
+ // children) will never be recorded on this boundary. When the
+ // streamed content later arrives and hydrates, it would read the
+ // updated context value and mismatch the server HTML. By propagating
+ // now, the boundary's childLanes records the change and
+ // `updateDehydratedSuspenseComponent` can recover instead of
+ // producing a hydration mismatch.
+ const contextChanged = lazilyPropagateParentContextChanges(
+ current,
+ workInProgress,
+ renderLanes,
+ );
+ if (contextChanged) {
+ return updateSuspenseComponent(
+ current,
+ workInProgress,
+ renderLanes,
+ );
+ }
// We're not going to render the children, so this is just to maintain
// push/pop symmetry
pushPrimaryTreeSuspenseHandler(workInProgress);