diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index bc609efcf0d0d..bf5388597571e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -2841,4 +2841,247 @@ describe('ReactDOMFizzServer', () => { expect(window.__test_outlet).toBe(1); }); }); + + // @gate experimental && enableClientRenderFallbackOnTextMismatch + it('#24384: Suspending should halt hydration warnings while still allowing siblings to warm up', async () => { + const makeApp = () => { + let resolve, resolved; + const promise = new Promise(r => { + resolve = () => { + resolved = true; + return r(); + }; + }); + function ComponentThatSuspends() { + if (!resolved) { + throw promise; + } + return

A

; + } + + const App = ({text}) => { + return ( +
+ Loading...}> + +

{text}

+
+
+ ); + }; + + return [App, resolve]; + }; + + const [ServerApp, serverResolve] = makeApp(); + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + await act(() => { + serverResolve(); + }); + + expect(getVisibleChildren(container)).toEqual( +
+

A

+

initial

+
, + ); + + // The client app is rendered with an intentionally incorrect text. The still Suspended component causes + // hydration to fail silently (allowing for cache warming but otherwise skipping this boundary) until it + // resolves. + const [ClientApp, clientResolve] = makeApp(); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue( + 'Logged recoverable error: ' + error.message, + ); + }, + }); + Scheduler.unstable_flushAll(); + + expect(getVisibleChildren(container)).toEqual( +
+

A

+

initial

+
, + ); + + // Now that the boundary resolves to it's children the hydration completes and discovers that there is a mismatch requiring + // client-side rendering. + await clientResolve(); + expect(() => { + expect(Scheduler).toFlushAndYield([ + 'Logged recoverable error: Text content does not match server-rendered HTML.', + 'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.', + ]); + }).toErrorDev( + 'Warning: Prop `name` did not match. Server: "initial" Client: "replaced"', + ); + expect(getVisibleChildren(container)).toEqual( +
+

A

+

replaced

+
, + ); + + expect(Scheduler).toFlushAndYield([]); + }); + + // @gate experimental && enableClientRenderFallbackOnTextMismatch + it('only warns once on hydration mismatch while within a suspense boundary', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + + const App = ({text}) => { + return ( +
+ Loading...}> +

{text}

+

{text}

+

{text}

+
+
+ ); + }; + + try { + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual( +
+

initial

+

initial

+

initial

+
, + ); + + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue( + 'Logged recoverable error: ' + error.message, + ); + }, + }); + expect(Scheduler).toFlushAndYield([ + 'Logged recoverable error: Text content does not match server-rendered HTML.', + 'Logged recoverable error: Text content does not match server-rendered HTML.', + 'Logged recoverable error: Text content does not match server-rendered HTML.', + 'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.', + ]); + + expect(getVisibleChildren(container)).toEqual( +
+

replaced

+

replaced

+

replaced

+
, + ); + + expect(Scheduler).toFlushAndYield([]); + if (__DEV__) { + expect(mockError.mock.calls.length).toBe(1); + expect(mockError.mock.calls[0]).toEqual([ + 'Warning: Text content did not match. Server: "%s" Client: "%s"%s', + 'initial', + 'replaced', + '\n' + + ' in h2 (at **)\n' + + ' in Suspense (at **)\n' + + ' in div (at **)\n' + + ' in App (at **)', + ]); + } else { + expect(mockError.mock.calls.length).toBe(0); + } + } finally { + console.error = originalConsoleError; + } + }); + + // @gate experimental + it('supresses hydration warnings when an error occurs within a Suspense boundary', async () => { + let isClient = false; + let shouldThrow = true; + + function ThrowUntilOnClient({children}) { + if (isClient && shouldThrow) { + throw new Error('uh oh'); + } + return children; + } + + function StopThrowingOnClient() { + if (isClient) { + shouldThrow = false; + } + return null; + } + + const App = () => { + return ( +
+ Loading...}> + +

one

+
+

two

+

{isClient ? 'five' : 'three'}

