Repository: facebook/react
Pull request: Fix: Errors should not escape a hidden Activity

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

Changed files:
filename: packages/react-reconciler/src/ReactFiberThrow.js
patch: @@ -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;
     }

filename: packages/react-reconciler/src/__tests__/ActivityErrorHandling-test.js
patch: @@ -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>
+        </>,
+      );
+    },
+  );
+});

filename: packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js
patch: @@ -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 {