Skip to content

Commit 717e708

Browse files
authored
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: - #35073
1 parent a10ff9c commit 717e708

File tree

3 files changed

+119
-2
lines changed

3 files changed

+119
-2
lines changed

packages/react-reconciler/src/ReactFiberThrow.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import type {Lane, Lanes} from './ReactFiberLane';
1212
import type {CapturedValue} from './ReactCapturedValue';
1313
import type {Update} from './ReactFiberClassUpdateQueue';
1414
import type {Wakeable} from 'shared/ReactTypes';
15-
import type {OffscreenQueue} from './ReactFiberOffscreenComponent';
15+
import type {
16+
OffscreenQueue,
17+
OffscreenState,
18+
} from './ReactFiberOffscreenComponent';
1619
import type {RetryQueue} from './ReactFiberSuspenseComponent';
1720

1821
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
@@ -676,6 +679,21 @@ function throwException(
676679
return false;
677680
}
678681
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+
}
679697
default:
680698
break;
681699
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ describe('ReactFreshIntegration', () => {
403403
await patch(code);
404404
});
405405

406-
// @gate __DEV__ && enableActivity && enableScopeAPI
406+
// @gate __DEV__ && enableActivity
407407
it('ignores ref for Scope in hidden subtree', async () => {
408408
const code = `
409409
import {

0 commit comments

Comments
 (0)