Repository: facebook/react
Pull request: Offscreen add attach

`Offscreen.attach` is imperative API to signal to Offscreen that its updates should be high priority and effects should be mounted. Coupled with `Offscreen.detach` it gives ability to manually control Offscreen. Unlike with mode `visible` and `hidden`, it is developers job to make sure contents of Offscreen are not visible to users.
`Offscreen.attach` only works if mode is `manual`.

Example uses:
```jsx
let offscreenRef = useRef(null);
<Offscreen mode={'manual'} ref={offscreenRef)}>
  <Child />
</Offscreen>

// ------

// Offscreen is attached by default. 
// For example user scrolls away and Offscreen subtree is not visible anymore.
offscreenRef.current.detach();


// User scrolls back and Offscreen subtree is visible again.
offscreenRef.current.attach();
```





Changed files:
filename: packages/react-reconciler/src/ReactFiber.js
patch: @@ -107,7 +107,10 @@ import {
   REACT_TRACING_MARKER_TYPE,
 } from 'shared/ReactSymbols';
 import {TransitionTracingMarker} from './ReactFiberTracingMarkerComponent';
-import {detachOffscreenInstance} from './ReactFiberCommitWork';
+import {
+  detachOffscreenInstance,
+  attachOffscreenInstance,
+} from './ReactFiberCommitWork';
 import {getHostContext} from './ReactFiberHostContext';
 
 export type {Fiber};
