From 710429e5aab0c3c9326f141e1cf47ae7ce6f440f Mon Sep 17 00:00:00 2001 From: Josh Story Date: Mon, 2 May 2022 15:54:00 -0700 Subject: [PATCH] Do not replay erroring beginWork with invokeGuardedCallback when suspended or previously errored When hydrating a suspense boundary an error or a suspending fiber can often lead to a cascade of hydration errors. While in many cases these errors are simply discarded (e.g. when teh root does not commit and we fall back to client rendering) the use of invokeGuardedCallback can lead to many of these errors appearing as uncaught in the browser console. This change avoids error replaying using invokeGuardedCallback when we are hydrating a suspense boundary and have either already suspended or we have one previous error which was replayed. --- .../src/__tests__/ReactDOMFizzServer-test.js | 441 +++++++++++++++++- .../src/ReactFiberHydrationContext.new.js | 7 + .../src/ReactFiberHydrationContext.old.js | 7 + .../src/ReactFiberWorkLoop.new.js | 11 +- .../src/ReactFiberWorkLoop.old.js | 11 +- 5 files changed, 468 insertions(+), 9 deletions(-) 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( +
+

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 => { @@ -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; }