/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @emails react-core * @jest-environment node */ 'use strict'; let PropTypes; let React; let ReactNoop; let Scheduler; let act; let assertLog; let waitForAll; let waitFor; let waitForThrow; let assertConsoleErrorDev; describe('ReactIncrementalErrorHandling', () => { beforeEach(() => { jest.resetModules(); PropTypes = require('prop-types'); React = require('react'); ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); act = require('internal-test-utils').act; assertConsoleErrorDev = require('internal-test-utils').assertConsoleErrorDev; const InternalTestUtils = require('internal-test-utils'); assertLog = InternalTestUtils.assertLog; waitForAll = InternalTestUtils.waitForAll; waitFor = InternalTestUtils.waitFor; waitForThrow = InternalTestUtils.waitForThrow; }); afterEach(() => { jest.restoreAllMocks(); }); function normalizeCodeLocInfo(str) { return ( str && str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) { return '\n in ' + name + ' (at **)'; }) ); } // Note: This is based on a similar component we use in www. We can delete // once the extra div wrapper is no longer necessary. function LegacyHiddenDiv({children, mode}) { return ( ); } it('recovers from errors asynchronously', async () => { class ErrorBoundary extends React.Component { state = {error: null}; static getDerivedStateFromError(error) { Scheduler.log('getDerivedStateFromError'); return {error}; } render() { if (this.state.error) { Scheduler.log('ErrorBoundary (catch)'); return ; } Scheduler.log('ErrorBoundary (try)'); return this.props.children; } } function ErrorMessage({error}) { Scheduler.log('ErrorMessage'); return ; } function Indirection({children}) { Scheduler.log('Indirection'); return children || null; } function BadRender({unused}) { Scheduler.log('throw'); throw new Error('oops!'); } React.startTransition(() => { ReactNoop.render( <> , ); }); // Start rendering asynchronously await waitFor([ 'ErrorBoundary (try)', 'Indirection', 'Indirection', 'Indirection', // An error is thrown. React keeps rendering asynchronously. 'throw', // Call getDerivedStateFromError and re-render the error boundary, this // time rendering an error message. 'getDerivedStateFromError', 'ErrorBoundary (catch)', 'ErrorMessage', ]); expect(ReactNoop).toMatchRenderedOutput(null); // The work loop unwound to the nearest error boundary. Continue rendering // asynchronously. await waitFor(['Indirection']); // Since the error was thrown during an async render, React won't commit the // result yet. After render we render the last child, React will attempt to // render again, synchronously, just in case that happens to fix the error // (i.e. as in the case of a data race). Flush just one more unit of work to // demonstrate that this render is synchronous. expect(ReactNoop.flushNextYield()).toEqual([ 'Indirection', 'ErrorBoundary (try)', 'Indirection', 'Indirection', 'Indirection', // The error was thrown again. This time, React will actually commit // the result. 'throw', 'getDerivedStateFromError', 'ErrorBoundary (catch)', 'ErrorMessage', 'Indirection', 'Indirection', ]); expect(ReactNoop).toMatchRenderedOutput( , ); }); it('recovers from errors asynchronously (legacy, no getDerivedStateFromError)', async () => { class ErrorBoundary extends React.Component { state = {error: null}; componentDidCatch(error) { Scheduler.log('componentDidCatch'); this.setState({error}); } render() { if (this.state.error) { Scheduler.log('ErrorBoundary (catch)'); return ; } Scheduler.log('ErrorBoundary (try)'); return this.props.children; } } function ErrorMessage({error}) { Scheduler.log('ErrorMessage'); return ; } function Indirection({children}) { Scheduler.log('Indirection'); return children || null; } function BadRender({unused}) { Scheduler.log('throw'); throw new Error('oops!'); } React.startTransition(() => { ReactNoop.render( <> , ); }); // Start rendering asynchronously await waitFor([ 'ErrorBoundary (try)', 'Indirection', 'Indirection', 'Indirection', // An error is thrown. React keeps rendering asynchronously. 'throw', ]); // Still rendering async... await waitFor(['Indirection']); await waitFor([ 'Indirection', // Now that the tree is complete, and there's no remaining work, React // reverts to legacy mode to retry one more time before handling the error. 'ErrorBoundary (try)', 'Indirection', 'Indirection', 'Indirection', // The error was thrown again. Now we can handle it. 'throw', 'Indirection', 'Indirection', 'componentDidCatch', 'ErrorBoundary (catch)', 'ErrorMessage', ]); expect(ReactNoop).toMatchRenderedOutput( , ); }); it("retries at a lower priority if there's additional pending work", async () => { function App(props) { if (props.isBroken) { Scheduler.log('error'); throw new Error('Oops!'); } Scheduler.log('success'); return ; } function onCommit() { Scheduler.log('commit'); } React.startTransition(() => { ReactNoop.render(, onCommit); }); await waitFor(['error']); React.startTransition(() => { // This update is in a separate batch ReactNoop.render(, onCommit); }); // React will try to recover by rendering all the pending updates in a // single batch, synchronously. This time it succeeds. // // This tells Scheduler to render a single unit of work. Because the render // to recover from the error is synchronous, this should be enough to // finish the rest of the work. Scheduler.unstable_flushNumberOfYields(1); assertLog([ 'success', // Nothing commits until the second update completes. 'commit', 'commit', ]); assertConsoleErrorDev([ 'Error: There was an error during concurrent rendering but React was able to recover by instead synchronously rendering the entire root.' + '\n in ', ]); expect(ReactNoop).toMatchRenderedOutput( , ); }); // @gate enableLegacyHidden it('does not include offscreen work when retrying after an error', async () => { function App(props) { if (props.isBroken) { Scheduler.log('error'); throw new Error('Oops!'); } Scheduler.log('success'); return ( <> Everything is fine
Offscreen content
); } function onCommit() { Scheduler.log('commit'); } React.startTransition(() => { ReactNoop.render(, onCommit); }); await waitFor(['error']); expect(ReactNoop).toMatchRenderedOutput(null); React.startTransition(() => { // This update is in a separate batch ReactNoop.render(, onCommit); }); // React will try to recover by rendering all the pending updates in a // single batch, synchronously. This time it succeeds. // // This tells Scheduler to render a single unit of work. Because the render // to recover from the error is synchronous, this should be enough to // finish the rest of the work. Scheduler.unstable_flushNumberOfYields(1); assertLog([ 'success', // Nothing commits until the second update completes. 'commit', 'commit', ]); assertConsoleErrorDev([ 'Error: There was an error during concurrent rendering but React was able to recover by instead synchronously rendering the entire root.' + '\n in ', ]); // This should not include the offscreen content expect(ReactNoop).toMatchRenderedOutput( <> Everything is fine