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( +
+

A

+

world

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

+

world

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

A

+

world

+
, + ); + }); + // @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; }