From d17ae6e53e596ef11301d3878b573ca417579fb2 Mon Sep 17 00:00:00 2001 From: Samuel Susla Date: Wed, 14 Sep 2022 14:09:26 +0100 Subject: [PATCH] Add detach to Offscreen component --- .../react-reconciler/src/ReactFiber.new.js | 2 + .../react-reconciler/src/ReactFiber.old.js | 2 + .../src/ReactFiberBeginWork.new.js | 6 +- .../src/ReactFiberBeginWork.old.js | 6 +- .../src/ReactFiberCommitWork.new.js | 30 ++- .../src/ReactFiberCommitWork.old.js | 30 ++- .../src/ReactFiberCompleteWork.new.js | 11 +- .../src/ReactFiberCompleteWork.old.js | 11 +- .../src/ReactFiberOffscreenComponent.js | 6 +- .../src/ReactFiberWorkLoop.new.js | 2 +- .../src/ReactFiberWorkLoop.old.js | 2 +- .../src/__tests__/ReactOffscreen-test.js | 194 ++++++++++++++++++ 12 files changed, 288 insertions(+), 14 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiber.new.js b/packages/react-reconciler/src/ReactFiber.new.js index 46f35fabf1fef..524a3806f07de 100644 --- a/packages/react-reconciler/src/ReactFiber.new.js +++ b/packages/react-reconciler/src/ReactFiber.new.js @@ -723,6 +723,7 @@ export function createFiberFromOffscreen( _pendingMarkers: null, _retryCache: null, _transitions: null, + detach: () => {}, }; fiber.stateNode = primaryChildInstance; return fiber; @@ -744,6 +745,7 @@ export function createFiberFromLegacyHidden( _pendingMarkers: null, _transitions: null, _retryCache: null, + detach: () => {}, }; fiber.stateNode = instance; return fiber; diff --git a/packages/react-reconciler/src/ReactFiber.old.js b/packages/react-reconciler/src/ReactFiber.old.js index 22712b6e18eed..409f9b230524b 100644 --- a/packages/react-reconciler/src/ReactFiber.old.js +++ b/packages/react-reconciler/src/ReactFiber.old.js @@ -723,6 +723,7 @@ export function createFiberFromOffscreen( _pendingMarkers: null, _retryCache: null, _transitions: null, + detach: () => {}, }; fiber.stateNode = primaryChildInstance; return fiber; @@ -744,6 +745,7 @@ export function createFiberFromLegacyHidden( _pendingMarkers: null, _transitions: null, _retryCache: null, + detach: () => {}, }; fiber.stateNode = instance; return fiber; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 81909edf5beb7..f2dbbb2bb8a80 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -41,7 +41,7 @@ import { enableCPUSuspense, enableUseMutableSource, } from 'shared/ReactFeatureFlags'; - +import {OffscreenDetached} from './ReactFiberOffscreenComponent'; import checkPropTypes from 'shared/checkPropTypes'; import { markComponentRenderStarted, @@ -683,7 +683,9 @@ function updateOffscreenComponent( if ( nextProps.mode === 'hidden' || - (enableLegacyHidden && nextProps.mode === 'unstable-defer-without-hiding') + (enableLegacyHidden && + nextProps.mode === 'unstable-defer-without-hiding') || + workInProgress.stateNode._visibility & OffscreenDetached ) { // Rendering a hidden tree. diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index cfe7a45a80ae6..8c973561c34c6 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -41,7 +41,7 @@ import { enableCPUSuspense, enableUseMutableSource, } from 'shared/ReactFeatureFlags'; - +import {OffscreenDetached} from './ReactFiberOffscreenComponent'; import checkPropTypes from 'shared/checkPropTypes'; import { markComponentRenderStarted, @@ -683,7 +683,9 @@ function updateOffscreenComponent( if ( nextProps.mode === 'hidden' || - (enableLegacyHidden && nextProps.mode === 'unstable-defer-without-hiding') + (enableLegacyHidden && + nextProps.mode === 'unstable-defer-without-hiding') || + workInProgress.stateNode._visibility & OffscreenDetached ) { // Rendering a hidden tree. diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 5f8de4fab94e5..7006f66c5e3b4 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -30,6 +30,7 @@ import type { import type {HookFlags} from './ReactHookEffectTags'; import type {Cache} from './ReactFiberCacheComponent.new'; import type {RootState} from './ReactFiberRoot.new'; +import {scheduleMicrotask} from './ReactFiberHostConfig'; import type { Transition, TracingMarkerInstance, @@ -154,6 +155,7 @@ import { setIsRunningInsertionEffect, getExecutionContext, CommitContext, + RenderContext, NoContext, } from './ReactFiberWorkLoop.new'; import { @@ -182,6 +184,7 @@ import {releaseCache, retainCache} from './ReactFiberCacheComponent.new'; import {clearTransitionsForLanes} from './ReactFiberLane.new'; import { OffscreenVisible, + OffscreenDetached, OffscreenPassiveEffectsConnected, } from './ReactFiberOffscreenComponent'; import { @@ -1078,7 +1081,9 @@ function commitLayoutEffectOnFiber( case OffscreenComponent: { const isModernRoot = (finishedWork.mode & ConcurrentMode) !== NoMode; if (isModernRoot) { - const isHidden = finishedWork.memoizedState !== null; + const isHidden = + finishedWork.memoizedState !== null || + finishedWork.stateNode._visibility & OffscreenDetached; const newOffscreenSubtreeIsHidden = isHidden || offscreenSubtreeIsHidden; if (newOffscreenSubtreeIsHidden) { @@ -2255,6 +2260,23 @@ function getRetryCache(finishedWork) { } } +function attachOffscreenActions(offscreenFiber: Fiber, root: FiberRoot) { + offscreenFiber.stateNode.detach = () => { + const executionContext = getExecutionContext(); + if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { + scheduleMicrotask(() => { + offscreenFiber.stateNode._visibility |= OffscreenDetached; + disappearLayoutEffects(offscreenFiber); + disconnectPassiveEffect(offscreenFiber); + }); + } else { + offscreenFiber.stateNode._visibility |= OffscreenDetached; + disappearLayoutEffects(offscreenFiber); + disconnectPassiveEffect(offscreenFiber); + } + }; +} + function attachSuspenseRetryListeners( finishedWork: Fiber, wakeables: Set, @@ -2633,6 +2655,7 @@ function commitMutationEffectsOnFiber( } commitReconciliationEffects(finishedWork); + attachOffscreenActions(finishedWork, root); if (flags & Visibility) { const offscreenInstance: OffscreenInstance = finishedWork.stateNode; @@ -2659,7 +2682,10 @@ function commitMutationEffectsOnFiber( } } - if (supportsMutation) { + if ( + supportsMutation && + !(offscreenInstance._visibility & OffscreenDetached) + ) { // TODO: This needs to run whenever there's an insertion or update // inside a hidden Offscreen tree. hideOrUnhideAllChildren(offscreenBoundary, isHidden); diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index c3c2e964ad497..d44efab4907b3 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -30,6 +30,7 @@ import type { import type {HookFlags} from './ReactHookEffectTags'; import type {Cache} from './ReactFiberCacheComponent.old'; import type {RootState} from './ReactFiberRoot.old'; +import {scheduleMicrotask} from './ReactFiberHostConfig'; import type { Transition, TracingMarkerInstance, @@ -154,6 +155,7 @@ import { setIsRunningInsertionEffect, getExecutionContext, CommitContext, + RenderContext, NoContext, } from './ReactFiberWorkLoop.old'; import { @@ -182,6 +184,7 @@ import {releaseCache, retainCache} from './ReactFiberCacheComponent.old'; import {clearTransitionsForLanes} from './ReactFiberLane.old'; import { OffscreenVisible, + OffscreenDetached, OffscreenPassiveEffectsConnected, } from './ReactFiberOffscreenComponent'; import { @@ -1078,7 +1081,9 @@ function commitLayoutEffectOnFiber( case OffscreenComponent: { const isModernRoot = (finishedWork.mode & ConcurrentMode) !== NoMode; if (isModernRoot) { - const isHidden = finishedWork.memoizedState !== null; + const isHidden = + finishedWork.memoizedState !== null || + finishedWork.stateNode._visibility & OffscreenDetached; const newOffscreenSubtreeIsHidden = isHidden || offscreenSubtreeIsHidden; if (newOffscreenSubtreeIsHidden) { @@ -2255,6 +2260,23 @@ function getRetryCache(finishedWork) { } } +function attachOffscreenActions(offscreenFiber: Fiber, root: FiberRoot) { + offscreenFiber.stateNode.detach = () => { + const executionContext = getExecutionContext(); + if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { + scheduleMicrotask(() => { + offscreenFiber.stateNode._visibility |= OffscreenDetached; + disappearLayoutEffects(offscreenFiber); + disconnectPassiveEffect(offscreenFiber); + }); + } else { + offscreenFiber.stateNode._visibility |= OffscreenDetached; + disappearLayoutEffects(offscreenFiber); + disconnectPassiveEffect(offscreenFiber); + } + }; +} + function attachSuspenseRetryListeners( finishedWork: Fiber, wakeables: Set, @@ -2633,6 +2655,7 @@ function commitMutationEffectsOnFiber( } commitReconciliationEffects(finishedWork); + attachOffscreenActions(finishedWork, root); if (flags & Visibility) { const offscreenInstance: OffscreenInstance = finishedWork.stateNode; @@ -2659,7 +2682,10 @@ function commitMutationEffectsOnFiber( } } - if (supportsMutation) { + if ( + supportsMutation && + !(offscreenInstance._visibility & OffscreenDetached) + ) { // TODO: This needs to run whenever there's an insertion or update // inside a hidden Offscreen tree. hideOrUnhideAllChildren(offscreenBoundary, isHidden); diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index 592bef4fbd25d..c08c100878536 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -28,6 +28,7 @@ import type { SuspenseListRenderState, } from './ReactFiberSuspenseComponent.new'; import type {OffscreenState} from './ReactFiberOffscreenComponent'; +import {OffscreenDetached} from './ReactFiberOffscreenComponent'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.new'; import type {Cache} from './ReactFiberCacheComponent.new'; import { @@ -410,7 +411,15 @@ if (supportsMutation) { if (child !== null) { child.return = node; } - appendAllChildrenToContainer(containerChildSet, node, true, true); + // Detached tree is hidden from user space. + const _needsVisibilityToggle = + node.stateNode._visibility & OffscreenDetached === 0; + appendAllChildrenToContainer( + containerChildSet, + node, + _needsVisibilityToggle, + true, + ); } else if (node.child !== null) { node.child.return = node; node = node.child; diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index f3ba7bf0799a0..cff20f8e1a2db 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -28,6 +28,7 @@ import type { SuspenseListRenderState, } from './ReactFiberSuspenseComponent.old'; import type {OffscreenState} from './ReactFiberOffscreenComponent'; +import {OffscreenDetached} from './ReactFiberOffscreenComponent'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.old'; import type {Cache} from './ReactFiberCacheComponent.old'; import { @@ -410,7 +411,15 @@ if (supportsMutation) { if (child !== null) { child.return = node; } - appendAllChildrenToContainer(containerChildSet, node, true, true); + // Detached tree is hidden from user space. + const _needsVisibilityToggle = + node.stateNode._visibility & OffscreenDetached === 0; + appendAllChildrenToContainer( + containerChildSet, + node, + _needsVisibilityToggle, + true, + ); } else if (node.child !== null) { node.child.return = node; node = node.child; diff --git a/packages/react-reconciler/src/ReactFiberOffscreenComponent.js b/packages/react-reconciler/src/ReactFiberOffscreenComponent.js index 081f6a5a519ea..fbc55bc359129 100644 --- a/packages/react-reconciler/src/ReactFiberOffscreenComponent.js +++ b/packages/react-reconciler/src/ReactFiberOffscreenComponent.js @@ -44,12 +44,14 @@ export type OffscreenQueue = { type OffscreenVisibility = number; -export const OffscreenVisible = /* */ 0b01; -export const OffscreenPassiveEffectsConnected = /* */ 0b10; +export const OffscreenVisible = /* */ 0b001; +export const OffscreenDetached = /* */ 0b010; +export const OffscreenPassiveEffectsConnected = /* */ 0b100; export type OffscreenInstance = { _visibility: OffscreenVisibility, _pendingMarkers: Set | null, _transitions: Set | null, _retryCache: WeakSet | Set | null, + detach: () => void, }; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index b7169f5493c6b..03ea08a225bbd 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -277,7 +277,7 @@ type ExecutionContext = number; export const NoContext = /* */ 0b000; const BatchedContext = /* */ 0b001; -const RenderContext = /* */ 0b010; +export const RenderContext = /* */ 0b010; export const CommitContext = /* */ 0b100; type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 3071dc5a16260..d32d4d10ba571 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -277,7 +277,7 @@ type ExecutionContext = number; export const NoContext = /* */ 0b000; const BatchedContext = /* */ 0b001; -const RenderContext = /* */ 0b010; +export const RenderContext = /* */ 0b010; export const CommitContext = /* */ 0b100; type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6; diff --git a/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js b/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js index 6180c8a1e7105..638b8a4770a01 100644 --- a/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js +++ b/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js @@ -1306,5 +1306,199 @@ describe('ReactOffscreen', () => { expect(offscreenRef.current).not.toBeNull(); }); + + // @gate enableOffscreen + it('should lower update priority for detached Offscreen', async () => { + let updateChildState; + let updateHighPriorityComponentState; + let offscreenRef; + + function Child() { + const [state, _stateUpdate] = useState(0); + updateChildState = _stateUpdate; + const text = 'Child ' + state; + return ; + } + + function HighPriorityComponent(props) { + const [state, _stateUpdate] = useState(0); + updateHighPriorityComponentState = _stateUpdate; + const text = 'HighPriorityComponent ' + state; + return ( + <> + + {props.children} + + ); + } + + function App() { + offscreenRef = useRef(null); + return ( + <> + + + + + + + ); + } + + const root = ReactNoop.createRoot(); + + await act(async () => { + root.render(); + }); + + expect(Scheduler).toHaveYielded(['HighPriorityComponent 0', 'Child 0']); + expect(root).toMatchRenderedOutput( + <> + + + , + ); + + 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 () => { + updateChildState(1); + updateHighPriorityComponentState(1); + expect(Scheduler).toFlushUntilNextPaint([ + 'HighPriorityComponent 1', + 'Child 1', + ]); + expect(root).toMatchRenderedOutput( + <> + + + , + ); + }); + + // detaching offscreen. + offscreenRef.current.detach(); + + // Offscreen is detached. State updates from offscreen are **defered**. + await act(async () => { + updateChildState(2); + updateHighPriorityComponentState(2); + expect(Scheduler).toFlushUntilNextPaint(['HighPriorityComponent 2']); + expect(root).toMatchRenderedOutput( + <> + + + , + ); + }); + + expect(Scheduler).toHaveYielded(['Child 2']); + expect(root).toMatchRenderedOutput( + <> + + + , + ); + }); + + // @gate enableOffscreen + it('defers detachment if called during commit', async () => { + let updateChildState; + let updateHighPriorityComponentState; + let offscreenRef; + let nextRenderTriggerDetach = false; + + function Child() { + const [state, _stateUpdate] = useState(0); + updateChildState = _stateUpdate; + const text = 'Child ' + state; + return ; + } + + function HighPriorityComponent(props) { + const [state, _stateUpdate] = useState(0); + updateHighPriorityComponentState = _stateUpdate; + const text = 'HighPriorityComponent ' + state; + useLayoutEffect(() => { + if (nextRenderTriggerDetach) { + offscreenRef.current.detach(); + _stateUpdate(state + 1); + updateChildState(state + 1); + nextRenderTriggerDetach = false; + } + }); + return ( + <> + + {props.children} + + ); + } + + function App() { + offscreenRef = useRef(null); + return ( + <> + + + + + + + ); + } + + const root = ReactNoop.createRoot(); + + await act(async () => { + root.render(); + }); + + expect(Scheduler).toHaveYielded(['HighPriorityComponent 0', 'Child 0']); + + nextRenderTriggerDetach = true; + + // Offscreen is attached. State updates from offscreen are **not defered**. + // Offscreen is detached inside useLayoutEffect; + await act(async () => { + updateChildState(1); + updateHighPriorityComponentState(1); + expect(Scheduler).toFlushUntilNextPaint([ + 'HighPriorityComponent 1', + 'Child 1', + 'HighPriorityComponent 2', + 'Child 2', + ]); + expect(root).toMatchRenderedOutput( + <> + + + , + ); + }); + + // Offscreen is detached. State updates from offscreen are **defered**. + await act(async () => { + updateChildState(3); + updateHighPriorityComponentState(3); + expect(Scheduler).toFlushUntilNextPaint(['HighPriorityComponent 3']); + expect(root).toMatchRenderedOutput( + <> + + + , + ); + }); + + expect(Scheduler).toHaveYielded(['Child 3']); + expect(root).toMatchRenderedOutput( + <> + + + , + ); + }); }); });