+ +
+
+ ); + }; + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual( +
+

one

+

two

+

three

+
, + ); + + isClient = true; + + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue( + 'Logged recoverable error: ' + error.message, + ); + }, + }); + expect(Scheduler).toFlushAndYield([ + 'Logged recoverable error: uh oh', + 'Logged recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.', + 'Logged recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.', + 'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.', + ]); + + expect(getVisibleChildren(container)).toEqual( +
+

one

+

two

+

five

+
, + ); + + expect(Scheduler).toFlushAndYield([]); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 4fce6008bcf5b..fe9d8dd453cdb 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -285,7 +285,7 @@ describe('ReactDOMServerPartialHydration', () => { } try { const finalHTML = ReactDOMServer.renderToString(); - const container = document.createElement('div'); + const container = document.createElement('section'); container.innerHTML = finalHTML; expect(Scheduler).toHaveYielded([ 'Hello', @@ -350,12 +350,14 @@ describe('ReactDOMServerPartialHydration', () => { ); if (__DEV__) { - expect(mockError.mock.calls[0]).toEqual([ + const secondToLastCall = + mockError.mock.calls[mockError.mock.calls.length - 2]; + expect(secondToLastCall).toEqual([ 'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s', - 'div', - 'div', + 'article', + 'section', '\n' + - ' in div (at **)\n' + + ' in article (at **)\n' + ' in Component (at **)\n' + ' in Suspense (at **)\n' + ' in App (at **)', diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index 26b05d008ac53..e0e592d32790f 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -80,7 +80,10 @@ import {queueRecoverableErrors} from './ReactFiberWorkLoop.new'; let hydrationParentFiber: null | Fiber = null; let nextHydratableInstance: null | HydratableInstance = null; let isHydrating: boolean = false; -let didSuspend: boolean = false; + +// This flag allows for warning supression when we expect there to be mismatches +// due to earlier mismatches or a suspended fiber. +let didSuspendOrErrorDEV: boolean = false; // Hydration errors that were thrown inside this boundary let hydrationErrors: Array | null = null; @@ -95,9 +98,9 @@ function warnIfHydrating() { } } -export function markDidSuspendWhileHydratingDEV() { +export function markDidThrowWhileHydratingDEV() { if (__DEV__) { - didSuspend = true; + didSuspendOrErrorDEV = true; } } @@ -113,7 +116,7 @@ function enterHydrationState(fiber: Fiber): boolean { hydrationParentFiber = fiber; isHydrating = true; hydrationErrors = null; - didSuspend = false; + didSuspendOrErrorDEV = false; return true; } @@ -131,7 +134,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( hydrationParentFiber = fiber; isHydrating = true; hydrationErrors = null; - didSuspend = false; + didSuspendOrErrorDEV = false; if (treeContext !== null) { restoreSuspendedTreeContext(fiber, treeContext); } @@ -196,7 +199,7 @@ function deleteHydratableInstance( function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) { if (__DEV__) { - if (didSuspend) { + if (didSuspendOrErrorDEV) { // Inside a boundary that already suspended. We're currently rendering the // siblings of a suspended node. The mismatch may be due to the missing // data, so it's probably a false positive. @@ -444,7 +447,7 @@ function prepareToHydrateHostInstance( } const instance: Instance = fiber.stateNode; - const shouldWarnIfMismatchDev = !didSuspend; + const shouldWarnIfMismatchDev = !didSuspendOrErrorDEV; const updatePayload = hydrateInstance( instance, fiber.type, @@ -474,7 +477,7 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean { const textInstance: TextInstance = fiber.stateNode; const textContent: string = fiber.memoizedProps; - const shouldWarnIfMismatchDev = !didSuspend; + const shouldWarnIfMismatchDev = !didSuspendOrErrorDEV; const shouldUpdate = hydrateTextInstance( textInstance, textContent, @@ -653,7 +656,7 @@ function resetHydrationState(): void { hydrationParentFiber = null; nextHydratableInstance = null; isHydrating = false; - didSuspend = false; + didSuspendOrErrorDEV = false; } export function upgradeHydrationErrorsToRecoverable(): void { diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index cf7890e3edbae..a4fd5c93e22ea 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -80,7 +80,10 @@ import {queueRecoverableErrors} from './ReactFiberWorkLoop.old'; let hydrationParentFiber: null | Fiber = null; let nextHydratableInstance: null | HydratableInstance = null; let isHydrating: boolean = false; -let didSuspend: boolean = false; + +// This flag allows for warning supression when we expect there to be mismatches +// due to earlier mismatches or a suspended fiber. +let didSuspendOrErrorDEV: boolean = false; // Hydration errors that were thrown inside this boundary let hydrationErrors: Array | null = null; @@ -95,9 +98,9 @@ function warnIfHydrating() { } } -export function markDidSuspendWhileHydratingDEV() { +export function markDidThrowWhileHydratingDEV() { if (__DEV__) { - didSuspend = true; + didSuspendOrErrorDEV = true; } } @@ -113,7 +116,7 @@ function enterHydrationState(fiber: Fiber): boolean { hydrationParentFiber = fiber; isHydrating = true; hydrationErrors = null; - didSuspend = false; + didSuspendOrErrorDEV = false; return true; } @@ -131,7 +134,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( hydrationParentFiber = fiber; isHydrating = true; hydrationErrors = null; - didSuspend = false; + didSuspendOrErrorDEV = false; if (treeContext !== null) { restoreSuspendedTreeContext(fiber, treeContext); } @@ -196,7 +199,7 @@ function deleteHydratableInstance( function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) { if (__DEV__) { - if (didSuspend) { + if (didSuspendOrErrorDEV) { // Inside a boundary that already suspended. We're currently rendering the // siblings of a suspended node. The mismatch may be due to the missing // data, so it's probably a false positive. @@ -444,7 +447,7 @@ function prepareToHydrateHostInstance( } const instance: Instance = fiber.stateNode; - const shouldWarnIfMismatchDev = !didSuspend; + const shouldWarnIfMismatchDev = !didSuspendOrErrorDEV; const updatePayload = hydrateInstance( instance, fiber.type, @@ -474,7 +477,7 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean { const textInstance: TextInstance = fiber.stateNode; const textContent: string = fiber.memoizedProps; - const shouldWarnIfMismatchDev = !didSuspend; + const shouldWarnIfMismatchDev = !didSuspendOrErrorDEV; const shouldUpdate = hydrateTextInstance( textInstance, textContent, @@ -653,7 +656,7 @@ function resetHydrationState(): void { hydrationParentFiber = null; nextHydratableInstance = null; isHydrating = false; - didSuspend = false; + didSuspendOrErrorDEV = false; } export function upgradeHydrationErrorsToRecoverable(): void { diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index 815cb25ef045f..7db27ba935d00 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -83,7 +83,7 @@ import { } from './ReactFiberLane.new'; import { getIsHydrating, - markDidSuspendWhileHydratingDEV, + markDidThrowWhileHydratingDEV, queueHydrationError, } from './ReactFiberHydrationContext.new'; @@ -453,6 +453,12 @@ function throwException( const wakeable: Wakeable = (value: any); resetSuspendedComponent(sourceFiber, rootRenderLanes); + if (__DEV__) { + if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) { + markDidThrowWhileHydratingDEV(); + } + } + if (__DEV__) { if (enableDebugTracing) { if (sourceFiber.mode & DebugTracingMode) { @@ -514,8 +520,7 @@ function throwException( } else { // This is a regular error, not a Suspense wakeable. if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) { - markDidSuspendWhileHydratingDEV(); - + markDidThrowWhileHydratingDEV(); const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber); // If the error was thrown during hydration, we may be able to recover by // discarding the dehydrated content and switching to a client render. diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index ec89f5ab0cd5e..a8e75e9ce6611 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -83,7 +83,7 @@ import { } from './ReactFiberLane.old'; import { getIsHydrating, - markDidSuspendWhileHydratingDEV, + markDidThrowWhileHydratingDEV, queueHydrationError, } from './ReactFiberHydrationContext.old'; @@ -453,6 +453,12 @@ function throwException( const wakeable: Wakeable = (value: any); resetSuspendedComponent(sourceFiber, rootRenderLanes); + if (__DEV__) { + if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) { + markDidThrowWhileHydratingDEV(); + } + } + if (__DEV__) { if (enableDebugTracing) { if (sourceFiber.mode & DebugTracingMode) { @@ -514,8 +520,7 @@ function throwException( } else { // This is a regular error, not a Suspense wakeable. if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) { - markDidSuspendWhileHydratingDEV(); - + markDidThrowWhileHydratingDEV(); const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber); // If the error was thrown during hydration, we may be able to recover by // discarding the dehydrated content and switching to a client render.