diff --git a/packages/react-reconciler/src/ReactFiberThrow.js b/packages/react-reconciler/src/ReactFiberThrow.js index 0216363631..d04bff34fa 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.js +++ b/packages/react-reconciler/src/ReactFiberThrow.js @@ -12,7 +12,10 @@ import type {Lane, Lanes} from './ReactFiberLane'; import type {CapturedValue} from './ReactCapturedValue'; import type {Update} from './ReactFiberClassUpdateQueue'; import type {Wakeable} from 'shared/ReactTypes'; -import type {OffscreenQueue} from './ReactFiberOffscreenComponent'; +import type { + OffscreenQueue, + OffscreenState, +} from './ReactFiberOffscreenComponent'; import type {RetryQueue} from './ReactFiberSuspenseComponent'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; @@ -676,6 +679,21 @@ function throwException( return false; } break; + case OffscreenComponent: { + const offscreenState: OffscreenState | null = + (workInProgress.memoizedState: any); + if (offscreenState !== null) { + // An error was thrown inside a hidden Offscreen boundary. This should + // not be allowed to escape into the visible part of the UI. Mark the + // boundary with ShouldCapture to abort the ongoing prerendering + // attempt. This is the same flag would be set if something were to + // suspend. It will be cleared the next time the boundary + // is attempted. + workInProgress.flags |= ShouldCapture; + return false; + } + break; + } default: break; } diff --git a/packages/react-reconciler/src/__tests__/ActivityErrorHandling-test.js b/packages/react-reconciler/src/__tests__/ActivityErrorHandling-test.js new file mode 100644 index 0000000000..ac6d1f9b88 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ActivityErrorHandling-test.js @@ -0,0 +1,99 @@ +let React; +let ReactNoop; +let Scheduler; +let act; +let Activity; +let useState; +let assertLog; + +describe('Activity error handling', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + act = require('internal-test-utils').act; + Activity = React.Activity; + useState = React.useState; + + const InternalTestUtils = require('internal-test-utils'); + assertLog = InternalTestUtils.assertLog; + }); + + function Text({text}) { + Scheduler.log(text); + return text; + } + + // @gate enableActivity + it( + 'errors inside a hidden Activity do not escape in the visible part ' + + 'of the UI', + async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return ( + + ); + } + return this.props.children; + } + } + + function Throws() { + throw new Error('Oops!'); + } + + let setShowMore; + function App({content, more}) { + const [showMore, _setShowMore] = useState(false); + setShowMore = _setShowMore; + return ( + <> +
{content}
+
+ + + {more} + + +
+ + ); + } + + await act(() => + ReactNoop.render( + } more={} />, + ), + ); + + // Initial render. An error is thrown when prerendering the hidden + // Activity boundary, but since it's hidden, the UI doesn't observe it. + assertLog(['Visible']); + expect(ReactNoop).toMatchRenderedOutput( + <> +
Visible
+
+ , + ); + + // Once the Activity boundary is revealed, the error is thrown and + // captured by the outer ErrorBoundary. + await act(() => setShowMore(true)); + assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']); + expect(ReactNoop).toMatchRenderedOutput( + <> +
Visible
+
Caught an error: Oops!
+ , + ); + }, + ); +}); diff --git a/packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js b/packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js index 0a9f1eb9ba..d6e1bef955 100644 --- a/packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js +++ b/packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js @@ -403,7 +403,7 @@ describe('ReactFreshIntegration', () => { await patch(code); }); - // @gate __DEV__ && enableActivity && enableScopeAPI + // @gate __DEV__ && enableActivity it('ignores ref for Scope in hidden subtree', async () => { const code = ` import {