diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index bf5388597571..f6a8c8105d16 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -1207,6 +1207,9 @@ describe('ReactDOMFizzServer', () => {
});
function normalizeCodeLocInfo(str) {
+ if (typeof str === 'object') {
+ return;
+ }
return (
str &&
str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function(m, name) {
@@ -2842,8 +2845,84 @@ describe('ReactDOMFizzServer', () => {
});
});
+ // @gate experimental
+ it('#24384: Suspending should halt hydration warnings and not emit any if hydration completes successfully after unsuspending', 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 = () => {
+ return (
+
+ Loading...}>
+
+ world
+
+
+ );
+ };
+
+ return [App, resolve];
+ };
+
+ const [ServerApp, serverResolve] = makeApp();
+ await act(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream();
+ pipe(writable);
+ });
+ await act(() => {
+ serverResolve();
+ });
+
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+
+ 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(Scheduler).toFlushWithoutYielding();
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+ });
+
// @gate experimental && enableClientRenderFallbackOnTextMismatch
- it('#24384: Suspending should halt hydration warnings while still allowing siblings to warm up', async () => {
+ it('#24384: Suspending should halt hydration warnings but still emit hydration warnings after unsuspending if mismatches are genuine', async () => {
const makeApp = () => {
let resolve, resolved;
const promise = new Promise(r => {
@@ -2976,8 +3055,6 @@ describe('ReactDOMFizzServer', () => {
},
});
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.',
]);
@@ -3069,8 +3146,6 @@ describe('ReactDOMFizzServer', () => {
});
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.',
]);
@@ -3084,4 +3159,365 @@ describe('ReactDOMFizzServer', () => {
expect(Scheduler).toFlushAndYield([]);
});
+
+ // @gate experimental && __DEV__
+ it('does not invokeGuardedCallback for errors after the first hydration error', async () => {
+ // We can't use the toErrorDev helper here because this is async.
+ const originalConsoleError = console.error;
+ const mockError = jest.fn();
+ console.error = (...args) => {
+ if (args.length > 1) {
+ if (typeof args[1] === 'object') {
+ mockError(args[0].split('\n')[0]);
+ return;
+ }
+ }
+ mockError(...args.map(normalizeCodeLocInfo));
+ };
+ let isClient = false;
+ let shouldThrow = true;
+
+ function ThrowUntilOnClient({children, message}) {
+ if (isClient && shouldThrow) {
+ Scheduler.unstable_yieldValue('throwing: ' + message);
+ throw new Error(message);
+ }
+ return children;
+ }
+
+ function StopThrowingOnClient() {
+ if (isClient) {
+ shouldThrow = false;
+ }
+ return null;
+ }
+
+ const App = () => {
+ return (
+
+ Loading...}>
+
+ one
+
+
+ two
+
+
+ three
+
+
+
+
+ );
+ };
+
+ try {
+ 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([
+ 'throwing: first error',
+ // this repeated first error is the invokeGuardedCallback throw
+ 'throwing: first error',
+ // these are actually thrown during render but no iGC repeat and no queueing as hydration errors
+ 'throwing: second error',
+ 'throwing: third error',
+ // the first hydration error cause is queued and later made recoverable
+ 'Logged recoverable error: first error',
+ // other recoverable errors are queued as hydration errors
+ 'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
+ ]);
+ // These Uncaught error calls are the error reported by the runtime (jsdom here, browser in actual use)
+ // when invokeGuardedCallback is used to replay an error in dev using event dispatching in the document
+ expect(mockError.mock.calls).toEqual([
+ // we only get one because we suppress invokeGuardedCallback after the first one when hydrating in a
+ // suspense boundary
+ ['Error: Uncaught [Error: first error]'],
+ ]);
+ mockError.mockClear();
+
+ expect(getVisibleChildren(container)).toEqual(
+
+
one
+ two
+ three
+ ,
+ );
+
+ expect(Scheduler).toFlushAndYield([]);
+ expect(mockError.mock.calls).toEqual([]);
+ } finally {
+ console.error = originalConsoleError;
+ }
+ });
+
+ // @gate experimental
+ it('does not invokeGuardedCallback for errors after a preceding fiber suspends', async () => {
+ // We can't use the toErrorDev helper here because this is async.
+ const originalConsoleError = console.error;
+ const mockError = jest.fn();
+ console.error = (...args) => {
+ if (args.length > 1) {
+ if (typeof args[1] === 'object') {
+ mockError(args[0].split('\n')[0]);
+ return;
+ }
+ }
+ mockError(...args.map(normalizeCodeLocInfo));
+ };
+ let isClient = false;
+ let shouldThrow = true;
+ let promise = null;
+ let unsuspend = null;
+ let isResolved = false;
+
+ function ComponentThatSuspendsOnClient() {
+ if (isClient && !isResolved) {
+ if (promise === null) {
+ promise = new Promise(resolve => {
+ unsuspend = () => {
+ isResolved = true;
+ resolve();
+ };
+ });
+ }
+ Scheduler.unstable_yieldValue('suspending');
+ throw promise;
+ }
+ return null;
+ }
+
+ function ThrowUntilOnClient({children, message}) {
+ if (isClient && shouldThrow) {
+ Scheduler.unstable_yieldValue('throwing: ' + message);
+ throw new Error(message);
+ }
+ return children;
+ }
+
+ function StopThrowingOnClient() {
+ if (isClient) {
+ shouldThrow = false;
+ }
+ return null;
+ }
+
+ const App = () => {
+ return (
+
+ Loading...}>
+
+
+ one
+
+
+ two
+
+
+ three
+
+
+
+
+ );
+ };
+
+ try {
+ 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([
+ 'suspending',
+ 'throwing: first error',
+ // There is no repeated first error because we already suspended and no
+ // invokeGuardedCallback is used if we are in dev
+ // or in prod there is just never an invokeGuardedCallback
+ 'throwing: second error',
+ 'throwing: third error',
+ ]);
+ expect(mockError.mock.calls).toEqual([]);
+
+ expect(getVisibleChildren(container)).toEqual(
+
+
one
+ two
+ three
+ ,
+ );
+ await unsuspend();
+ // Since our client components only throw on the very first render there are no
+ // new throws in this pass
+ expect(Scheduler).toFlushAndYield([]);
+
+ expect(mockError.mock.calls).toEqual([]);
+ } finally {
+ console.error = originalConsoleError;
+ }
+ });
+
+ // @gate experimental && __DEV__
+ it('suspending after erroring will cause errors previously queued to be silenced until the boundary resolves', async () => {
+ // We can't use the toErrorDev helper here because this is async.
+ const originalConsoleError = console.error;
+ const mockError = jest.fn();
+ console.error = (...args) => {
+ if (args.length > 1) {
+ if (typeof args[1] === 'object') {
+ mockError(args[0].split('\n')[0]);
+ return;
+ }
+ }
+ mockError(...args.map(normalizeCodeLocInfo));
+ };
+ let isClient = false;
+ let shouldThrow = true;
+ let promise = null;
+ let unsuspend = null;
+ let isResolved = false;
+
+ function ComponentThatSuspendsOnClient() {
+ if (isClient && !isResolved) {
+ if (promise === null) {
+ promise = new Promise(resolve => {
+ unsuspend = () => {
+ isResolved = true;
+ resolve();
+ };
+ });
+ }
+ Scheduler.unstable_yieldValue('suspending');
+ throw promise;
+ }
+ return null;
+ }
+
+ function ThrowUntilOnClient({children, message}) {
+ if (isClient && shouldThrow) {
+ Scheduler.unstable_yieldValue('throwing: ' + message);
+ throw new Error(message);
+ }
+ return children;
+ }
+
+ function StopThrowingOnClient() {
+ if (isClient) {
+ shouldThrow = false;
+ }
+ return null;
+ }
+
+ const App = () => {
+ return (
+
+ Loading...}>
+
+ one
+
+
+ two
+
+
+
+ three
+
+
+
+
+ );
+ };
+
+ try {
+ 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([
+ 'throwing: first error',
+ // duplicate because first error is re-done in invokeGuardedCallback
+ 'throwing: first error',
+ 'throwing: second error',
+ 'suspending',
+ 'throwing: third error',
+ ]);
+ // These Uncaught error calls are the error reported by the runtime (jsdom here, browser in actual use)
+ // when invokeGuardedCallback is used to replay an error in dev using event dispatching in the document
+ expect(mockError.mock.calls).toEqual([
+ // we only get one because we suppress invokeGuardedCallback after the first one when hydrating in a
+ // suspense boundary
+ ['Error: Uncaught [Error: first error]'],
+ ]);
+ mockError.mockClear();
+
+ expect(getVisibleChildren(container)).toEqual(
+
+
one
+ two
+ three
+ ,
+ );
+ await unsuspend();
+ // Since our client components only throw on the very first render there are no
+ // new throws in this pass
+ expect(Scheduler).toFlushAndYield([]);
+ expect(mockError.mock.calls).toEqual([]);
+ } finally {
+ console.error = originalConsoleError;
+ }
+ });
});
diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
index fe9d8dd453cd..dec9656c3383 100644
--- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
@@ -350,9 +350,7 @@ describe('ReactDOMServerPartialHydration', () => {
);
if (__DEV__) {
- const secondToLastCall =
- mockError.mock.calls[mockError.mock.calls.length - 2];
- expect(secondToLastCall).toEqual([
+ expect(mockError.mock.calls[0]).toEqual([
'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
'article',
'section',
diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js
index e0e592d32790..81a747cdc22e 100644
--- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js
+++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js
@@ -88,6 +88,10 @@ let didSuspendOrErrorDEV: boolean = false;
// Hydration errors that were thrown inside this boundary
let hydrationErrors: Array | null = null;
+export function hydrationDidSuspendOrErrorDEV() {
+ return didSuspendOrErrorDEV;
+}
+
function warnIfHydrating() {
if (__DEV__) {
if (isHydrating) {
@@ -457,6 +461,7 @@ function prepareToHydrateHostInstance(
fiber,
shouldWarnIfMismatchDev,
);
+
// TODO: Type this specific to this type of component.
fiber.updateQueue = (updatePayload: any);
// If the update payload indicates that there is a change or if there
@@ -673,7 +678,13 @@ function getIsHydrating(): boolean {
return isHydrating;
}
-export function queueHydrationError(error: mixed): void {
+function queueIfFirstHydrationError(error: mixed): void {
+ if (hydrationErrors === null) {
+ hydrationErrors = [error];
+ }
+}
+
+function queueHydrationError(error: mixed): void {
if (hydrationErrors === null) {
hydrationErrors = [error];
} else {
@@ -694,4 +705,6 @@ export {
popHydrationState,
hasUnhydratedTailNodes,
warnIfUnhydratedTailNodes,
+ queueIfFirstHydrationError,
+ queueHydrationError,
};
diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js
index a4fd5c93e22e..8b19675d2ff8 100644
--- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js
+++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js
@@ -88,6 +88,10 @@ let didSuspendOrErrorDEV: boolean = false;
// Hydration errors that were thrown inside this boundary
let hydrationErrors: Array | null = null;
+export function hydrationDidSuspendOrErrorDEV() {
+ return didSuspendOrErrorDEV;
+}
+
function warnIfHydrating() {
if (__DEV__) {
if (isHydrating) {
@@ -457,6 +461,7 @@ function prepareToHydrateHostInstance(
fiber,
shouldWarnIfMismatchDev,
);
+
// TODO: Type this specific to this type of component.
fiber.updateQueue = (updatePayload: any);
// If the update payload indicates that there is a change or if there
@@ -673,7 +678,13 @@ function getIsHydrating(): boolean {
return isHydrating;
}
-export function queueHydrationError(error: mixed): void {
+function queueIfFirstHydrationError(error: mixed): void {
+ if (hydrationErrors === null) {
+ hydrationErrors = [error];
+ }
+}
+
+function queueHydrationError(error: mixed): void {
if (hydrationErrors === null) {
hydrationErrors = [error];
} else {
@@ -694,4 +705,6 @@ export {
popHydrationState,
hasUnhydratedTailNodes,
warnIfUnhydratedTailNodes,
+ queueIfFirstHydrationError,
+ queueHydrationError,
};
diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js
index 7db27ba935d0..c77a52d6e630 100644
--- a/packages/react-reconciler/src/ReactFiberThrow.new.js
+++ b/packages/react-reconciler/src/ReactFiberThrow.new.js
@@ -84,7 +84,7 @@ import {
import {
getIsHydrating,
markDidThrowWhileHydratingDEV,
- queueHydrationError,
+ queueIfFirstHydrationError,
} from './ReactFiberHydrationContext.new';
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
@@ -542,7 +542,7 @@ function throwException(
// Even though the user may not be affected by this error, we should
// still log it so it can be fixed.
- queueHydrationError(value);
+ queueIfFirstHydrationError(value);
return;
}
} else {
diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js
index a8e75e9ce661..f98252154ef0 100644
--- a/packages/react-reconciler/src/ReactFiberThrow.old.js
+++ b/packages/react-reconciler/src/ReactFiberThrow.old.js
@@ -84,7 +84,7 @@ import {
import {
getIsHydrating,
markDidThrowWhileHydratingDEV,
- queueHydrationError,
+ queueIfFirstHydrationError,
} from './ReactFiberHydrationContext.old';
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
@@ -542,7 +542,7 @@ function throwException(
// Even though the user may not be affected by this error, we should
// still log it so it can be fixed.
- queueHydrationError(value);
+ queueIfFirstHydrationError(value);
return;
}
} else {
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js
index aa20335cbec9..d6d61a9d663d 100644
--- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js
@@ -88,6 +88,7 @@ import {
assignFiberPropertiesInDEV,
} from './ReactFiber.new';
import {isRootDehydrated} from './ReactFiberShellHydration';
+import {hydrationDidSuspendOrErrorDEV} from './ReactFiberHydrationContext.new';
import {NoMode, ProfileMode, ConcurrentMode} from './ReactTypeOfMode';
import {
HostRoot,
@@ -3001,11 +3002,13 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
return originalBeginWork(current, unitOfWork, lanes);
} catch (originalError) {
if (
- originalError !== null &&
- typeof originalError === 'object' &&
- typeof originalError.then === 'function'
+ hydrationDidSuspendOrErrorDEV() ||
+ (originalError !== null &&
+ typeof originalError === 'object' &&
+ typeof originalError.then === 'function')
) {
- // Don't replay promises. Treat everything else like an error.
+ // Don't replay promises.
+ // Don't replay when hydration has suspended or errored already
throw originalError;
}
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js
index e2c3a76c7fc2..2978f08ab2c2 100644
--- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js
@@ -88,6 +88,7 @@ import {
assignFiberPropertiesInDEV,
} from './ReactFiber.old';
import {isRootDehydrated} from './ReactFiberShellHydration';
+import {hydrationDidSuspendOrErrorDEV} from './ReactFiberHydrationContext.old';
import {NoMode, ProfileMode, ConcurrentMode} from './ReactTypeOfMode';
import {
HostRoot,
@@ -3001,11 +3002,13 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
return originalBeginWork(current, unitOfWork, lanes);
} catch (originalError) {
if (
- originalError !== null &&
- typeof originalError === 'object' &&
- typeof originalError.then === 'function'
+ hydrationDidSuspendOrErrorDEV() ||
+ (originalError !== null &&
+ typeof originalError === 'object' &&
+ typeof originalError.then === 'function')
) {
- // Don't replay promises. Treat everything else like an error.
+ // Don't replay promises.
+ // Don't replay when hydration has suspended or errored already
throw originalError;
}