From e16d61c3000e2de6217d06b9afad162e883f73c4 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 1 Jun 2021 15:46:08 -0400 Subject: [PATCH] [Offscreen] Mount/unmount layout effects (#21386) * [Offscreen] Mount/unmount layout effects Exposes the Offscreen component type and implements basic support for mount/unmounting layout effects when the visibility is toggled. Mostly it works the same way as hidden Suspense trees, which use the same internal fiber type. I had to add an extra bailout, though, that doesn't apply to the Suspense case but does apply to Offscreen components: a hidden Offscreen tree will eventually render at low priority, and when we it does, its `subtreeTag` will have effects scheduled on it. So I added a check to the layout phase where, if the subtree is hidden, we skip over the subtree entirely. An alternate design would be to clear the subtree flags in the render phase, but I prefer doing it this way since it's harder to mess up. We also need an API to enable the same thing for passive effects. This is not yet implemented. * Add test starting from hidden Co-authored-by: Rick Hanlon --- .../src/ReactFiberCommitWork.new.js | 47 +++--- .../src/ReactFiberCommitWork.old.js | 47 +++--- .../src/__tests__/ReactOffscreen-test.js | 150 ++++++++++++++++++ packages/react/index.classic.fb.js | 1 + packages/react/index.experimental.js | 1 + packages/react/index.js | 1 + packages/react/index.modern.fb.js | 1 + packages/react/src/React.js | 2 + packages/shared/isValidElementType.js | 2 + 9 files changed, 212 insertions(+), 40 deletions(-) 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) ) {