From 9bff373ba8e7e257b1c0ddc54217cd6ea93e6dc6 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 29 Mar 2024 13:37:36 -0400 Subject: [PATCH 1/2] Don't render error recovery phase in error boundaries --- packages/react-reconciler/src/ReactFiberThrow.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react-reconciler/src/ReactFiberThrow.js b/packages/react-reconciler/src/ReactFiberThrow.js index ce18234fd37ca..3bb54f73e80d6 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.js +++ b/packages/react-reconciler/src/ReactFiberThrow.js @@ -574,6 +574,13 @@ function throwException( return false; } case ClassComponent: + if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) { + // If we're hydrating and got here, it means that we didn't find a suspense + // boundary above so it's a root error. In this case we shouldn't let the + // error boundary capture it because it'll just try to hydrate the error state. + // Instead we let it bubble to the root and let the recover pass handle it. + break; + } // Capture and retry const errorInfo = value; const ctor = workInProgress.type; From 08533764324af9ada25a84b1316b5ffd23e00f7c Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 29 Mar 2024 14:47:35 -0400 Subject: [PATCH 2/2] Add tests for errors in root error boundary - Server and client throws - Only client throws - Only hydration pass of client throws but recovers --- .../ReactDOMFizzShellHydration-test.js | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js index 3c2260d83bd6f..756d5d455c9d2 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js @@ -314,4 +314,229 @@ describe('ReactDOMFizzShellHydration', () => { 'RangeError: Maximum call stack size exceeded', ); }); + + it('client renders when an error is thrown in an error boundary', async () => { + function Throws() { + throw new Error('plain error'); + } + + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return
Caught an error: {this.state.error.message}
; + } + return this.props.children; + } + } + + function App() { + return ( + + + + ); + } + + // Server render + let shellError; + try { + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(, { + onError(error) { + Scheduler.log('onError: ' + error.message); + }, + }); + pipe(writable); + }); + } catch (x) { + shellError = x; + } + expect(shellError).toEqual( + expect.objectContaining({message: 'plain error'}), + ); + assertLog(['onError: plain error']); + + function ErroredApp() { + return loading; + } + + // Reset test environment + buffer = ''; + hasErrored = false; + writable = new Stream.PassThrough(); + writable.setEncoding('utf8'); + writable.on('data', chunk => { + buffer += chunk; + }); + writable.on('error', error => { + hasErrored = true; + fatalError = error; + }); + + // The Server errored at the shell. The recommended approach is to render a + // fallback loading state, which can then be hydrated with a mismatch. + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expect(container.innerHTML).toBe('loading'); + + // Hydration suspends because the data for the shell hasn't loaded yet + await clientAct(async () => { + ReactDOMClient.hydrateRoot(container, , { + onCaughtError(error) { + Scheduler.log('onCaughtError: ' + error.message); + }, + onUncaughtError(error) { + Scheduler.log('onUncaughtError: ' + error.message); + }, + onRecoverableError(error) { + Scheduler.log('onRecoverableError: ' + error.message); + }, + }); + }); + + assertLog(['onCaughtError: plain error']); + expect(container.textContent).toBe('Caught an error: plain error'); + }); + + it('client renders when a client error is thrown in an error boundary', async () => { + let isClient = false; + + function Throws() { + if (isClient) { + throw new Error('plain error'); + } + return
Hello world
; + } + + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return
Caught an error: {this.state.error.message}
; + } + return this.props.children; + } + } + + function App() { + return ( + + + + ); + } + + // Server render + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(, { + onError(error) { + Scheduler.log('onError: ' + error.message); + }, + }); + pipe(writable); + }); + assertLog([]); + + expect(container.innerHTML).toBe('
Hello world
'); + + isClient = true; + + // Hydration suspends because the data for the shell hasn't loaded yet + await clientAct(async () => { + ReactDOMClient.hydrateRoot(container, , { + onCaughtError(error) { + Scheduler.log('onCaughtError: ' + error.message); + }, + onUncaughtError(error) { + Scheduler.log('onUncaughtError: ' + error.message); + }, + onRecoverableError(error) { + Scheduler.log('onRecoverableError: ' + error.message); + }, + }); + }); + + assertLog(['onCaughtError: plain error']); + expect(container.textContent).toBe('Caught an error: plain error'); + }); + + it('client renders when a hydration pass error is thrown in an error boundary', async () => { + let isClient = false; + let isFirst = true; + + function Throws() { + if (isClient && isFirst) { + isFirst = false; // simulate a hydration or concurrent error + throw new Error('plain error'); + } + return
Hello world
; + } + + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return
Caught an error: {this.state.error.message}
; + } + return this.props.children; + } + } + + function App() { + return ( + + + + ); + } + + // Server render + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(, { + onError(error) { + Scheduler.log('onError: ' + error.message); + }, + }); + pipe(writable); + }); + assertLog([]); + + expect(container.innerHTML).toBe('
Hello world
'); + + isClient = true; + + // Hydration suspends because the data for the shell hasn't loaded yet + await clientAct(async () => { + ReactDOMClient.hydrateRoot(container, , { + onCaughtError(error) { + Scheduler.log('onCaughtError: ' + error.message); + }, + onUncaughtError(error) { + Scheduler.log('onUncaughtError: ' + error.message); + }, + onRecoverableError(error) { + Scheduler.log('onRecoverableError: ' + error.message); + }, + }); + }); + + assertLog([ + 'onRecoverableError: plain error', + 'onRecoverableError: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ]); + expect(container.textContent).toBe('Hello world'); + }); });