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; }