diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index 72ed9f8775dab..105fe10eef574 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -2850,8 +2850,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 => {
@@ -3092,4 +3168,367 @@ 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',
+ // all hydration errors are still queued
+ 'Logged recoverable error: first error',
+ 'Logged recoverable error: second error',
+ 'Logged recoverable error: third 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-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js
index e0e592d32790f..636a467475df4 100644
--- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js
+++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js
@@ -104,6 +104,13 @@ export function markDidThrowWhileHydratingDEV() {
}
}
+export function didSuspendOrErrorWhileHydratingDEV() {
+ if (__DEV__) {
+ return didSuspendOrErrorDEV;
+ }
+ return false;
+}
+
function enterHydrationState(fiber: Fiber): boolean {
if (!supportsHydration) {
return false;
diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js
index a4fd5c93e22ea..3befb348c05ab 100644
--- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js
+++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js
@@ -104,6 +104,13 @@ export function markDidThrowWhileHydratingDEV() {
}
}
+export function didSuspendOrErrorWhileHydratingDEV() {
+ if (__DEV__) {
+ return didSuspendOrErrorDEV;
+ }
+ return false;
+}
+
function enterHydrationState(fiber: Fiber): boolean {
if (!supportsHydration) {
return false;
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js
index 89c880d868554..b4343485259ae 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 {didSuspendOrErrorWhileHydratingDEV} 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'
+ didSuspendOrErrorWhileHydratingDEV() ||
+ (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 errors if we are hydrating and have already suspended or handled an error
throw originalError;
}
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js
index 2f5dbe8530ce5..58757a37367ce 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 {didSuspendOrErrorWhileHydratingDEV} 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'
+ didSuspendOrErrorWhileHydratingDEV() ||
+ (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 errors if we are hydrating and have already suspended or handled an error
throw originalError;
}