diff --git a/packages/react-reconciler/src/ReactFiber.new.js b/packages/react-reconciler/src/ReactFiber.new.js index 57e10f9af2e31..971a88369c65a 100644 --- a/packages/react-reconciler/src/ReactFiber.new.js +++ b/packages/react-reconciler/src/ReactFiber.new.js @@ -73,7 +73,6 @@ import { } from './ReactWorkTags'; import {OffscreenVisible} from './ReactFiberOffscreenComponent'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; - import {isDevToolsPresent} from './ReactFiberDevToolsHook.new'; import { resolveClassForHotReloading, @@ -109,6 +108,7 @@ import { REACT_TRACING_MARKER_TYPE, } from 'shared/ReactSymbols'; import {TransitionTracingMarker} from './ReactFiberTracingMarkerComponent.new'; +import {detachOffscreenInstance} from './ReactFiberCommitWork.new'; export type {Fiber}; @@ -755,6 +755,8 @@ export function createFiberFromOffscreen( _pendingMarkers: null, _retryCache: null, _transitions: null, + _current: null, + detach: () => detachOffscreenInstance(primaryChildInstance), }; fiber.stateNode = primaryChildInstance; return fiber; @@ -776,6 +778,8 @@ export function createFiberFromLegacyHidden( _pendingMarkers: null, _transitions: null, _retryCache: null, + _current: null, + detach: () => detachOffscreenInstance(instance), }; fiber.stateNode = instance; return fiber; diff --git a/packages/react-reconciler/src/ReactFiber.old.js b/packages/react-reconciler/src/ReactFiber.old.js index dac93beeec4c8..48c7db420c571 100644 --- a/packages/react-reconciler/src/ReactFiber.old.js +++ b/packages/react-reconciler/src/ReactFiber.old.js @@ -73,7 +73,6 @@ import { } from './ReactWorkTags'; import {OffscreenVisible} from './ReactFiberOffscreenComponent'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; - import {isDevToolsPresent} from './ReactFiberDevToolsHook.old'; import { resolveClassForHotReloading, @@ -109,6 +108,7 @@ import { REACT_TRACING_MARKER_TYPE, } from 'shared/ReactSymbols'; import {TransitionTracingMarker} from './ReactFiberTracingMarkerComponent.old'; +import {detachOffscreenInstance} from './ReactFiberCommitWork.old'; export type {Fiber}; @@ -755,6 +755,8 @@ export function createFiberFromOffscreen( _pendingMarkers: null, _retryCache: null, _transitions: null, + _current: null, + detach: () => detachOffscreenInstance(primaryChildInstance), }; fiber.stateNode = primaryChildInstance; return fiber; @@ -776,6 +778,8 @@ export function createFiberFromLegacyHidden( _pendingMarkers: null, _transitions: null, _retryCache: null, + _current: null, + detach: () => detachOffscreenInstance(instance), }; fiber.stateNode = instance; return fiber; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 66c679b424646..1f7d5c304c678 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -29,6 +29,7 @@ import type { OffscreenQueue, OffscreenInstance, } from './ReactFiberOffscreenComponent'; +import {OffscreenDetached} from './ReactFiberOffscreenComponent'; import type { Cache, CacheComponentState, @@ -37,7 +38,6 @@ import type { import type {UpdateQueue} from './ReactFiberClassUpdateQueue.new'; import type {RootState} from './ReactFiberRoot.new'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.new'; - import checkPropTypes from 'shared/checkPropTypes'; import { markComponentRenderStarted, @@ -688,7 +688,10 @@ function updateOffscreenComponent( if ( nextProps.mode === 'hidden' || - (enableLegacyHidden && nextProps.mode === 'unstable-defer-without-hiding') + (enableLegacyHidden && + nextProps.mode === 'unstable-defer-without-hiding') || + // TODO: remove read from stateNode. + 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 e5c3f1f4cb562..b3683ba0bcd64 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -29,6 +29,7 @@ import type { OffscreenQueue, OffscreenInstance, } from './ReactFiberOffscreenComponent'; +import {OffscreenDetached} from './ReactFiberOffscreenComponent'; import type { Cache, CacheComponentState, @@ -37,7 +38,6 @@ import type { import type {UpdateQueue} from './ReactFiberClassUpdateQueue.old'; import type {RootState} from './ReactFiberRoot.old'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.old'; - import checkPropTypes from 'shared/checkPropTypes'; import { markComponentRenderStarted, @@ -688,7 +688,10 @@ function updateOffscreenComponent( if ( nextProps.mode === 'hidden' || - (enableLegacyHidden && nextProps.mode === 'unstable-defer-without-hiding') + (enableLegacyHidden && + nextProps.mode === 'unstable-defer-without-hiding') || + // TODO: remove read from stateNode. + 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 343cc152d735c..248698a9cd224 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -22,6 +22,7 @@ import type {SuspenseState} from './ReactFiberSuspenseComponent.new'; import type {UpdateQueue} from './ReactFiberClassUpdateQueue.new'; import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.new'; import type {Wakeable} from 'shared/ReactTypes'; +import {isOffscreenManual} from './ReactFiberOffscreenComponent'; import type { OffscreenState, OffscreenInstance, @@ -153,6 +154,7 @@ import { clearSingleton, acquireSingletonInstance, releaseSingletonInstance, + scheduleMicrotask, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -169,6 +171,7 @@ import { setIsRunningInsertionEffect, getExecutionContext, CommitContext, + RenderContext, NoContext, } from './ReactFiberWorkLoop.new'; import { @@ -197,6 +200,7 @@ import {releaseCache, retainCache} from './ReactFiberCacheComponent.new'; import {clearTransitionsForLanes} from './ReactFiberLane.new'; import { OffscreenVisible, + OffscreenDetached, OffscreenPassiveEffectsConnected, } from './ReactFiberOffscreenComponent'; import { @@ -2426,6 +2430,28 @@ function getRetryCache(finishedWork) { } } +export function detachOffscreenInstance(instance: OffscreenInstance): void { + const currentOffscreenFiber = instance._current; + if (currentOffscreenFiber === 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); + } +} + function attachSuspenseRetryListeners( finishedWork: Fiber, wakeables: Set, @@ -2842,6 +2868,8 @@ function commitMutationEffectsOnFiber( } commitReconciliationEffects(finishedWork); + // TODO: Add explicit effect flag to set _current. + finishedWork.stateNode._current = finishedWork; if (flags & Visibility) { const offscreenInstance: OffscreenInstance = finishedWork.stateNode; @@ -2868,7 +2896,8 @@ function commitMutationEffectsOnFiber( } } - if (supportsMutation) { + // Offscreen with manual mode manages visibility manually. + if (supportsMutation && !isOffscreenManual(finishedWork)) { // 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 0dac9e775a7fb..7156a4c143db9 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -22,6 +22,7 @@ import type {SuspenseState} from './ReactFiberSuspenseComponent.old'; import type {UpdateQueue} from './ReactFiberClassUpdateQueue.old'; import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.old'; import type {Wakeable} from 'shared/ReactTypes'; +import {isOffscreenManual} from './ReactFiberOffscreenComponent'; import type { OffscreenState, OffscreenInstance, @@ -153,6 +154,7 @@ import { clearSingleton, acquireSingletonInstance, releaseSingletonInstance, + scheduleMicrotask, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -169,6 +171,7 @@ import { setIsRunningInsertionEffect, getExecutionContext, CommitContext, + RenderContext, NoContext, } from './ReactFiberWorkLoop.old'; import { @@ -197,6 +200,7 @@ import {releaseCache, retainCache} from './ReactFiberCacheComponent.old'; import {clearTransitionsForLanes} from './ReactFiberLane.old'; import { OffscreenVisible, + OffscreenDetached, OffscreenPassiveEffectsConnected, } from './ReactFiberOffscreenComponent'; import { @@ -2426,6 +2430,28 @@ function getRetryCache(finishedWork) { } } +export function detachOffscreenInstance(instance: OffscreenInstance): void { + const currentOffscreenFiber = instance._current; + if (currentOffscreenFiber === 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); + } +} + function attachSuspenseRetryListeners( finishedWork: Fiber, wakeables: Set, @@ -2842,6 +2868,8 @@ function commitMutationEffectsOnFiber( } commitReconciliationEffects(finishedWork); + // TODO: Add explicit effect flag to set _current. + finishedWork.stateNode._current = finishedWork; if (flags & Visibility) { const offscreenInstance: OffscreenInstance = finishedWork.stateNode; @@ -2868,7 +2896,8 @@ function commitMutationEffectsOnFiber( } } - if (supportsMutation) { + // Offscreen with manual mode manages visibility manually. + if (supportsMutation && !isOffscreenManual(finishedWork)) { // 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 195a6f8d54ffc..e85cb8e7015ab 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -27,6 +27,7 @@ import type { SuspenseState, SuspenseListRenderState, } from './ReactFiberSuspenseComponent.new'; +import {isOffscreenManual} from './ReactFiberOffscreenComponent'; import type {OffscreenState} from './ReactFiberOffscreenComponent'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.new'; import type {Cache} from './ReactFiberCacheComponent.new'; @@ -428,7 +429,14 @@ if (supportsMutation) { if (child !== null) { child.return = node; } - appendAllChildrenToContainer(containerChildSet, node, true, true); + // If Offscreen is not in manual mode, detached tree is hidden from user space. + const _needsVisibilityToggle = !isOffscreenManual(node); + 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 1ead0a8902311..d0bcc580df7f5 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -27,6 +27,7 @@ import type { SuspenseState, SuspenseListRenderState, } from './ReactFiberSuspenseComponent.old'; +import {isOffscreenManual} from './ReactFiberOffscreenComponent'; import type {OffscreenState} from './ReactFiberOffscreenComponent'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.old'; import type {Cache} from './ReactFiberCacheComponent.old'; @@ -428,7 +429,14 @@ if (supportsMutation) { if (child !== null) { child.return = node; } - appendAllChildrenToContainer(containerChildSet, node, true, true); + // If Offscreen is not in manual mode, detached tree is hidden from user space. + const _needsVisibilityToggle = !isOffscreenManual(node); + 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 fe2817f0f4377..dbebee78c0461 100644 --- a/packages/react-reconciler/src/ReactFiberOffscreenComponent.js +++ b/packages/react-reconciler/src/ReactFiberOffscreenComponent.js @@ -10,6 +10,7 @@ import type {ReactNodeList, OffscreenMode, Wakeable} from 'shared/ReactTypes'; import type {Lanes} from './ReactFiberLane.old'; import type {SpawnedCachePool} from './ReactFiberCacheComponent.new'; +import type {Fiber} from './ReactInternalTypes'; import type { Transition, TracingMarkerInstance, @@ -44,8 +45,9 @@ 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, @@ -53,4 +55,17 @@ export type OffscreenInstance = { _transitions: Set | null, // $FlowFixMe[incompatible-type-arg] found when upgrading Flow _retryCache: WeakSet | Set | null, + + // Represents the current Offscreen fiber + _current: Fiber | null, + detach: () => void, + + // TODO: attach }; + +export function isOffscreenManual(offscreenFiber: Fiber): boolean { + return ( + offscreenFiber.memoizedProps !== null && + offscreenFiber.memoizedProps.mode === 'manual' + ); +} diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 38994cdef5291..ed2f11c705687 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -290,7 +290,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 1dacb1fd5685e..bf7a4b02e7321 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -290,7 +290,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 ca505c785fe48..66bd613763960 100644 --- a/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js +++ b/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js @@ -1362,6 +1362,200 @@ 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( + <> + + + , + ); + }); }); // @gate enableOffscreen @@ -1430,7 +1624,6 @@ describe('ReactOffscreen', () => { }); expect(offscreenRef.current).not.toBeNull(); - await act(async () => { root.render(); }); @@ -1438,5 +1631,41 @@ describe('ReactOffscreen', () => { expect(offscreenRef.current).toBeNull(); }); + // @gate enableOffscreen + it('should change _current', async () => { + let offscreenRef; + const root = ReactNoop.createRoot(); + + function App({children}) { + offscreenRef = useRef(null); + return ( + + {children} + + ); + } + + await act(async () => { + root.render( + +
+ , + ); + }); + + expect(offscreenRef.current).not.toBeNull(); + const firstFiber = offscreenRef.current._current; + + await act(async () => { + root.render( + + + , + ); + }); + + expect(offscreenRef.current._current === firstFiber).toBeFalsy(); + }); + // TODO: When attach/detach methods are implemented. Add tests for nested Offscreen case. }); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 278c8d464089a..86c99af5d2e8d 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -440,5 +440,6 @@ "452": "React expected an element (document.documentElement) to exist in the Document but one was not found. React never removes the documentElement for any Document it renders into so the cause is likely in some other script running on this page.", "453": "React expected a element (document.head) to exist in the Document but one was not found. React never removes the head for any Document it renders into so the cause is likely in some other script running on this page.", "454": "React expected a element (document.body) to exist in the Document but one was not found. React never removes the body for any Document it renders into so the cause is likely in some other script running on this page.", - "455": "This CacheSignal was requested outside React which means that it is immediately aborted." -} \ No newline at end of file + "455": "This CacheSignal was requested outside React which means that it is immediately aborted.", + "456": "Calling Offscreen.detach before instance handle has been set." +}