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(
+ ,
+ );
+
+ // 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(
+ ,
+ );
+
+ // 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(
+ ,
+ );
+
+ 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.