From 996e4c0d56dabab382ca932cd5b8517e63020999 Mon Sep 17 00:00:00 2001 From: Samuel Susla Date: Mon, 12 Dec 2022 14:00:16 +0000 Subject: [PATCH] Offscreen add attach (#25603) `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 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(); ``` Co-authored-by: Andrew Clark --- packages/react-reconciler/src/ReactFiber.js | 9 +- .../src/ReactFiberBeginWork.js | 5 +- .../src/ReactFiberCommitWork.js | 64 +++- .../src/ReactFiberOffscreenComponent.js | 4 +- .../src/__tests__/ReactOffscreen-test.js | 333 ++++++++++++++++-- 5 files changed, 372 insertions(+), 43 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index 78c0794d432c2..f1e473ebf0b98 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -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; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 78372d1762916..7b3f525d2dcb9 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -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. diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index e394f5671a733..eec6e1eb0142f 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -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 | 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) { diff --git a/packages/react-reconciler/src/ReactFiberOffscreenComponent.js b/packages/react-reconciler/src/ReactFiberOffscreenComponent.js index e6445fd66840a..d8833779195d1 100644 --- a/packages/react-reconciler/src/ReactFiberOffscreenComponent.js +++ b/packages/react-reconciler/src/ReactFiberOffscreenComponent.js @@ -50,6 +50,7 @@ export const OffscreenDetached = /* */ 0b010; export const OffscreenPassiveEffectsConnected = /* */ 0b100; export type OffscreenInstance = { + _pendingVisibility: OffscreenVisibility, _visibility: OffscreenVisibility, _pendingMarkers: Set | null, _transitions: Set | 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 { diff --git a/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js b/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js index d830265610823..d723c44a5d308 100644 --- a/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js +++ b/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js @@ -31,7 +31,24 @@ describe('ReactOffscreen', () => { function Text(props) { Scheduler.unstable_yieldValue(props.text); - return ; + return {props.children}; + } + + 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 {children}; } // @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', () => { , ); + + 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( + <> + + + , + ); + }); }); // @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,8 +1663,8 @@ 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); @@ -1629,36 +1672,41 @@ describe('ReactOffscreen', () => { 'HighPriorityComponent 1', 'Child 1', 'HighPriorityComponent 2', - 'Child 2', ]); expect(root).toMatchRenderedOutput( <> - + , ); }); + expect(Scheduler).toHaveYielded(['Child 2']); + expect(root).toMatchRenderedOutput( + <> + + + , + ); + + 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( <> - + , ); }); - - expect(Scheduler).toHaveYielded(['Child 3']); - expect(root).toMatchRenderedOutput( - <> - - - , - ); }); }); @@ -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 Child; + } + + function App() { + return ( + (offscreenRef = el)}> + + + ); + } + + const root = ReactNoop.createRoot(); + + await act(async () => { + root.render(); + }); + + 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 ( + + (outerOffscreen = el)}> + + (innerOffscreen = el)}> + + + + + + ); + } + + const root = ReactNoop.createRoot(); + + await act(async () => { + root.render(); + }); + + 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 ( + + + + ); + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + + 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 ( + + + + ); + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['attach child']); + }); });