Skip to content

Commit e16d61c

Browse files
[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 <rickhanlonii@gmail.com>
1 parent 6309193 commit e16d61c

9 files changed

+212
-40
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: 150 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,150 @@ describe('ReactOffscreen', () => {
169173
</>,
170174
);
171175
});
176+
177+
// @gate experimental
178+
// @gate enableSuspenseLayoutEffectSemantics
179+
it('mounts without layout effects when hidden', 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 (starting visible)', 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+
});
268+
269+
// @gate experimental
270+
// @gate enableSuspenseLayoutEffectSemantics
271+
it('mounts/unmounts layout effects when visibility changes (starting hidden)', async () => {
272+
function Child({text}) {
273+
useLayoutEffect(() => {
274+
Scheduler.unstable_yieldValue('Mount layout');
275+
return () => {
276+
Scheduler.unstable_yieldValue('Unmount layout');
277+
};
278+
}, []);
279+
return <Text text="Child" />;
280+
}
281+
282+
const root = ReactNoop.createRoot();
283+
await ReactNoop.act(async () => {
284+
// Start the tree hidden. The layout effect is not mounted.
285+
root.render(
286+
<Offscreen mode="hidden">
287+
<Child />
288+
</Offscreen>,
289+
);
290+
});
291+
expect(Scheduler).toHaveYielded(['Child']);
292+
// TODO: Offscreen does not yet hide/unhide children correctly. Until we do,
293+
// it should only be used inside a host component wrapper whose visibility
294+
// is toggled simultaneously.
295+
expect(root).toMatchRenderedOutput(<span prop="Child" />);
296+
297+
// Show the tree. The layout effect is mounted.
298+
await ReactNoop.act(async () => {
299+
root.render(
300+
<Offscreen mode="visible">
301+
<Child />
302+
</Offscreen>,
303+
);
304+
});
305+
expect(Scheduler).toHaveYielded(['Child', 'Mount layout']);
306+
expect(root).toMatchRenderedOutput(<span prop="Child" />);
307+
308+
// Hide the tree again. The layout effect is un-mounted.
309+
await ReactNoop.act(async () => {
310+
root.render(
311+
<Offscreen mode="hidden">
312+
<Child />
313+
</Offscreen>,
314+
);
315+
});
316+
expect(Scheduler).toHaveYielded(['Unmount layout', 'Child']);
317+
// TODO: Offscreen does not yet hide/unhide children correctly. Until we do,
318+
// it should only be used inside a host component wrapper whose visibility
319+
// is toggled simultaneously.
320+
expect(root).toMatchRenderedOutput(<span prop="Child" />);
321+
});
172322
});

packages/react/index.classic.fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export {
3434
unstable_Cache,
3535
unstable_DebugTracingMode,
3636
unstable_LegacyHidden,
37+
unstable_Offscreen,
3738
unstable_Scope,
3839
unstable_getCacheForType,
3940
unstable_useCacheRefresh,

packages/react/index.experimental.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export {
3131
unstable_Cache,
3232
unstable_DebugTracingMode,
3333
unstable_LegacyHidden,
34+
unstable_Offscreen,
3435
unstable_getCacheForType,
3536
unstable_useCacheRefresh,
3637
unstable_useOpaqueIdentifier,

packages/react/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export {
5555
unstable_Cache,
5656
unstable_DebugTracingMode,
5757
unstable_LegacyHidden,
58+
unstable_Offscreen,
5859
unstable_Scope,
5960
unstable_getCacheForType,
6061
unstable_useCacheRefresh,

packages/react/index.modern.fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export {
3333
unstable_Cache,
3434
unstable_DebugTracingMode,
3535
unstable_LegacyHidden,
36+
unstable_Offscreen,
3637
unstable_Scope,
3738
unstable_getCacheForType,
3839
unstable_useCacheRefresh,

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