@samitouri / QOS-React-1 / commits / 717e70843e

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 {