@samitouri / QOS-React-2 / commits / e16d61c300

[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>

Andrew Clark committed Jun 1, 2021 at 15:46 UTC e16d61c3000e2de6217d06b9afad162e883f73c4
9 files changed +212 -40
packages/react-reconciler/src/ReactFiberCommitWork.new.js
+27 -20
@@ -2306,33 +2306,40 @@ function commitLayoutEffects_begin(
2306 const fiber = nextEffect;
2307 const firstChild = fiber.child;
2308
2309 - if (enableSuspenseLayoutEffectSemantics && isModernRoot) {
2309 + if (
2310 + enableSuspenseLayoutEffectSemantics &&
2311 + fiber.tag === OffscreenComponent &&
2312 + isModernRoot
2313 + ) {
2314 // 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;
2327 const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden;
2328 const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
2329
2330 // Traverse the Offscreen subtree with the current Offscreen as the root.
2331 offscreenSubtreeIsHidden = newOffscreenSubtreeIsHidden;
2332 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 + }
2343
2344 // Restore Offscreen state and resume in our-progress traversal.
2345 nextEffect = fiber;
packages/react-reconciler/src/ReactFiberCommitWork.old.js
+27 -20
@@ -2306,33 +2306,40 @@ function commitLayoutEffects_begin(
2306 const fiber = nextEffect;
2307 const firstChild = fiber.child;
2308
2309 - if (enableSuspenseLayoutEffectSemantics && isModernRoot) {
2309 + if (
2310 + enableSuspenseLayoutEffectSemantics &&
2311 + fiber.tag === OffscreenComponent &&
2312 + isModernRoot
2313 + ) {
2314 // 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;
2327 const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden;
2328 const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
2329
2330 // Traverse the Offscreen subtree with the current Offscreen as the root.
2331 offscreenSubtreeIsHidden = newOffscreenSubtreeIsHidden;
2332 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 + }
2343
2344 // Restore Offscreen state and resume in our-progress traversal.
2345 nextEffect = fiber;
packages/react-reconciler/src/__tests__/ReactOffscreen-test.js
+150
@@ -2,7 +2,9 @@ let React;
2 let ReactNoop;
3 let Scheduler;
4 let LegacyHidden;
5 +let Offscreen;
6 let useState;
7 +let useLayoutEffect;
8
9 describe('ReactOffscreen', () => {
10 beforeEach(() => {
@@ -12,7 +14,9 @@ describe('ReactOffscreen', () => {
14 ReactNoop = require('react-noop-renderer');
15 Scheduler = require('scheduler');
16 LegacyHidden = React.unstable_LegacyHidden;
17 + Offscreen = React.unstable_Offscreen;
18 useState = React.useState;
19 + useLayoutEffect = React.useLayoutEffect;
20 });
21
22 function Text(props) {
@@ -169,4 +173,150 @@ describe('ReactOffscreen', () => {
173 </>,
174 );
175 });
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 + });
322 });
packages/react/index.classic.fb.js
+1
@@ -34,6 +34,7 @@ export {
34 unstable_Cache,
35 unstable_DebugTracingMode,
36 unstable_LegacyHidden,
37 + unstable_Offscreen,
38 unstable_Scope,
39 unstable_getCacheForType,
40 unstable_useCacheRefresh,
packages/react/index.experimental.js
+1
@@ -31,6 +31,7 @@ export {
31 unstable_Cache,
32 unstable_DebugTracingMode,
33 unstable_LegacyHidden,
34 + unstable_Offscreen,
35 unstable_getCacheForType,
36 unstable_useCacheRefresh,
37 unstable_useOpaqueIdentifier,
packages/react/index.js
+1
@@ -55,6 +55,7 @@ export {
55 unstable_Cache,
56 unstable_DebugTracingMode,
57 unstable_LegacyHidden,
58 + unstable_Offscreen,
59 unstable_Scope,
60 unstable_getCacheForType,
61 unstable_useCacheRefresh,
packages/react/index.modern.fb.js
+1
@@ -33,6 +33,7 @@ export {
33 unstable_Cache,
34 unstable_DebugTracingMode,
35 unstable_LegacyHidden,
36 + unstable_Offscreen,
37 unstable_Scope,
38 unstable_getCacheForType,
39 unstable_useCacheRefresh,
packages/react/src/React.js
+2
@@ -16,6 +16,7 @@ import {
16 REACT_SUSPENSE_TYPE,
17 REACT_SUSPENSE_LIST_TYPE,
18 REACT_LEGACY_HIDDEN_TYPE,
19 + REACT_OFFSCREEN_TYPE,
20 REACT_SCOPE_TYPE,
21 REACT_CACHE_TYPE,
22 } from 'shared/ReactSymbols';
@@ -112,6 +113,7 @@ export {
113 useDeferredValue,
114 REACT_SUSPENSE_LIST_TYPE as SuspenseList,
115 REACT_LEGACY_HIDDEN_TYPE as unstable_LegacyHidden,
116 + REACT_OFFSCREEN_TYPE as unstable_Offscreen,
117 getCacheForType as unstable_getCacheForType,
118 useCacheRefresh as unstable_useCacheRefresh,
119 REACT_CACHE_TYPE as unstable_Cache,
packages/shared/isValidElementType.js
+2
@@ -21,6 +21,7 @@ import {
21 REACT_LAZY_TYPE,
22 REACT_SCOPE_TYPE,
23 REACT_LEGACY_HIDDEN_TYPE,
24 + REACT_OFFSCREEN_TYPE,
25 REACT_CACHE_TYPE,
26 } from 'shared/ReactSymbols';
27 import {enableScopeAPI, enableCache} from './ReactFeatureFlags';
@@ -44,6 +45,7 @@ export default function isValidElementType(type: mixed) {
45 type === REACT_SUSPENSE_TYPE ||
46 type === REACT_SUSPENSE_LIST_TYPE ||
47 type === REACT_LEGACY_HIDDEN_TYPE ||
48 + type === REACT_OFFSCREEN_TYPE ||
49 (enableScopeAPI && type === REACT_SCOPE_TYPE) ||
50 (enableCache && type === REACT_CACHE_TYPE)
51 ) {