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)
) {