diff --git a/packages/react-reconciler/src/ReactFiber.new.js b/packages/react-reconciler/src/ReactFiber.new.js index d6a6c8f7ad..f6f15ba773 100644 --- a/packages/react-reconciler/src/ReactFiber.new.js +++ b/packages/react-reconciler/src/ReactFiber.new.js @@ -723,7 +723,11 @@ export function createFiberFromOffscreen( pendingMarkers: null, retryCache: null, transitions: null, + detach: () => {}, + attach: () => {}, + _isDetached: false, }; + fiber.stateNode = primaryChildInstance; return fiber; } @@ -744,6 +748,13 @@ export function createFiberFromLegacyHidden( pendingMarkers: null, transitions: null, retryCache: null, + detach: () => { + // noop + }, + attach: () => { + // noop + }, + _isDetached: false, }; fiber.stateNode = instance; return fiber; diff --git a/packages/react-reconciler/src/ReactFiber.old.js b/packages/react-reconciler/src/ReactFiber.old.js index d8f1ef424b..68af335795 100644 --- a/packages/react-reconciler/src/ReactFiber.old.js +++ b/packages/react-reconciler/src/ReactFiber.old.js @@ -723,7 +723,11 @@ export function createFiberFromOffscreen( pendingMarkers: null, retryCache: null, transitions: null, + detach: () => {}, + attach: () => {}, + _isDetached: false, }; + fiber.stateNode = primaryChildInstance; return fiber; } @@ -744,6 +748,13 @@ export function createFiberFromLegacyHidden( pendingMarkers: null, transitions: null, retryCache: null, + detach: () => { + // noop + }, + attach: () => { + // noop + }, + _isDetached: false, }; fiber.stateNode = instance; return fiber; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index fa417e0811..dc735e2f54 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -677,9 +677,13 @@ function updateOffscreenComponent( const prevState: OffscreenState | null = current !== null ? current.memoizedState : null; + markRef(current, workInProgress); + if ( nextProps.mode === 'hidden' || - (enableLegacyHidden && nextProps.mode === 'unstable-defer-without-hiding') + (enableLegacyHidden && + nextProps.mode === 'unstable-defer-without-hiding') || + workInProgress.stateNode._isDetached ) { // Rendering a hidden tree. diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 449f306e7e..a5f799cf38 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -677,9 +677,13 @@ function updateOffscreenComponent( const prevState: OffscreenState | null = current !== null ? current.memoizedState : null; + markRef(current, workInProgress); + if ( nextProps.mode === 'hidden' || - (enableLegacyHidden && nextProps.mode === 'unstable-defer-without-hiding') + (enableLegacyHidden && + nextProps.mode === 'unstable-defer-without-hiding') || + workInProgress.stateNode._isDetached ) { // Rendering a hidden tree. diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index c70e4ae347..4ff36eaffa 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -18,6 +18,7 @@ import type { import type {Fiber} from './ReactInternalTypes'; import type {FiberRoot} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane.new'; +import {NoLanes} from './ReactFiberLane.new'; import type {SuspenseState} from './ReactFiberSuspenseComponent.new'; import type {UpdateQueue} from './ReactFiberClassUpdateQueue.new'; import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.new'; @@ -29,6 +30,7 @@ import type { } from './ReactFiberOffscreenComponent'; import type {HookFlags} from './ReactHookEffectTags'; import type {Cache} from './ReactFiberCacheComponent.new'; +import {scheduleMicrotask} from './ReactFiberHostConfig'; import type {RootState} from './ReactFiberRoot.new'; import type { Transition, @@ -154,6 +156,7 @@ import { setIsRunningInsertionEffect, getExecutionContext, CommitContext, + RenderContext, NoContext, } from './ReactFiberWorkLoop.new'; 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._isDetached; const newOffscreenSubtreeIsHidden = isHidden || offscreenSubtreeIsHidden; if (newOffscreenSubtreeIsHidden) { @@ -1116,6 +1121,46 @@ function commitLayoutEffectOnFiber( offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden; offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden; } + + // TODO: figure out the proper place for this. + finishedWork.stateNode.detach = () => { + const executionContext = getExecutionContext(); + if ( + (executionContext & (RenderContext | CommitContext)) !== + NoContext + ) { + scheduleMicrotask(() => { + finishedWork.stateNode._isDetached = true; + disappearLayoutEffects(finishedWork); + disconnectPassiveEffect(finishedWork); + }); + } else { + finishedWork.stateNode._isDetached = true; + disappearLayoutEffects(finishedWork); + disconnectPassiveEffect(finishedWork); + } + }; + + finishedWork.stateNode.attach = () => { + // TODO: does not handle when attach is called from effect or when tree is rendered. + finishedWork.stateNode._isDetached = false; + reappearLayoutEffects(finishedRoot, null, finishedWork, false); + reconnectPassiveEffects( + finishedRoot, + finishedWork, + NoLanes, + null, + false, + ); + }; + + if (finishedWork.pendingProps.mode === null) { + if (flags & Ref) { + safelyAttachRef(finishedWork, finishedWork.return); + } + } else if (finishedWork.pendingProps.mode !== undefined) { + safelyDetachRef(finishedWork, finishedWork.return); + } } else { recursivelyTraverseLayoutEffects( finishedRoot, @@ -2651,7 +2696,7 @@ function commitMutationEffectsOnFiber( } } - if (supportsMutation) { + if (supportsMutation && offscreenInstance._isDetached !== true) { // 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 4b5076a61c..5bccd0a306 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -18,6 +18,7 @@ import type { import type {Fiber} from './ReactInternalTypes'; import type {FiberRoot} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane.old'; +import {NoLanes} from './ReactFiberLane.old'; import type {SuspenseState} from './ReactFiberSuspenseComponent.old'; import type {UpdateQueue} from './ReactFiberClassUpdateQueue.old'; import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.old'; @@ -29,6 +30,7 @@ import type { } from './ReactFiberOffscreenComponent'; import type {HookFlags} from './ReactHookEffectTags'; import type {Cache} from './ReactFiberCacheComponent.old'; +import {scheduleMicrotask} from './ReactFiberHostConfig'; import type {RootState} from './ReactFiberRoot.old'; import type { Transition, @@ -154,6 +156,7 @@ import { setIsRunningInsertionEffect, getExecutionContext, CommitContext, + RenderContext, NoContext, } from './ReactFiberWorkLoop.old'; 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._isDetached; const newOffscreenSubtreeIsHidden = isHidden || offscreenSubtreeIsHidden; if (newOffscreenSubtreeIsHidden) { @@ -1116,6 +1121,46 @@ function commitLayoutEffectOnFiber( offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden; offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden; } + + // TODO: figure out the proper place for this. + finishedWork.stateNode.detach = () => { + const executionContext = getExecutionContext(); + if ( + (executionContext & (RenderContext | CommitContext)) !== + NoContext + ) { + scheduleMicrotask(() => { + finishedWork.stateNode._isDetached = true; + disappearLayoutEffects(finishedWork); + disconnectPassiveEffect(finishedWork); + }); + } else { + finishedWork.stateNode._isDetached = true; + disappearLayoutEffects(finishedWork); + disconnectPassiveEffect(finishedWork); + } + }; + + finishedWork.stateNode.attach = () => { + // TODO: does not handle when attach is called from effect or when tree is rendered. + finishedWork.stateNode._isDetached = false; + reappearLayoutEffects(finishedRoot, null, finishedWork, false); + reconnectPassiveEffects( + finishedRoot, + finishedWork, + NoLanes, + null, + false, + ); + }; + + if (finishedWork.pendingProps.mode === null) { + if (flags & Ref) { + safelyAttachRef(finishedWork, finishedWork.return); + } + } else if (finishedWork.pendingProps.mode !== undefined) { + safelyDetachRef(finishedWork, finishedWork.return); + } } else { recursivelyTraverseLayoutEffects( finishedRoot, @@ -2651,7 +2696,7 @@ function commitMutationEffectsOnFiber( } } - if (supportsMutation) { + if (supportsMutation && offscreenInstance._isDetached !== true) { // 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 135e9290f5..4632002053 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -302,7 +302,6 @@ if (supportsMutation) { }; } else if (supportsPersistence) { // Persistent host tree mode - appendAllChildren = function( parent: Instance, workInProgress: Fiber, @@ -410,7 +409,14 @@ if (supportsMutation) { if (child !== null) { child.return = node; } - appendAllChildrenToContainer(containerChildSet, node, true, true); + // Detached tree is hidden from user space. + const _needsVisibilityToggle = node.stateNode._isDetached === false; + 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 3fd1bffbc3..26b83addd3 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -302,7 +302,6 @@ if (supportsMutation) { }; } else if (supportsPersistence) { // Persistent host tree mode - appendAllChildren = function( parent: Instance, workInProgress: Fiber, @@ -410,7 +409,14 @@ if (supportsMutation) { if (child !== null) { child.return = node; } - appendAllChildrenToContainer(containerChildSet, node, true, true); + // Detached tree is hidden from user space. + const _needsVisibilityToggle = node.stateNode._isDetached === false; + 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 1c45f6135d..aa0b4b2f1d 100644 --- a/packages/react-reconciler/src/ReactFiberOffscreenComponent.js +++ b/packages/react-reconciler/src/ReactFiberOffscreenComponent.js @@ -52,4 +52,7 @@ export type OffscreenInstance = { pendingMarkers: Set | null, transitions: Set | null, retryCache: WeakSet | Set | null, + detach: () => void, + attach: () => void, + _isDetached: boolean, }; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index a40b5ac480..a7d56eea60 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 3db85b0b9b..b713af60db 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-DOM-test.js b/packages/react-reconciler/src/__tests__/ReactOffscreen-DOM-test.js new file mode 100644 index 0000000000..0f10ce17c8 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactOffscreen-DOM-test.js @@ -0,0 +1,65 @@ +let React; +let ReactDOMClient; +let Offscreen; +let container; +let act; +let useRef; + +describe('ReactOffscreen', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactDOMClient = require('react-dom/client'); + Offscreen = React.unstable_Offscreen; + act = require('jest-react').act; + useRef = React.useRef; + + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + // @gate enableOffscreen + xit('does not attach event handlers by default', async () => { + const onClick = jest.fn(); + let offscreenRef; + + function App({mode}) { + offscreenRef = useRef(null); + return ( + + + + ); + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render(); + }); + + function click() { + container + .querySelector('#span-1') + .dispatchEvent( + new MouseEvent('click', {bubbles: true, cancelable: true}), + ); + } + + expect(offscreenRef.current).not.toBeNull(); + + click(); + + expect(onClick.mock.calls.length).toBe(1); + + offscreenRef.current.detach(); + + click(); + + expect(onClick.mock.calls.length).toBe(1); + }); +}); diff --git a/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js b/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js index 4e77f831f9..e1fb21f343 100644 --- a/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js +++ b/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js @@ -5,6 +5,7 @@ let act; let LegacyHidden; let Offscreen; let useState; +let useRef; let useLayoutEffect; let useEffect; let useMemo; @@ -24,6 +25,7 @@ describe('ReactOffscreen', () => { useLayoutEffect = React.useLayoutEffect; useEffect = React.useEffect; useMemo = React.useMemo; + useRef = React.useRef; startTransition = React.startTransition; }); @@ -1259,4 +1261,371 @@ describe('ReactOffscreen', () => { , ); }); + + // @gate enableOffscreen + it('defers updates in hidden tree', async () => { + let updateChildState; + let updateHighPriorityComponentState; + + 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} + + ); + } + + const root = ReactNoop.createRoot(); + + // Mount hidden tree. + await act(async () => { + root.render( + <> + + + + + + , + ); + }); + + expect(Scheduler).toHaveYielded(['HighPriorityComponent 0', 'Child 0']); + expect(root).toMatchRenderedOutput( + <> + +