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 4b75fd0f78d3e..b94328007cfe7 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 f44afa3b408af..f9701df2fc962 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 a879f457aaf0f..1b4c338994982 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -25,6 +25,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, @@ -156,6 +157,7 @@ import { clearSingleton, acquireSingletonInstance, releaseSingletonInstance, + scheduleMicrotask, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -172,6 +174,7 @@ import { setIsRunningInsertionEffect, getExecutionContext, CommitContext, + RenderContext, NoContext, } from './ReactFiberWorkLoop.new'; import { @@ -200,6 +203,7 @@ import {releaseCache, retainCache} from './ReactFiberCacheComponent.new'; import {clearTransitionsForLanes} from './ReactFiberLane.new'; import { OffscreenVisible, + OffscreenDetached, OffscreenPassiveEffectsConnected, } from './ReactFiberOffscreenComponent'; import { @@ -2416,6 +2420,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, @@ -2845,6 +2871,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; @@ -2871,7 +2899,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 0b772d65077fe..0217c00fd4d6a 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -25,6 +25,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, @@ -156,6 +157,7 @@ import { clearSingleton, acquireSingletonInstance, releaseSingletonInstance, + scheduleMicrotask, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -172,6 +174,7 @@ import { setIsRunningInsertionEffect, getExecutionContext, CommitContext, + RenderContext, NoContext, } from './ReactFiberWorkLoop.old'; import { @@ -200,6 +203,7 @@ import {releaseCache, retainCache} from './ReactFiberCacheComponent.old'; import {clearTransitionsForLanes} from './ReactFiberLane.old'; import { OffscreenVisible, + OffscreenDetached, OffscreenPassiveEffectsConnected, } from './ReactFiberOffscreenComponent'; import { @@ -2416,6 +2420,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, @@ -2845,6 +2871,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; @@ -2871,7 +2899,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 9031a733fc86b..5d3987698e5dc 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -26,6 +26,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'; @@ -425,7 +426,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 ac35eeab58cec..6c92f523fb873 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -26,6 +26,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'; @@ -425,7 +426,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 172b956d8fe7f..750ca10d36176 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 a439b90e40843..e3470f18df666 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 23afdb85c34cd..82c61942924f5 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." +}