Skip to content

Commit 3633e8d

Browse files
committed
[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.
1 parent 57768ef commit 3633e8d

File tree

10 files changed

+160
-41
lines changed

10 files changed

+160
-41
lines changed

packages/react-reconciler/src/ReactFiberCommitWork.new.js

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2306,33 +2306,40 @@ function commitLayoutEffects_begin(
23062306
const fiber = nextEffect;
23072307
const firstChild = fiber.child;
23082308

2309-
if (enableSuspenseLayoutEffectSemantics && isModernRoot) {
2309+
if (
2310+
enableSuspenseLayoutEffectSemantics &&
2311+
fiber.tag === OffscreenComponent &&
2312+
isModernRoot
2313+
) {
23102314
// Keep track of the current Offscreen stack's state.
2311-
if (fiber.tag === OffscreenComponent) {
2312-
const current = fiber.alternate;
2313-
const wasHidden = current !== null && current.memoizedState !== null;
2314-
const isHidden = fiber.memoizedState !== null;
2315-
2316-
const newOffscreenSubtreeIsHidden =
2317-
isHidden || offscreenSubtreeIsHidden;
2318-
const newOffscreenSubtreeWasHidden =
2319-
wasHidden || offscreenSubtreeWasHidden;
2320-
2321-
if (
2322-
newOffscreenSubtreeIsHidden !== offscreenSubtreeIsHidden ||
2323-
newOffscreenSubtreeWasHidden !== offscreenSubtreeWasHidden
2324-
) {
2315+
const isHidden = fiber.memoizedState !== null;
2316+
const newOffscreenSubtreeIsHidden = isHidden || offscreenSubtreeIsHidden;
2317+
if (newOffscreenSubtreeIsHidden) {
2318+
// The Offscreen tree is hidden. Skip over its layout effects.
2319+
commitLayoutMountEffects_complete(subtreeRoot, root, committedLanes);
2320+
continue;
2321+
} else {
2322+
if ((fiber.subtreeFlags & LayoutMask) !== NoFlags) {
2323+
const current = fiber.alternate;
2324+
const wasHidden = current !== null && current.memoizedState !== null;
2325+
const newOffscreenSubtreeWasHidden =
2326+
wasHidden || offscreenSubtreeWasHidden;
23252327
const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden;
23262328
const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
23272329

23282330
// Traverse the Offscreen subtree with the current Offscreen as the root.
23292331
offscreenSubtreeIsHidden = newOffscreenSubtreeIsHidden;
23302332
offscreenSubtreeWasHidden = newOffscreenSubtreeWasHidden;
2331-
commitLayoutEffects_begin(
2332-
fiber, // New root; bubble back up to here and stop.
2333-
root,
2334-
committedLanes,
2335-
);
2333+
let child = firstChild;
2334+
while (child !== null) {
2335+
nextEffect = child;
2336+
commitLayoutEffects_begin(
2337+
child, // New root; bubble back up to here and stop.
2338+
root,
2339+
committedLanes,
2340+
);
2341+
child = child.sibling;
2342+
}
23362343

23372344
// Restore Offscreen state and resume in our-progress traversal.
23382345
nextEffect = fiber;

packages/react-reconciler/src/ReactFiberCommitWork.old.js

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2306,33 +2306,40 @@ function commitLayoutEffects_begin(
23062306
const fiber = nextEffect;
23072307
const firstChild = fiber.child;
23082308

2309-
if (enableSuspenseLayoutEffectSemantics && isModernRoot) {
2309+
if (
2310+
enableSuspenseLayoutEffectSemantics &&
2311+
fiber.tag === OffscreenComponent &&
2312+
isModernRoot
2313+
) {
23102314
// Keep track of the current Offscreen stack's state.
2311-
if (fiber.tag === OffscreenComponent) {
2312-
const current = fiber.alternate;
2313-
const wasHidden = current !== null && current.memoizedState !== null;
2314-
const isHidden = fiber.memoizedState !== null;
2315-
2316-
const newOffscreenSubtreeIsHidden =
2317-
isHidden || offscreenSubtreeIsHidden;
2318-
const newOffscreenSubtreeWasHidden =
2319-
wasHidden || offscreenSubtreeWasHidden;
2320-
2321-
if (
2322-
newOffscreenSubtreeIsHidden !== offscreenSubtreeIsHidden ||
2323-
newOffscreenSubtreeWasHidden !== offscreenSubtreeWasHidden
2324-
) {
2315+
const isHidden = fiber.memoizedState !== null;
2316+
const newOffscreenSubtreeIsHidden = isHidden || offscreenSubtreeIsHidden;
2317+
if (newOffscreenSubtreeIsHidden) {
2318+
// The Offscreen tree is hidden. Skip over its layout effects.
2319+
commitLayoutMountEffects_complete(subtreeRoot, root, committedLanes);
2320+
continue;
2321+
} else {
2322+
if ((fiber.subtreeFlags & LayoutMask) !== NoFlags) {
2323+
const current = fiber.alternate;
2324+
const wasHidden = current !== null && current.memoizedState !== null;
2325+
const newOffscreenSubtreeWasHidden =
2326+
wasHidden || offscreenSubtreeWasHidden;
23252327
const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden;
23262328
const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
23272329

23282330
// Traverse the Offscreen subtree with the current Offscreen as the root.
23292331
offscreenSubtreeIsHidden = newOffscreenSubtreeIsHidden;
23302332
offscreenSubtreeWasHidden = newOffscreenSubtreeWasHidden;
2331-
commitLayoutEffects_begin(
2332-
fiber, // New root; bubble back up to here and stop.
2333-
root,
2334-
committedLanes,
2335-
);
2333+
let child = firstChild;
2334+
while (child !== null) {
2335+
nextEffect = child;
2336+
commitLayoutEffects_begin(
2337+
child, // New root; bubble back up to here and stop.
2338+
root,
2339+
committedLanes,
2340+
);
2341+
child = child.sibling;
2342+
}
23362343

23372344
// Restore Offscreen state and resume in our-progress traversal.
23382345
nextEffect = fiber;

packages/react-reconciler/src/__tests__/ReactOffscreen-test.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ let React;
22
let ReactNoop;
33
let Scheduler;
44
let LegacyHidden;
5+
let Offscreen;
56
let useState;
7+
let useLayoutEffect;
68

79
describe('ReactOffscreen', () => {
810
beforeEach(() => {
@@ -12,7 +14,9 @@ describe('ReactOffscreen', () => {
1214
ReactNoop = require('react-noop-renderer');
1315
Scheduler = require('scheduler');
1416
LegacyHidden = React.unstable_LegacyHidden;
17+
Offscreen = React.unstable_Offscreen;
1518
useState = React.useState;
19+
useLayoutEffect = React.useLayoutEffect;
1620
});
1721

1822
function Text(props) {
@@ -169,4 +173,96 @@ describe('ReactOffscreen', () => {
169173
</>,
170174
);
171175
});
176+
177+
// @gate experimental
178+
// @gate enableSuspenseLayoutEffectSemantics
179+
it('mounts without layout effects', async () => {
180+
function Child({text}) {
181+
useLayoutEffect(() => {
182+
Scheduler.unstable_yieldValue('Mount layout');
183+
return () => {
184+
Scheduler.unstable_yieldValue('Unmount layout');
185+
};
186+
}, []);
187+
return <Text text="Child" />;
188+
}
189+
190+
const root = ReactNoop.createRoot();
191+
192+
// Mount hidden tree.
193+
await ReactNoop.act(async () => {
194+
root.render(
195+
<Offscreen mode="hidden">
196+
<Child />
197+
</Offscreen>,
198+
);
199+
});
200+
// No layout effect.
201+
expect(Scheduler).toHaveYielded(['Child']);
202+
// TODO: Offscreen does not yet hide/unhide children correctly. Until we do,
203+
// it should only be used inside a host component wrapper whose visibility
204+
// is toggled simultaneously.
205+
expect(root).toMatchRenderedOutput(<span prop="Child" />);
206+
207+
// Unhide the tree. The layout effect is mounted.
208+
await ReactNoop.act(async () => {
209+
root.render(
210+
<Offscreen mode="visible">
211+
<Child />
212+
</Offscreen>,
213+
);
214+
});
215+
expect(Scheduler).toHaveYielded(['Child', 'Mount layout']);
216+
expect(root).toMatchRenderedOutput(<span prop="Child" />);
217+
});
218+
219+
// @gate experimental
220+
// @gate enableSuspenseLayoutEffectSemantics
221+
it('mounts/unmounts layout effects when visibility changes', async () => {
222+
function Child({text}) {
223+
useLayoutEffect(() => {
224+
Scheduler.unstable_yieldValue('Mount layout');
225+
return () => {
226+
Scheduler.unstable_yieldValue('Unmount layout');
227+
};
228+
}, []);
229+
return <Text text="Child" />;
230+
}
231+
232+
const root = ReactNoop.createRoot();
233+
await ReactNoop.act(async () => {
234+
root.render(
235+
<Offscreen mode="visible">
236+
<Child />
237+
</Offscreen>,
238+
);
239+
});
240+
expect(Scheduler).toHaveYielded(['Child', 'Mount layout']);
241+
expect(root).toMatchRenderedOutput(<span prop="Child" />);
242+
243+
// Hide the tree. The layout effect is unmounted.
244+
await ReactNoop.act(async () => {
245+
root.render(
246+
<Offscreen mode="hidden">
247+
<Child />
248+
</Offscreen>,
249+
);
250+
});
251+
expect(Scheduler).toHaveYielded(['Unmount layout', 'Child']);
252+
// TODO: Offscreen does not yet hide/unhide children correctly. Until we do,
253+
// it should only be used inside a host component wrapper whose visibility
254+
// is toggled simultaneously.
255+
expect(root).toMatchRenderedOutput(<span prop="Child" />);
256+
257+
// Unhide the tree. The layout effect is re-mounted.
258+
await ReactNoop.act(async () => {
259+
root.render(
260+
<Offscreen mode="visible">
261+
<Child />
262+
</Offscreen>,
263+
);
264+
});
265+
expect(Scheduler).toHaveYielded(['Child', 'Mount layout']);
266+
expect(root).toMatchRenderedOutput(<span prop="Child" />);
267+
});
172268
});

packages/react/index.classic.fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export {
3535
StrictMode,
3636
Suspense,
3737
unstable_LegacyHidden,
38+
unstable_Offscreen,
3839
createElement,
3940
cloneElement,
4041
isValidElement,

packages/react/index.experimental.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export {
3333
StrictMode,
3434
Suspense,
3535
unstable_LegacyHidden,
36+
unstable_Offscreen,
3637
createElement,
3738
cloneElement,
3839
isValidElement,

packages/react/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export {
7474
SuspenseList,
7575
SuspenseList as unstable_SuspenseList,
7676
unstable_LegacyHidden,
77+
unstable_Offscreen,
7778
unstable_Scope,
7879
unstable_useOpaqueIdentifier,
7980
unstable_getCacheForType,

packages/react/index.modern.fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export {
3535
StrictMode,
3636
Suspense,
3737
unstable_LegacyHidden,
38+
unstable_Offscreen,
3839
createElement,
3940
cloneElement,
4041
isValidElement,

packages/react/src/React.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
REACT_SUSPENSE_TYPE,
1717
REACT_SUSPENSE_LIST_TYPE,
1818
REACT_LEGACY_HIDDEN_TYPE,
19+
REACT_OFFSCREEN_TYPE,
1920
REACT_SCOPE_TYPE,
2021
REACT_CACHE_TYPE,
2122
} from 'shared/ReactSymbols';
@@ -112,6 +113,7 @@ export {
112113
useDeferredValue,
113114
REACT_SUSPENSE_LIST_TYPE as SuspenseList,
114115
REACT_LEGACY_HIDDEN_TYPE as unstable_LegacyHidden,
116+
REACT_OFFSCREEN_TYPE as unstable_Offscreen,
115117
getCacheForType as unstable_getCacheForType,
116118
useCacheRefresh as unstable_useCacheRefresh,
117119
REACT_CACHE_TYPE as unstable_Cache,

packages/shared/forks/ReactFeatureFlags.native-fb.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ export const enableFilterEmptyStringAttributesDOM = false;
4747
export const disableNativeComponentFrames = false;
4848
export const skipUnmountedBoundaries = false;
4949
export const deletedTreeCleanUpLevel = 1;
50-
export const enableSuspenseLayoutEffectSemantics = false;
50+
// TODO: Temporarily hard-coding this to `true` to unblock internal testing.
51+
export const enableSuspenseLayoutEffectSemantics = true;
5152

5253
export const enableNewReconciler = false;
5354
export const deferRenderPhaseUpdateToNextBatch = true;

packages/shared/isValidElementType.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
REACT_LAZY_TYPE,
2222
REACT_SCOPE_TYPE,
2323
REACT_LEGACY_HIDDEN_TYPE,
24+
REACT_OFFSCREEN_TYPE,
2425
REACT_CACHE_TYPE,
2526
} from 'shared/ReactSymbols';
2627
import {enableScopeAPI, enableCache} from './ReactFeatureFlags';
@@ -44,6 +45,7 @@ export default function isValidElementType(type: mixed) {
4445
type === REACT_SUSPENSE_TYPE ||
4546
type === REACT_SUSPENSE_LIST_TYPE ||
4647
type === REACT_LEGACY_HIDDEN_TYPE ||
48+
type === REACT_OFFSCREEN_TYPE ||
4749
(enableScopeAPI && type === REACT_SCOPE_TYPE) ||
4850
(enableCache && type === REACT_CACHE_TYPE)
4951
) {

0 commit comments

Comments
 (0)