Fix: Errors should not escape a hidden Activity (#35074)

If an error is thrown inside a hidden Activity, it should not escape
into the visible part of the UI. Conceptually, a hidden Activity
boundary is not part of the current UI; it's the same as an unmounted
tree, except for the fact that the state will be restored if it's later
revealed.

Fixes:
- https://github.com/facebook/react/issues/35073
This commit is contained in:
Andrew Clark
2025-11-07 15:18:24 -08:00
committed by GitHub
parent a10ff9c857
commit 717e70843e
3 changed files with 119 additions and 2 deletions

View File

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

View File

@@ -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 (
<Text text={`Caught an error: ${this.state.error.message}`} />
);
}
return this.props.children;
}
}
function Throws() {
throw new Error('Oops!');
}
let setShowMore;
function App({content, more}) {
const [showMore, _setShowMore] = useState(false);
setShowMore = _setShowMore;
return (
<>
<div>{content}</div>
<div>
<ErrorBoundary>
<Activity mode={showMore ? 'visible' : 'hidden'}>
{more}
</Activity>
</ErrorBoundary>
</div>
</>
);
}
await act(() =>
ReactNoop.render(
<App content={<Text text="Visible" />} more={<Throws />} />,
),
);
// 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(
<>
<div>Visible</div>
<div />
</>,
);
// 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(
<>
<div>Visible</div>
<div>Caught an error: Oops!</div>
</>,
);
},
);
});

View File

@@ -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 {