@@ -750,11 +753,13 @@ export function createFiberFromOffscreen(
   fiber.lanes = lanes;
   const primaryChildInstance: OffscreenInstance = {
     _visibility: OffscreenVisible,
+    _pendingVisibility: OffscreenVisible,
     _pendingMarkers: null,
     _retryCache: null,
     _transitions: null,
     _current: null,
     detach: () => detachOffscreenInstance(primaryChildInstance),
+    attach: () => attachOffscreenInstance(primaryChildInstance),
   };
   fiber.stateNode = primaryChildInstance;
   return fiber;
@@ -773,11 +778,13 @@ export function createFiberFromLegacyHidden(
   // the offscreen implementation, which depends on a state node
   const instance: OffscreenInstance = {
     _visibility: OffscreenVisible,
+    _pendingVisibility: OffscreenVisible,
     _pendingMarkers: null,
     _transitions: null,
     _retryCache: null,
     _current: null,
     detach: () => detachOffscreenInstance(instance),
+    attach: () => attachOffscreenInstance(instance),
   };
   fiber.stateNode = instance;
   return fiber;

filename: packages/react-reconciler/src/ReactFiberBeginWork.js
patch: @@ -677,6 +677,8 @@ function updateOffscreenComponent(
 ) {
   const nextProps: OffscreenProps = workInProgress.pendingProps;
   const nextChildren = nextProps.children;
+  const nextIsDetached =
+    (workInProgress.stateNode._pendingVisibility & OffscreenDetached) !== 0;
 
   const prevState: OffscreenState | null =
     current !== null ? current.memoizedState : null;
@@ -687,8 +689,7 @@ function updateOffscreenComponent(
     nextProps.mode === 'hidden' ||
     (enableLegacyHidden &&
       nextProps.mode === 'unstable-defer-without-hiding') ||
-    // TODO: remove read from stateNode.
-    workInProgress.stateNode._visibility & OffscreenDetached
+    nextIsDetached
   ) {
     // Rendering a hidden tree.
 

filename: packages/react-reconciler/src/ReactFiberCommitWork.js
patch: @@ -17,6 +17,7 @@ import type {
 } from './ReactFiberHostConfig';
 import type {Fiber, FiberRoot} from './ReactInternalTypes';
 import type {Lanes} from './ReactFiberLane';
+import {NoTimestamp, SyncLane} from './ReactFiberLane';
 import type {SuspenseState} from './ReactFiberSuspenseComponent';
 import type {UpdateQueue} from './ReactFiberClassUpdateQueue';
 import type {FunctionComponentUpdateQueue} from './ReactFiberHooks';
@@ -152,7 +153,6 @@ import {
   clearSingleton,
   acquireSingletonInstance,
   releaseSingletonInstance,
-  scheduleMicrotask,
 } from './ReactFiberHostConfig';
 import {
   captureCommitPhaseError,
@@ -169,7 +169,6 @@ import {
   setIsRunningInsertionEffect,
   getExecutionContext,
   CommitContext,
-  RenderContext,
   NoContext,
 } from './ReactFiberWorkLoop';
 import {
@@ -205,6 +204,8 @@ import {
   TransitionRoot,
   TransitionTracingMarker,
 } from './ReactFiberTracingMarkerComponent';
+import {scheduleUpdateOnFiber} from './ReactFiberWorkLoop';
+import {enqueueConcurrentRenderForLane} from './ReactFiberConcurrentUpdates';
 
 let didWarnAboutUndefinedSnapshotBeforeUpdate: Set<mixed> | null = null;
 if (__DEV__) {
@@ -2407,24 +2408,44 @@ function getRetryCache(finishedWork) {
 }
 
 export function detachOffscreenInstance(instance: OffscreenInstance): void {
-  const currentOffscreenFiber = instance._current;
-  if (currentOffscreenFiber === null) {
+  const fiber = instance._current;
+  if (fiber === null) {
     throw new Error(
       'Calling Offscreen.detach before instance handle has been set.',
     );
   }
 
-  const executionContext = getExecutionContext();
-  if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
-    scheduleMicrotask(() => {
-      instance._visibility |= OffscreenDetached;
-      disappearLayoutEffects(currentOffscreenFiber);
-      disconnectPassiveEffect(currentOffscreenFiber);
-    });
-  } else {
-    instance._visibility |= OffscreenDetached;
-    disappearLayoutEffects(currentOffscreenFiber);
-    disconnectPassiveEffect(currentOffscreenFiber);
+  if ((instance._pendingVisibility & OffscreenDetached) !== NoFlags) {
+    // The instance is already detached, this is a noop.
+    return;
+  }
+
+  // TODO: There is an opportunity to optimise this by not entering commit phase
+  // and unmounting effects directly.
+  const root = enqueueConcurrentRenderForLane(fiber, SyncLane);
+  if (root !== null) {
+    instance._pendingVisibility |= OffscreenDetached;
+    scheduleUpdateOnFiber(root, fiber, SyncLane, NoTimestamp);
+  }
+}
+
+export function attachOffscreenInstance(instance: OffscreenInstance): void {
+  const fiber = instance._current;
+  if (fiber === null) {
+    throw new Error(
+      'Calling Offscreen.detach before instance handle has been set.',
+    );
+  }
+
+  if ((instance._pendingVisibility & OffscreenDetached) === NoFlags) {
+    // The instance is already attached, this is a noop.
+    return;
+  }
+
+  const root = enqueueConcurrentRenderForLane(fiber, SyncLane);
+  if (root !== null) {
+    instance._pendingVisibility &= ~OffscreenDetached;
+    scheduleUpdateOnFiber(root, fiber, SyncLane, NoTimestamp);
   }
 }
 
@@ -2857,12 +2878,19 @@ function commitMutationEffectsOnFiber(
       }
 
       commitReconciliationEffects(finishedWork);
+
+      const offscreenInstance: OffscreenInstance = finishedWork.stateNode;
+
       // TODO: Add explicit effect flag to set _current.
-      finishedWork.stateNode._current = finishedWork;
+      offscreenInstance._current = finishedWork;
 
-      if (flags & Visibility) {
-        const offscreenInstance: OffscreenInstance = finishedWork.stateNode;
+      // Offscreen stores pending changes to visibility in `_pendingVisibility`. This is
+      // to support batching of `attach` and `detach` calls.
+      offscreenInstance._visibility &= ~OffscreenDetached;
+      offscreenInstance._visibility |=
+        offscreenInstance._pendingVisibility & OffscreenDetached;
 
+      if (flags & Visibility) {
         // Track the current state on the Offscreen instance so we can
         // read it during an event
         if (isHidden) {

filename: packages/react-reconciler/src/ReactFiberOffscreenComponent.js
patch: @@ -50,6 +50,7 @@ export const OffscreenDetached = /*                    */ 0b010;
 export const OffscreenPassiveEffectsConnected = /*     */ 0b100;
 
 export type OffscreenInstance = {
+  _pendingVisibility: OffscreenVisibility,
   _visibility: OffscreenVisibility,
   _pendingMarkers: Set<TracingMarkerInstance> | null,
   _transitions: Set<Transition> | null,
@@ -59,8 +60,7 @@ export type OffscreenInstance = {
   // Represents the current Offscreen fiber
   _current: Fiber | null,
   detach: () => void,
-
-  // TODO: attach
+  attach: () => void,
 };
 
 export function isOffscreenManual(offscreenFiber: Fiber): boolean {

filename: packages/react-reconciler/src/__tests__/ReactOffscreen-test.js
patch: @@ -31,7 +31,24 @@ describe('ReactOffscreen', () => {
 
   function Text(props) {
     Scheduler.unstable_yieldValue(props.text);
-    return <span prop={props.text} />;
+    return <span prop={props.text}>{props.children}</span>;
+  }
+
+  function LoggedText({text, children}) {
+    useEffect(() => {
+      Scheduler.unstable_yieldValue(`mount ${text}`);
+      return () => {
+        Scheduler.unstable_yieldValue(`unmount ${text}`);
+      };
+    });
+
+    useLayoutEffect(() => {
+      Scheduler.unstable_yieldValue(`mount layout ${text}`);
+      return () => {
+        Scheduler.unstable_yieldValue(`unmount layout ${text}`);
+      };
+    });
+    return <Text text={text}>{children}</Text>;
   }
 
   // @gate enableLegacyHidden
@@ -1520,7 +1537,6 @@ describe('ReactOffscreen', () => {
       );
 
       expect(offscreenRef.current).not.toBeNull();
-      expect(offscreenRef.current.detach).not.toBeNull();
 
       // Offscreen is attached by default. State updates from offscreen are **not defered**.
       await act(async () => {
@@ -1538,8 +1554,9 @@ describe('ReactOffscreen', () => {
         );
       });
 
-      // detaching offscreen.
-      offscreenRef.current.detach();
+      await act(async () => {
+        offscreenRef.current.detach();
+      });
 
       // Offscreen is detached. State updates from offscreen are **defered**.
       await act(async () => {
@@ -1561,6 +1578,26 @@ describe('ReactOffscreen', () => {
           <span prop="Child 2" />
         </>,
       );
+
+      await act(async () => {
+        offscreenRef.current.attach();
+      });
+
+      // Offscreen is attached. State updates from offscreen are **not defered**.
+      await act(async () => {
+        updateChildState(3);
+        updateHighPriorityComponentState(3);
+        expect(Scheduler).toFlushUntilNextPaint([
+          'HighPriorityComponent 3',
+          'Child 3',
+        ]);
+        expect(root).toMatchRenderedOutput(
+          <>
+            <span prop="HighPriorityComponent 3" />
+            <span prop="Child 3" />
+          </>,
+        );
+      });
     });
 
     // @gate enableOffscreen
@@ -1569,6 +1606,7 @@ describe('ReactOffscreen', () => {
       let updateHighPriorityComponentState;
       let offscreenRef;
       let nextRenderTriggerDetach = false;
+      let nextRenderTriggerAttach = false;
 
       function Child() {
         const [state, _stateUpdate] = useState(0);
@@ -1583,11 +1621,16 @@ describe('ReactOffscreen', () => {
         const text = 'HighPriorityComponent ' + state;
         useLayoutEffect(() => {
           if (nextRenderTriggerDetach) {
-            offscreenRef.current.detach();
             _stateUpdate(state + 1);
             updateChildState(state + 1);
+            offscreenRef.current.detach();
             nextRenderTriggerDetach = false;
           }
+
+          if (nextRenderTriggerAttach) {
+            offscreenRef.current.attach();
+            nextRenderTriggerAttach = false;
+          }
         });
         return (
           <>
@@ -1620,45 +1663,50 @@ describe('ReactOffscreen', () => {
 
       nextRenderTriggerDetach = true;
 
-      // Offscreen is attached. State updates from offscreen are **not defered**.
-      // Offscreen is detached inside useLayoutEffect;
+      // Offscreen is attached and gets detached inside useLayoutEffect.
+      // State updates from offscreen are **defered**.
       await act(async () => {
         updateChildState(1);
         updateHighPriorityComponentState(1);
         expect(Scheduler).toFlushUntilNextPaint([
           'HighPriorityComponent 1',
           'Child 1',
           'HighPriorityComponent 2',
-          'Child 2',
         ]);
         expect(root).toMatchRenderedOutput(
           <>
             <span prop="HighPriorityComponent 2" />
-            <span prop="Child 2" />
+            <span prop="Child 1" />
           </>,
         );
       });
 
+      expect(Scheduler).toHaveYielded(['Child 2']);
+      expect(root).toMatchRenderedOutput(
+        <>
+          <span prop="HighPriorityComponent 2" />
+          <span prop="Child 2" />
+        </>,
+      );
+
+      nextRenderTriggerAttach = true;
+
       // Offscreen is detached. State updates from offscreen are **defered**.
+      // Offscreen is attached inside useLayoutEffect;
       await act(async () => {
         updateChildState(3);
         updateHighPriorityComponentState(3);
-        expect(Scheduler).toFlushUntilNextPaint(['HighPriorityComponent 3']);
+        expect(Scheduler).toFlushUntilNextPaint([
+          'HighPriorityComponent 3',
+          'Child 3',
+        ]);
         expect(root).toMatchRenderedOutput(
           <>
             <span prop="HighPriorityComponent 3" />
-            <span prop="Child 2" />
+            <span prop="Child 3" />
           </>,
         );
       });
-
-      expect(Scheduler).toHaveYielded(['Child 3']);
-      expect(root).toMatchRenderedOutput(
-        <>
-          <span prop="HighPriorityComponent 3" />
-          <span prop="Child 3" />
-        </>,
-      );
     });
   });
 
@@ -1771,5 +1819,250 @@ describe('ReactOffscreen', () => {
     expect(offscreenRef.current._current === firstFiber).toBeFalsy();
   });
 
-  // TODO: When attach/detach methods are implemented. Add tests for nested Offscreen case.
+  // @gate enableOffscreen
+  it('does not mount tree until attach is called', async () => {
+    let offscreenRef;
+    let spanRef;
+
+    function Child() {
+      spanRef = useRef(null);
+      useEffect(() => {
+        Scheduler.unstable_yieldValue('Mount Child');
+        return () => {
+          Scheduler.unstable_yieldValue('Unmount Child');
+        };
+      });
+      useLayoutEffect(() => {
+        Scheduler.unstable_yieldValue('Mount Layout Child');
+        return () => {
+          Scheduler.unstable_yieldValue('Unmount Layout Child');
+        };
+      });
+
+      return <span ref={spanRef}>Child</span>;
+    }
+
+    function App() {
+      return (
+        <Offscreen mode={'manual'} ref={el => (offscreenRef = el)}>
+          <Child />
+        </Offscreen>
+      );
+    }
+
+    const root = ReactNoop.createRoot();
+
+    await act(async () => {
+      root.render(<App />);
+    });
+
+    expect(offscreenRef).not.toBeNull();
+    expect(spanRef.current).not.toBeNull();
+    expect(Scheduler).toHaveYielded(['Mount Layout Child', 'Mount Child']);
+
+    await act(async () => {
+      offscreenRef.detach();
+    });
+
+    expect(spanRef.current).toBeNull();
+    expect(Scheduler).toHaveYielded(['Unmount Layout Child', 'Unmount Child']);
+
+    // Calling attach on already attached Offscreen.
+    await act(async () => {
+      offscreenRef.detach();
+    });
+
+    expect(Scheduler).toHaveYielded([]);
+
+    await act(async () => {
+      offscreenRef.attach();
+    });
+
+    expect(spanRef.current).not.toBeNull();
+    expect(Scheduler).toHaveYielded(['Mount Layout Child', 'Mount Child']);
+
+    // Calling attach on already attached Offscreen
+    offscreenRef.attach();
+
+    expect(Scheduler).toHaveYielded([]);
+  });
+
+  // @gate enableOffscreen
+  it('handles nested manual offscreens', async () => {
+    let outerOffscreen;
+    let innerOffscreen;
+
+    function App() {
+      return (
+        <LoggedText text={'outer'}>
+          <Offscreen mode={'manual'} ref={el => (outerOffscreen = el)}>
+            <LoggedText text={'middle'}>
+              <Offscreen mode={'manual'} ref={el => (innerOffscreen = el)}>
+                <LoggedText text={'inner'} />
+              </Offscreen>
+            </LoggedText>
+          </Offscreen>
+        </LoggedText>
+      );
+    }
+
+    const root = ReactNoop.createRoot();
+
+    await act(async () => {
+      root.render(<App />);
+    });
+
+    expect(Scheduler).toHaveYielded([
+      'outer',
+      'middle',
+      'inner',
+      'mount layout inner',
+      'mount layout middle',
+      'mount layout outer',
+      'mount inner',
+      'mount middle',
+      'mount outer',
+    ]);
+
+    expect(outerOffscreen).not.toBeNull();
+    expect(innerOffscreen).not.toBeNull();
+
+    await act(async () => {
+      outerOffscreen.detach();
+    });
+
+    expect(innerOffscreen).toBeNull();
+
+    expect(Scheduler).toHaveYielded([
+      'unmount layout middle',
+      'unmount layout inner',
+      'unmount middle',
+      'unmount inner',
+    ]);
+
+    await act(async () => {
+      outerOffscreen.attach();
+    });
+
+    expect(Scheduler).toHaveYielded([
+      'mount layout inner',
+      'mount layout middle',
+      'mount inner',
+      'mount middle',
+    ]);
+
+    await act(async () => {
+      innerOffscreen.detach();
+    });
+
+    expect(Scheduler).toHaveYielded(['unmount layout inner', 'unmount inner']);
+
+    // Calling detach on already detached Offscreen.
+    await act(async () => {
+      innerOffscreen.detach();
+    });
+
+    expect(Scheduler).toHaveYielded([]);
+
+    await act(async () => {
+      innerOffscreen.attach();
+    });
+
+    expect(Scheduler).toHaveYielded(['mount layout inner', 'mount inner']);
+
+    await act(async () => {
+      innerOffscreen.detach();
+      outerOffscreen.attach();
+    });
+
+    expect(Scheduler).toHaveYielded(['unmount layout inner', 'unmount inner']);
+  });
+
+  // @gate enableOffscreen
+  it('batches multiple attach and detach calls scheduled from an event handler', async () => {
+    function Child() {
+      useEffect(() => {
+        Scheduler.unstable_yieldValue('attach child');
+        return () => {
+          Scheduler.unstable_yieldValue('detach child');
+        };
+      }, []);
+      return 'child';
+    }
+
+    const offscreen = React.createRef(null);
+    function App() {
+      return (
+        <Offscreen ref={offscreen} mode="manual">
+          <Child />
+        </Offscreen>
+      );
+    }
+
+    const root = ReactNoop.createRoot();
+    await act(() => {
+      root.render(<App />);
+    });
+
+    expect(Scheduler).toHaveYielded(['attach child']);
+
+    await act(async () => {
+      const instance = offscreen.current;
+      // Detach then immediately attach the instance.
+      instance.detach();
+      instance.attach();
+    });
+
+    expect(Scheduler).toHaveYielded([]);
+
+    await act(async () => {
+      const instance = offscreen.current;
+      instance.detach();
+    });
+
+    expect(Scheduler).toHaveYielded(['detach child']);
+
+    await act(async () => {
+      const instance = offscreen.current;
+      // Attach then immediately detach.
+      instance.attach();
+      instance.detach();
+    });
+
+    expect(Scheduler).toHaveYielded([]);
+  });
+
+  // @gate enableOffscreen
+  it('batches multiple attach and detach calls scheduled from an effect', async () => {
+    function Child() {
+      useEffect(() => {
+        Scheduler.unstable_yieldValue('attach child');
+        return () => {
+          Scheduler.unstable_yieldValue('detach child');
+        };
+      }, []);
+      return 'child';
+    }
+
+    function App() {
+      const offscreen = useRef(null);
+      useLayoutEffect(() => {
+        const instance = offscreen.current;
+        // Detach then immediately attach the instance.
+        instance.detach();
+        instance.attach();
+      }, []);
+      return (
+        <Offscreen ref={offscreen} mode="manual">
+          <Child />
+        </Offscreen>
+      );
+    }
+
+    const root = ReactNoop.createRoot();
+    await act(() => {
+      root.render(<App />);
+    });
+    expect(Scheduler).toHaveYielded(['attach child']);
+  });
 });