Skip to content

Commit

Permalink
[Offscreen] Mount/unmount layout effects (#21386)
Browse files Browse the repository at this point in the history
* [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>
  • Loading branch information
acdlite and rickhanlonii authored Jun 1, 2021
1 parent 6309193 commit e16d61c
Show file tree
Hide file tree
Showing 9 changed files with 212 additions and 40 deletions.
47 changes: 27 additions & 20 deletions packages/react-reconciler/src/ReactFiberCommitWork.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 27 additions & 20 deletions packages/react-reconciler/src/ReactFiberCommitWork.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
150 changes: 150 additions & 0 deletions packages/react-reconciler/src/__tests__/ReactOffscreen-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ let React;
let ReactNoop;
let Scheduler;
let LegacyHidden;
let Offscreen;
let useState;
let useLayoutEffect;

describe('ReactOffscreen', () => {
beforeEach(() => {
Expand All @@ -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) {
Expand Down Expand Up @@ -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 <Text text="Child" />;
}

const root = ReactNoop.createRoot();

// Mount hidden tree.
await ReactNoop.act(async () => {
root.render(
<Offscreen mode="hidden">
<Child />
</Offscreen>,
);
});
// 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(<span prop="Child" />);

// Unhide the tree. The layout effect is mounted.
await ReactNoop.act(async () => {
root.render(
<Offscreen mode="visible">
<Child />
</Offscreen>,
);
});
expect(Scheduler).toHaveYielded(['Child', 'Mount layout']);
expect(root).toMatchRenderedOutput(<span prop="Child" />);
});

// @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 <Text text="Child" />;
}

const root = ReactNoop.createRoot();
await ReactNoop.act(async () => {
root.render(
<Offscreen mode="visible">
<Child />
</Offscreen>,
);
});
expect(Scheduler).toHaveYielded(['Child', 'Mount layout']);
expect(root).toMatchRenderedOutput(<span prop="Child" />);

// Hide the tree. The layout effect is unmounted.
await ReactNoop.act(async () => {
root.render(
<Offscreen mode="hidden">
<Child />
</Offscreen>,
);
});
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(<span prop="Child" />);

// Unhide the tree. The layout effect is re-mounted.
await ReactNoop.act(async () => {
root.render(
<Offscreen mode="visible">
<Child />
</Offscreen>,
);
});
expect(Scheduler).toHaveYielded(['Child', 'Mount layout']);
expect(root).toMatchRenderedOutput(<span prop="Child" />);
});

// @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 <Text text="Child" />;
}

const root = ReactNoop.createRoot();
await ReactNoop.act(async () => {
// Start the tree hidden. The layout effect is not mounted.
root.render(
<Offscreen mode="hidden">
<Child />
</Offscreen>,
);
});
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(<span prop="Child" />);

// Show the tree. The layout effect is mounted.
await ReactNoop.act(async () => {
root.render(
<Offscreen mode="visible">
<Child />
</Offscreen>,
);
});
expect(Scheduler).toHaveYielded(['Child', 'Mount layout']);
expect(root).toMatchRenderedOutput(<span prop="Child" />);

// Hide the tree again. The layout effect is un-mounted.
await ReactNoop.act(async () => {
root.render(
<Offscreen mode="hidden">
<Child />
</Offscreen>,
);
});
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(<span prop="Child" />);
});
});
1 change: 1 addition & 0 deletions packages/react/index.classic.fb.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export {
unstable_Cache,
unstable_DebugTracingMode,
unstable_LegacyHidden,
unstable_Offscreen,
unstable_Scope,
unstable_getCacheForType,
unstable_useCacheRefresh,
Expand Down
1 change: 1 addition & 0 deletions packages/react/index.experimental.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export {
unstable_Cache,
unstable_DebugTracingMode,
unstable_LegacyHidden,
unstable_Offscreen,
unstable_getCacheForType,
unstable_useCacheRefresh,
unstable_useOpaqueIdentifier,
Expand Down
1 change: 1 addition & 0 deletions packages/react/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export {
unstable_Cache,
unstable_DebugTracingMode,
unstable_LegacyHidden,
unstable_Offscreen,
unstable_Scope,
unstable_getCacheForType,
unstable_useCacheRefresh,
Expand Down
1 change: 1 addition & 0 deletions packages/react/index.modern.fb.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export {
unstable_Cache,
unstable_DebugTracingMode,
unstable_LegacyHidden,
unstable_Offscreen,
unstable_Scope,
unstable_getCacheForType,
unstable_useCacheRefresh,
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/React.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/isValidElementType.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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)
) {
Expand Down

0 comments on commit e16d61c

Please sign in to comment.