diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 46f19294562c1..0f6bfdee73d16 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -2306,33 +2306,40 @@ function commitLayoutEffects_begin( const fiber = nextEffect; const firstChild = fiber.child; - if (enableSuspenseLayoutEffectSemantics && isModernRoot) { + if ( + enableSuspenseLayoutEffectSemantics && + fiber.tag === OffscreenComponent && + isModernRoot + ) { // Keep track of the current Offscreen stack's state. - if (fiber.tag === OffscreenComponent) { - const current = fiber.alternate; - const wasHidden = current !== null && current.memoizedState !== null; - const isHidden = fiber.memoizedState !== null; - - const newOffscreenSubtreeIsHidden = - isHidden || offscreenSubtreeIsHidden; - const newOffscreenSubtreeWasHidden = - wasHidden || offscreenSubtreeWasHidden; - - if ( - newOffscreenSubtreeIsHidden !== offscreenSubtreeIsHidden || - newOffscreenSubtreeWasHidden !== offscreenSubtreeWasHidden - ) { + const isHidden = fiber.memoizedState !== null; + const newOffscreenSubtreeIsHidden = isHidden || offscreenSubtreeIsHidden; + if (newOffscreenSubtreeIsHidden) { + // The Offscreen tree is hidden. Skip over its layout effects. + commitLayoutMountEffects_complete(subtreeRoot, root, committedLanes); + continue; + } else { + if ((fiber.subtreeFlags & LayoutMask) !== NoFlags) { + const current = fiber.alternate; + const wasHidden = current !== null && current.memoizedState !== null; + const newOffscreenSubtreeWasHidden = + wasHidden || offscreenSubtreeWasHidden; const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden; const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden; // Traverse the Offscreen subtree with the current Offscreen as the root. offscreenSubtreeIsHidden = newOffscreenSubtreeIsHidden; offscreenSubtreeWasHidden = newOffscreenSubtreeWasHidden; - commitLayoutEffects_begin( - fiber, // New root; bubble back up to here and stop. - root, - committedLanes, - ); + let child = firstChild; + while (child !== null) { + nextEffect = child; + commitLayoutEffects_begin( + child, // New root; bubble back up to here and stop. + root, + committedLanes, + ); + child = child.sibling; + } // Restore Offscreen state and resume in our-progress traversal. nextEffect = fiber; diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index f45a44083e236..63942e1a6936d 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -2306,33 +2306,40 @@ function commitLayoutEffects_begin( const fiber = nextEffect; const firstChild = fiber.child; - if (enableSuspenseLayoutEffectSemantics && isModernRoot) { + if ( + enableSuspenseLayoutEffectSemantics && + fiber.tag === OffscreenComponent && + isModernRoot + ) { // Keep track of the current Offscreen stack's state. - if (fiber.tag === OffscreenComponent) { - const current = fiber.alternate; - const wasHidden = current !== null && current.memoizedState !== null; - const isHidden = fiber.memoizedState !== null; - - const newOffscreenSubtreeIsHidden = - isHidden || offscreenSubtreeIsHidden; - const newOffscreenSubtreeWasHidden = - wasHidden || offscreenSubtreeWasHidden; - - if ( - newOffscreenSubtreeIsHidden !== offscreenSubtreeIsHidden || - newOffscreenSubtreeWasHidden !== offscreenSubtreeWasHidden - ) { + const isHidden = fiber.memoizedState !== null; + const newOffscreenSubtreeIsHidden = isHidden || offscreenSubtreeIsHidden; + if (newOffscreenSubtreeIsHidden) { + // The Offscreen tree is hidden. Skip over its layout effects. + commitLayoutMountEffects_complete(subtreeRoot, root, committedLanes); + continue; + } else { + if ((fiber.subtreeFlags & LayoutMask) !== NoFlags) { + const current = fiber.alternate; + const wasHidden = current !== null && current.memoizedState !== null; + const newOffscreenSubtreeWasHidden = + wasHidden || offscreenSubtreeWasHidden; const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden; const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden; // Traverse the Offscreen subtree with the current Offscreen as the root. offscreenSubtreeIsHidden = newOffscreenSubtreeIsHidden; offscreenSubtreeWasHidden = newOffscreenSubtreeWasHidden; - commitLayoutEffects_begin( - fiber, // New root; bubble back up to here and stop. - root, - committedLanes, - ); + let child = firstChild; + while (child !== null) { + nextEffect = child; + commitLayoutEffects_begin( + child, // New root; bubble back up to here and stop. + root, + committedLanes, + ); + child = child.sibling; + } // Restore Offscreen state and resume in our-progress traversal. nextEffect = fiber; diff --git a/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js b/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js index d04203089c22b..da6778aefc894 100644 --- a/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js +++ b/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js @@ -2,7 +2,9 @@ let React; let ReactNoop; let Scheduler; let LegacyHidden; +let Offscreen; let useState; +let useLayoutEffect; describe('ReactOffscreen', () => { beforeEach(() => { @@ -12,7 +14,9 @@ describe('ReactOffscreen', () => { ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); LegacyHidden = React.unstable_LegacyHidden; + Offscreen = React.unstable_Offscreen; useState = React.useState; + useLayoutEffect = React.useLayoutEffect; }); function Text(props) { @@ -169,4 +173,150 @@ describe('ReactOffscreen', () => { , ); }); + + // @gate experimental + // @gate enableSuspenseLayoutEffectSemantics + it('mounts without layout effects when hidden', async () => { + function Child({text}) { + useLayoutEffect(() => { + Scheduler.unstable_yieldValue('Mount layout'); + return () => { + Scheduler.unstable_yieldValue('Unmount layout'); + }; + }, []); + return ; + } + + const root = ReactNoop.createRoot(); + + // Mount hidden tree. + await ReactNoop.act(async () => { + root.render( + + + , + ); + }); + // No layout effect. + expect(Scheduler).toHaveYielded(['Child']); + // TODO: Offscreen does not yet hide/unhide children correctly. Until we do, + // it should only be used inside a host component wrapper whose visibility + // is toggled simultaneously. + expect(root).toMatchRenderedOutput(); + + // Unhide the tree. The layout effect is mounted. + await ReactNoop.act(async () => { + root.render( + + + , + ); + }); + expect(Scheduler).toHaveYielded(['Child', 'Mount layout']); + expect(root).toMatchRenderedOutput(); + }); + + // @gate experimental + // @gate enableSuspenseLayoutEffectSemantics + it('mounts/unmounts layout effects when visibility changes (starting visible)', async () => { + function Child({text}) { + useLayoutEffect(() => { + Scheduler.unstable_yieldValue('Mount layout'); + return () => { + Scheduler.unstable_yieldValue('Unmount layout'); + }; + }, []); + return ; + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render( + + + , + ); + }); + expect(Scheduler).toHaveYielded(['Child', 'Mount layout']); + expect(root).toMatchRenderedOutput(); + + // Hide the tree. The layout effect is unmounted. + await ReactNoop.act(async () => { + root.render( + + + , + ); + }); + expect(Scheduler).toHaveYielded(['Unmount layout', 'Child']); + // TODO: Offscreen does not yet hide/unhide children correctly. Until we do, + // it should only be used inside a host component wrapper whose visibility + // is toggled simultaneously. + expect(root).toMatchRenderedOutput(); + + // Unhide the tree. The layout effect is re-mounted. + await ReactNoop.act(async () => { + root.render( + + + , + ); + }); + expect(Scheduler).toHaveYielded(['Child', 'Mount layout']); + expect(root).toMatchRenderedOutput(); + }); + + // @gate experimental + // @gate enableSuspenseLayoutEffectSemantics + it('mounts/unmounts layout effects when visibility changes (starting hidden)', async () => { + function Child({text}) { + useLayoutEffect(() => { + Scheduler.unstable_yieldValue('Mount layout'); + return () => { + Scheduler.unstable_yieldValue('Unmount layout'); + }; + }, []); + return ; + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + // Start the tree hidden. The layout effect is not mounted. + root.render( + + + , + ); + }); + expect(Scheduler).toHaveYielded(['Child']); + // TODO: Offscreen does not yet hide/unhide children correctly. Until we do, + // it should only be used inside a host component wrapper whose visibility + // is toggled simultaneously. + expect(root).toMatchRenderedOutput(); + + // Show the tree. The layout effect is mounted. + await ReactNoop.act(async () => { + root.render( + + + , + ); + }); + expect(Scheduler).toHaveYielded(['Child', 'Mount layout']); + expect(root).toMatchRenderedOutput(); + + // Hide the tree again. The layout effect is un-mounted. + await ReactNoop.act(async () => { + root.render( + + + , + ); + }); + expect(Scheduler).toHaveYielded(['Unmount layout', 'Child']); + // TODO: Offscreen does not yet hide/unhide children correctly. Until we do, + // it should only be used inside a host component wrapper whose visibility + // is toggled simultaneously. + expect(root).toMatchRenderedOutput(); + }); }); diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index 43ae345b1eb28..15f3447a111f8 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -34,6 +34,7 @@ export { unstable_Cache, unstable_DebugTracingMode, unstable_LegacyHidden, + unstable_Offscreen, unstable_Scope, unstable_getCacheForType, unstable_useCacheRefresh, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 4fbc1664ab490..5c7197b282cdc 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -31,6 +31,7 @@ export { unstable_Cache, unstable_DebugTracingMode, unstable_LegacyHidden, + unstable_Offscreen, unstable_getCacheForType, unstable_useCacheRefresh, unstable_useOpaqueIdentifier, diff --git a/packages/react/index.js b/packages/react/index.js index bc77959248f05..59e76eff92fa3 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -55,6 +55,7 @@ export { unstable_Cache, unstable_DebugTracingMode, unstable_LegacyHidden, + unstable_Offscreen, unstable_Scope, unstable_getCacheForType, unstable_useCacheRefresh, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index 64073b0b8949e..47b629832b332 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -33,6 +33,7 @@ export { unstable_Cache, unstable_DebugTracingMode, unstable_LegacyHidden, + unstable_Offscreen, unstable_Scope, unstable_getCacheForType, unstable_useCacheRefresh, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 70c92ff86820e..a99d2a331b20a 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -16,6 +16,7 @@ import { REACT_SUSPENSE_TYPE, REACT_SUSPENSE_LIST_TYPE, REACT_LEGACY_HIDDEN_TYPE, + REACT_OFFSCREEN_TYPE, REACT_SCOPE_TYPE, REACT_CACHE_TYPE, } from 'shared/ReactSymbols'; @@ -112,6 +113,7 @@ export { useDeferredValue, REACT_SUSPENSE_LIST_TYPE as SuspenseList, REACT_LEGACY_HIDDEN_TYPE as unstable_LegacyHidden, + REACT_OFFSCREEN_TYPE as unstable_Offscreen, getCacheForType as unstable_getCacheForType, useCacheRefresh as unstable_useCacheRefresh, REACT_CACHE_TYPE as unstable_Cache, diff --git a/packages/shared/isValidElementType.js b/packages/shared/isValidElementType.js index 7ad6b0766a663..78b6d2fc27545 100644 --- a/packages/shared/isValidElementType.js +++ b/packages/shared/isValidElementType.js @@ -21,6 +21,7 @@ import { REACT_LAZY_TYPE, REACT_SCOPE_TYPE, REACT_LEGACY_HIDDEN_TYPE, + REACT_OFFSCREEN_TYPE, REACT_CACHE_TYPE, } from 'shared/ReactSymbols'; import {enableScopeAPI, enableCache} from './ReactFeatureFlags'; @@ -44,6 +45,7 @@ export default function isValidElementType(type: mixed) { type === REACT_SUSPENSE_TYPE || type === REACT_SUSPENSE_LIST_TYPE || type === REACT_LEGACY_HIDDEN_TYPE || + type === REACT_OFFSCREEN_TYPE || (enableScopeAPI && type === REACT_SCOPE_TYPE) || (enableCache && type === REACT_CACHE_TYPE) ) {