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
Andrew Clark committed
Nov 7, 2025 at 15:18 UTC
717e70843e68db648d2fc75c57d1a61465a9f7f9
3 files changed
+119
-2
packages/react-reconciler/src/ReactFiberThrow.js
+19
-1
@@ -12,7 +12,10 @@ import type {Lane, Lanes} from './ReactFiberLane';
12
import type {CapturedValue} from './ReactCapturedValue';
13
import type {Update} from './ReactFiberClassUpdateQueue';
14
import type {Wakeable} from 'shared/ReactTypes';
15
-import type {OffscreenQueue} from './ReactFiberOffscreenComponent';
15
+import type {
16
+ OffscreenQueue,
17
+ OffscreenState,
18
+} from './ReactFiberOffscreenComponent';
19
import type {RetryQueue} from './ReactFiberSuspenseComponent';
20
21
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
@@ -676,6 +679,21 @@ function throwException(
679
return false;
680
}
681
break;
682
+ case OffscreenComponent: {
683
+ const offscreenState: OffscreenState | null =
684
+ (workInProgress.memoizedState: any);
685
+ if (offscreenState !== null) {
686
+ // An error was thrown inside a hidden Offscreen boundary. This should
687
+ // not be allowed to escape into the visible part of the UI. Mark the
688
+ // boundary with ShouldCapture to abort the ongoing prerendering
689
+ // attempt. This is the same flag would be set if something were to
690
+ // suspend. It will be cleared the next time the boundary
691
+ // is attempted.
692
+ workInProgress.flags |= ShouldCapture;
693
+ return false;
694
+ }
695
+ break;
696
+ }
697
default:
698
break;
699
}
packages/react-reconciler/src/__tests__/ActivityErrorHandling-test.js
new
+99
@@ -0,0 +1,99 @@
1
+let React;
2
+let ReactNoop;
3
+let Scheduler;
4
+let act;
5
+let Activity;
6
+let useState;
7
+let assertLog;
8
+
9
+describe('Activity error handling', () => {
10
+ beforeEach(() => {
11
+ jest.resetModules();
12
+
13
+ React = require('react');
14
+ ReactNoop = require('react-noop-renderer');
15
+ Scheduler = require('scheduler');
16
+ act = require('internal-test-utils').act;
17
+ Activity = React.Activity;
18
+ useState = React.useState;
19
+
20
+ const InternalTestUtils = require('internal-test-utils');
21
+ assertLog = InternalTestUtils.assertLog;
22
+ });
23
+
24
+ function Text({text}) {
25
+ Scheduler.log(text);
26
+ return text;
27
+ }
28
+
29
+ // @gate enableActivity
30
+ it(
31
+ 'errors inside a hidden Activity do not escape in the visible part ' +
32
+ 'of the UI',
33
+ async () => {
34
+ class ErrorBoundary extends React.Component {
35
+ state = {error: null};
36
+ static getDerivedStateFromError(error) {
37
+ return {error};
38
+ }
39
+ render() {
40
+ if (this.state.error) {
41
+ return (
42
+ <Text text={`Caught an error: ${this.state.error.message}`} />
43
+ );
44
+ }
45
+ return this.props.children;
46
+ }
47
+ }
48
+
49
+ function Throws() {
50
+ throw new Error('Oops!');
51
+ }
52
+
53
+ let setShowMore;
54
+ function App({content, more}) {
55
+ const [showMore, _setShowMore] = useState(false);
56
+ setShowMore = _setShowMore;
57
+ return (
58
+ <>
59
+ <div>{content}</div>
60
+ <div>
61
+ <ErrorBoundary>
62
+ <Activity mode={showMore ? 'visible' : 'hidden'}>
63
+ {more}
64
+ </Activity>
65
+ </ErrorBoundary>
66
+ </div>
67
+ </>
68
+ );
69
+ }
70
+
71
+ await act(() =>
72
+ ReactNoop.render(
73
+ <App content={<Text text="Visible" />} more={<Throws />} />,
74
+ ),
75
+ );
76
+
77
+ // Initial render. An error is thrown when prerendering the hidden
78
+ // Activity boundary, but since it's hidden, the UI doesn't observe it.
79
+ assertLog(['Visible']);
80
+ expect(ReactNoop).toMatchRenderedOutput(
81
+ <>
82
+ <div>Visible</div>
83
+ <div />
84
+ </>,
85
+ );
86
+
87
+ // Once the Activity boundary is revealed, the error is thrown and
88
+ // captured by the outer ErrorBoundary.
89
+ await act(() => setShowMore(true));
90
+ assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']);
91
+ expect(ReactNoop).toMatchRenderedOutput(
92
+ <>
93
+ <div>Visible</div>
94
+ <div>Caught an error: Oops!</div>
95
+ </>,
96
+ );
97
+ },
98
+ );
99
+});
packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js
+1
-1
@@ -403,7 +403,7 @@ describe('ReactFreshIntegration', () => {
403
await patch(code);
404
});
405
406
- // @gate __DEV__ && enableActivity && enableScopeAPI
406
+ // @gate __DEV__ && enableActivity
407
it('ignores ref for Scope in hidden subtree', async () => {
408
const code = `
409
import {