diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index d1a11a1b0d04e..0ed934f00ebd6 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -1445,7 +1445,8 @@ export function renderDidSuspend(): void { export function renderDidSuspendDelayIfPossible(): void { if ( workInProgressRootExitStatus === RootIncomplete || - workInProgressRootExitStatus === RootSuspended + workInProgressRootExitStatus === RootSuspended || + workInProgressRootExitStatus === RootErrored ) { workInProgressRootExitStatus = RootSuspendedWithDelay; } @@ -1469,7 +1470,7 @@ export function renderDidSuspendDelayIfPossible(): void { } export function renderDidError() { - if (workInProgressRootExitStatus !== RootCompleted) { + if (workInProgressRootExitStatus !== RootSuspendedWithDelay) { workInProgressRootExitStatus = RootErrored; } } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 233ec1a00d9ae..438dce8b13fbf 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -1445,7 +1445,8 @@ export function renderDidSuspend(): void { export function renderDidSuspendDelayIfPossible(): void { if ( workInProgressRootExitStatus === RootIncomplete || - workInProgressRootExitStatus === RootSuspended + workInProgressRootExitStatus === RootSuspended || + workInProgressRootExitStatus === RootErrored ) { workInProgressRootExitStatus = RootSuspendedWithDelay; } @@ -1469,7 +1470,7 @@ export function renderDidSuspendDelayIfPossible(): void { } export function renderDidError() { - if (workInProgressRootExitStatus !== RootCompleted) { + if (workInProgressRootExitStatus !== RootSuspendedWithDelay) { workInProgressRootExitStatus = RootErrored; } } diff --git a/packages/react-reconciler/src/__tests__/ReactConcurrentErrorRecovery-test.js b/packages/react-reconciler/src/__tests__/ReactConcurrentErrorRecovery-test.js new file mode 100644 index 0000000000000..b968826845923 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactConcurrentErrorRecovery-test.js @@ -0,0 +1,401 @@ +let React; +let ReactNoop; +let Scheduler; +let act; +let Suspense; +let getCacheForType; +let startTransition; + +let caches; +let seededCache; + +describe('ReactConcurrentErrorRecovery', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + act = require('jest-react').act; + Suspense = React.Suspense; + startTransition = React.startTransition; + + getCacheForType = React.unstable_getCacheForType; + + caches = []; + seededCache = null; + }); + + function createTextCache() { + if (seededCache !== null) { + // Trick to seed a cache before it exists. + // TODO: Need a built-in API to seed data before the initial render (i.e. + // not a refresh because nothing has mounted yet). + const cache = seededCache; + seededCache = null; + return cache; + } + + const data = new Map(); + const version = caches.length + 1; + const cache = { + version, + data, + resolve(text) { + const record = data.get(text); + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } + }, + reject(text, error) { + const record = data.get(text); + if (record === undefined) { + const newRecord = { + status: 'rejected', + value: error, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'rejected'; + record.value = error; + thenable.pings.forEach(t => t()); + } + }, + }; + caches.push(cache); + return cache; + } + + function readText(text) { + const textCache = getCacheForType(createTextCache); + const record = textCache.data.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + Scheduler.unstable_yieldValue(`Suspend! [${text}]`); + throw record.value; + case 'rejected': + Scheduler.unstable_yieldValue(`Error! [${text}]`); + throw record.value; + case 'resolved': + return textCache.version; + } + } else { + Scheduler.unstable_yieldValue(`Suspend! [${text}]`); + + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.data.set(text, newRecord); + + throw thenable; + } + } + + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + + function AsyncText({text, showVersion}) { + const version = readText(text); + const fullText = showVersion ? `${text} [v${version}]` : text; + Scheduler.unstable_yieldValue(fullText); + return fullText; + } + + function seedNextTextCache(text) { + if (seededCache === null) { + seededCache = createTextCache(); + } + seededCache.resolve(text); + } + + function resolveMostRecentTextCache(text) { + if (caches.length === 0) { + throw Error('Cache does not exist.'); + } else { + // Resolve the most recently created cache. An older cache can by + // resolved with `caches[index].resolve(text)`. + caches[caches.length - 1].resolve(text); + } + } + + const resolveText = resolveMostRecentTextCache; + + function rejectMostRecentTextCache(text, error) { + if (caches.length === 0) { + throw Error('Cache does not exist.'); + } else { + // Resolve the most recently created cache. An older cache can by + // resolved with `caches[index].reject(text, error)`. + caches[caches.length - 1].reject(text, error); + } + } + + const rejectText = rejectMostRecentTextCache; + + // @gate enableCache + test('errors during a refresh transition should not force fallbacks to display (suspend then error)', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error !== null) { + return ; + } + return this.props.children; + } + } + + function App({step}) { + return ( + <> + }> + + + + + }> + + + + + + ); + } + + // Initial render + const root = ReactNoop.createRoot(); + seedNextTextCache('A1'); + seedNextTextCache('B1'); + await act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A1', 'B1']); + expect(root).toMatchRenderedOutput('A1B1'); + + // Start a refresh transition + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded([ + 'Suspend! [A2]', + 'Loading...', + 'Suspend! [B2]', + 'Loading...', + ]); + // Because this is a refresh, we don't switch to a fallback + expect(root).toMatchRenderedOutput('A1B1'); + + // B fails to load. + await act(async () => { + rejectText('B2', new Error('Oops!')); + }); + + // Because we're still suspended on A, we can't show an error boundary. We + // should wait for A to resolve. + if (gate(flags => flags.replayFailedUnitOfWorkWithInvokeGuardedCallback)) { + expect(Scheduler).toHaveYielded([ + 'Suspend! [A2]', + 'Loading...', + + 'Error! [B2]', + // This extra log happens when we replay the error + // in invokeGuardedCallback + 'Error! [B2]', + 'Oops!', + ]); + } else { + expect(Scheduler).toHaveYielded([ + 'Suspend! [A2]', + 'Loading...', + 'Error! [B2]', + 'Oops!', + ]); + } + // Remain on previous screen. + expect(root).toMatchRenderedOutput('A1B1'); + + // A finishes loading. + await act(async () => { + resolveText('A2'); + }); + if (gate(flags => flags.replayFailedUnitOfWorkWithInvokeGuardedCallback)) { + expect(Scheduler).toHaveYielded([ + 'A2', + 'Error! [B2]', + // This extra log happens when we replay the error + // in invokeGuardedCallback + 'Error! [B2]', + 'Oops!', + + 'A2', + 'Error! [B2]', + // This extra log happens when we replay the error + // in invokeGuardedCallback + 'Error! [B2]', + 'Oops!', + ]); + } else { + expect(Scheduler).toHaveYielded([ + 'A2', + 'Error! [B2]', + 'Oops!', + + 'A2', + 'Error! [B2]', + 'Oops!', + ]); + } + // Now we can show the error boundary that's wrapped around B. + expect(root).toMatchRenderedOutput('A2Oops!'); + }); + + // @gate enableCache + test('errors during a refresh transition should not force fallbacks to display (error then suspend)', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error !== null) { + return ; + } + return this.props.children; + } + } + + function App({step}) { + return ( + <> + }> + + + + + }> + + + + + + ); + } + + // Initial render + const root = ReactNoop.createRoot(); + seedNextTextCache('A1'); + seedNextTextCache('B1'); + await act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A1', 'B1']); + expect(root).toMatchRenderedOutput('A1B1'); + + // Start a refresh transition + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded([ + 'Suspend! [A2]', + 'Loading...', + 'Suspend! [B2]', + 'Loading...', + ]); + // Because this is a refresh, we don't switch to a fallback + expect(root).toMatchRenderedOutput('A1B1'); + + // A fails to load. + await act(async () => { + rejectText('A2', new Error('Oops!')); + }); + + // Because we're still suspended on B, we can't show an error boundary. We + // should wait for B to resolve. + if (gate(flags => flags.replayFailedUnitOfWorkWithInvokeGuardedCallback)) { + expect(Scheduler).toHaveYielded([ + 'Error! [A2]', + // This extra log happens when we replay the error + // in invokeGuardedCallback + 'Error! [A2]', + 'Oops!', + + 'Suspend! [B2]', + 'Loading...', + ]); + } else { + expect(Scheduler).toHaveYielded([ + 'Error! [A2]', + 'Oops!', + 'Suspend! [B2]', + 'Loading...', + ]); + } + // Remain on previous screen. + expect(root).toMatchRenderedOutput('A1B1'); + + // B finishes loading. + await act(async () => { + resolveText('B2'); + }); + if (gate(flags => flags.replayFailedUnitOfWorkWithInvokeGuardedCallback)) { + expect(Scheduler).toHaveYielded([ + 'Error! [A2]', + // This extra log happens when we replay the error + // in invokeGuardedCallback + 'Error! [A2]', + 'Oops!', + 'B2', + + 'Error! [A2]', + // This extra log happens when we replay the error + // in invokeGuardedCallback + 'Error! [A2]', + 'Oops!', + 'B2', + ]); + } else { + expect(Scheduler).toHaveYielded([ + 'Error! [A2]', + 'Oops!', + 'B2', + + 'Error! [A2]', + 'Oops!', + 'B2', + ]); + } + // Now we can show the error boundary that's wrapped around B. + expect(root).toMatchRenderedOutput('Oops!B2'); + }); +});