diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 01397d00004fc..0b14e567c7e0d 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -301,6 +301,72 @@ describe('ReactDOMFizzServer', () => { ); }); + // @gate experimental + it('#23331: does not warn about hydration mismatches if something suspended in an earlier sibling', async () => { + const makeApp = () => { + let resolve; + const imports = new Promise(r => { + resolve = () => r({default: () => async}); + }); + const Lazy = React.lazy(() => imports); + + const App = () => ( +
+ Loading...}> + + after + +
+ ); + + return [App, resolve]; + }; + + // Server-side + const [App, resolve] = makeApp(); + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+ Loading... +
, + ); + await act(async () => { + resolve(); + }); + expect(getVisibleChildren(container)).toEqual( +
+ async + after +
, + ); + + // Client-side + const [HydrateApp, hydrateResolve] = makeApp(); + await act(async () => { + ReactDOM.hydrateRoot(container, ); + }); + + expect(getVisibleChildren(container)).toEqual( +
+ async + after +
, + ); + + await act(async () => { + hydrateResolve(); + }); + expect(getVisibleChildren(container)).toEqual( +
+ async + after +
, + ); + }); + // @gate experimental it('should support nonce scripts', async () => { CSPnonce = 'R4nd0m'; diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index 767b200f29093..1552629da1744 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -230,6 +230,7 @@ export function checkForUnmatchedText( serverText: string, clientText: string | number, isConcurrentMode: boolean, + shouldWarnDev: boolean, ) { const normalizedClientText = normalizeMarkupForTextOrAttribute(clientText); const normalizedServerText = normalizeMarkupForTextOrAttribute(serverText); @@ -237,14 +238,16 @@ export function checkForUnmatchedText( return; } - if (__DEV__) { - if (!didWarnInvalidHydration) { - didWarnInvalidHydration = true; - console.error( - 'Text content did not match. Server: "%s" Client: "%s"', - normalizedServerText, - normalizedClientText, - ); + if (shouldWarnDev) { + if (__DEV__) { + if (!didWarnInvalidHydration) { + didWarnInvalidHydration = true; + console.error( + 'Text content did not match. Server: "%s" Client: "%s"', + normalizedServerText, + normalizedClientText, + ); + } } } @@ -866,6 +869,7 @@ export function diffHydratedProperties( parentNamespace: string, rootContainerElement: Element | Document, isConcurrentMode: boolean, + shouldWarnDev: boolean, ): null | Array { let isCustomComponentTag; let extraAttributeNames: Set; @@ -985,6 +989,7 @@ export function diffHydratedProperties( domElement.textContent, nextProp, isConcurrentMode, + shouldWarnDev, ); } updatePayload = [CHILDREN, nextProp]; @@ -996,6 +1001,7 @@ export function diffHydratedProperties( domElement.textContent, nextProp, isConcurrentMode, + shouldWarnDev, ); } updatePayload = [CHILDREN, '' + nextProp]; @@ -1011,6 +1017,7 @@ export function diffHydratedProperties( } } } else if ( + shouldWarnDev && __DEV__ && // Convince Flow we've calculated it (it's DEV-only in this method.) typeof isCustomComponentTag === 'boolean' @@ -1142,10 +1149,12 @@ export function diffHydratedProperties( } if (__DEV__) { - // $FlowFixMe - Should be inferred as not undefined. - if (extraAttributeNames.size > 0 && !suppressHydrationWarning) { + if (shouldWarnDev) { // $FlowFixMe - Should be inferred as not undefined. - warnForExtraAttributes(extraAttributeNames); + if (extraAttributeNames.size > 0 && !suppressHydrationWarning) { + // $FlowFixMe - Should be inferred as not undefined. + warnForExtraAttributes(extraAttributeNames); + } } } diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 4863a16eda68e..97eb494ecba2a 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -786,6 +786,7 @@ export function hydrateInstance( rootContainerInstance: Container, hostContext: HostContext, internalInstanceHandle: Object, + shouldWarnDev: boolean, ): null | Array { precacheFiberNode(internalInstanceHandle, instance); // TODO: Possibly defer this until the commit phase where all the events @@ -811,6 +812,7 @@ export function hydrateInstance( parentNamespace, rootContainerInstance, isConcurrentMode, + shouldWarnDev, ); } @@ -818,6 +820,7 @@ export function hydrateTextInstance( textInstance: TextInstance, text: string, internalInstanceHandle: Object, + shouldWarnDev: boolean, ): boolean { precacheFiberNode(internalInstanceHandle, textInstance); @@ -924,7 +927,13 @@ export function didNotMatchHydratedContainerTextInstance( text: string, isConcurrentMode: boolean, ) { - checkForUnmatchedText(textInstance.nodeValue, text, isConcurrentMode); + const shouldWarnDev = true; + checkForUnmatchedText( + textInstance.nodeValue, + text, + isConcurrentMode, + shouldWarnDev, + ); } export function didNotMatchHydratedTextInstance( @@ -936,7 +945,13 @@ export function didNotMatchHydratedTextInstance( isConcurrentMode: boolean, ) { if (parentProps[SUPPRESS_HYDRATION_WARNING] !== true) { - checkForUnmatchedText(textInstance.nodeValue, text, isConcurrentMode); + const shouldWarnDev = true; + checkForUnmatchedText( + textInstance.nodeValue, + text, + isConcurrentMode, + shouldWarnDev, + ); } } diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index 6905f58e2ab1b..6a7a175d6cff3 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -84,6 +84,7 @@ import {queueRecoverableErrors} from './ReactFiberWorkLoop.new'; let hydrationParentFiber: null | Fiber = null; let nextHydratableInstance: null | HydratableInstance = null; let isHydrating: boolean = false; +let didSuspend: boolean = false; // Hydration errors that were thrown inside this boundary let hydrationErrors: Array | null = null; @@ -98,6 +99,12 @@ function warnIfHydrating() { } } +export function markDidSuspendWhileHydratingDEV() { + if (__DEV__) { + didSuspend = true; + } +} + function enterHydrationState(fiber: Fiber): boolean { if (!supportsHydration) { return false; @@ -110,6 +117,7 @@ function enterHydrationState(fiber: Fiber): boolean { hydrationParentFiber = fiber; isHydrating = true; hydrationErrors = null; + didSuspend = false; return true; } @@ -127,6 +135,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( hydrationParentFiber = fiber; isHydrating = true; hydrationErrors = null; + didSuspend = false; if (treeContext !== null) { restoreSuspendedTreeContext(fiber, treeContext); } @@ -185,6 +194,13 @@ function deleteHydratableInstance( function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) { if (__DEV__) { + if (didSuspend) { + // 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. + return; + } + switch (returnFiber.tag) { case HostRoot: { const parentContainer = returnFiber.stateNode.containerInfo; @@ -418,6 +434,7 @@ function prepareToHydrateHostInstance( } const instance: Instance = fiber.stateNode; + const shouldWarnIfMismatchDev = !didSuspend; const updatePayload = hydrateInstance( instance, fiber.type, @@ -425,6 +442,7 @@ function prepareToHydrateHostInstance( rootContainerInstance, hostContext, fiber, + shouldWarnIfMismatchDev, ); // TODO: Type this specific to this type of component. fiber.updateQueue = (updatePayload: any); @@ -446,7 +464,13 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean { const textInstance: TextInstance = fiber.stateNode; const textContent: string = fiber.memoizedProps; - const shouldUpdate = hydrateTextInstance(textInstance, textContent, fiber); + const shouldWarnIfMismatchDev = !didSuspend; + const shouldUpdate = hydrateTextInstance( + textInstance, + textContent, + fiber, + shouldWarnIfMismatchDev, + ); if (shouldUpdate) { // We assume that prepareToHydrateHostTextInstance is called in a context where the // hydration parent is the parent host component of this host text. @@ -616,6 +640,7 @@ function resetHydrationState(): void { hydrationParentFiber = null; nextHydratableInstance = null; isHydrating = false; + didSuspend = false; } export function upgradeHydrationErrorsToRecoverable(): void { diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index b9e88d3a21af0..8c334924943ab 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -84,6 +84,7 @@ import {queueRecoverableErrors} from './ReactFiberWorkLoop.old'; let hydrationParentFiber: null | Fiber = null; let nextHydratableInstance: null | HydratableInstance = null; let isHydrating: boolean = false; +let didSuspend: boolean = false; // Hydration errors that were thrown inside this boundary let hydrationErrors: Array | null = null; @@ -98,6 +99,12 @@ function warnIfHydrating() { } } +export function markDidSuspendWhileHydratingDEV() { + if (__DEV__) { + didSuspend = true; + } +} + function enterHydrationState(fiber: Fiber): boolean { if (!supportsHydration) { return false; @@ -110,6 +117,7 @@ function enterHydrationState(fiber: Fiber): boolean { hydrationParentFiber = fiber; isHydrating = true; hydrationErrors = null; + didSuspend = false; return true; } @@ -127,6 +135,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( hydrationParentFiber = fiber; isHydrating = true; hydrationErrors = null; + didSuspend = false; if (treeContext !== null) { restoreSuspendedTreeContext(fiber, treeContext); } @@ -185,6 +194,13 @@ function deleteHydratableInstance( function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) { if (__DEV__) { + if (didSuspend) { + // 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. + return; + } + switch (returnFiber.tag) { case HostRoot: { const parentContainer = returnFiber.stateNode.containerInfo; @@ -418,6 +434,7 @@ function prepareToHydrateHostInstance( } const instance: Instance = fiber.stateNode; + const shouldWarnIfMismatchDev = !didSuspend; const updatePayload = hydrateInstance( instance, fiber.type, @@ -425,6 +442,7 @@ function prepareToHydrateHostInstance( rootContainerInstance, hostContext, fiber, + shouldWarnIfMismatchDev, ); // TODO: Type this specific to this type of component. fiber.updateQueue = (updatePayload: any); @@ -446,7 +464,13 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean { const textInstance: TextInstance = fiber.stateNode; const textContent: string = fiber.memoizedProps; - const shouldUpdate = hydrateTextInstance(textInstance, textContent, fiber); + const shouldWarnIfMismatchDev = !didSuspend; + const shouldUpdate = hydrateTextInstance( + textInstance, + textContent, + fiber, + shouldWarnIfMismatchDev, + ); if (shouldUpdate) { // We assume that prepareToHydrateHostTextInstance is called in a context where the // hydration parent is the parent host component of this host text. @@ -616,6 +640,7 @@ function resetHydrationState(): void { hydrationParentFiber = null; nextHydratableInstance = null; isHydrating = false; + didSuspend = false; } export function upgradeHydrationErrorsToRecoverable(): void { diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index cc5e3215c786d..815cb25ef045f 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -83,6 +83,7 @@ import { } from './ReactFiberLane.new'; import { getIsHydrating, + markDidSuspendWhileHydratingDEV, queueHydrationError, } from './ReactFiberHydrationContext.new'; @@ -513,6 +514,8 @@ function throwException( } else { // This is a regular error, not a Suspense wakeable. if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) { + markDidSuspendWhileHydratingDEV(); + 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 727c613d7622f..ec89f5ab0cd5e 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -83,6 +83,7 @@ import { } from './ReactFiberLane.old'; import { getIsHydrating, + markDidSuspendWhileHydratingDEV, queueHydrationError, } from './ReactFiberHydrationContext.old'; @@ -513,6 +514,8 @@ function throwException( } else { // This is a regular error, not a Suspense wakeable. if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) { + markDidSuspendWhileHydratingDEV(); + 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.