Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Offscreen] Mount/unmount layout effects #21386

Merged
merged 2 commits into from Jun 1, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
47 changes: 27 additions & 20 deletions packages/react-reconciler/src/ReactFiberCommitWork.new.js
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
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
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
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
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
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
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
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
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