Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions packages/react-devtools-shared/src/__tests__/store-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3142,4 +3142,105 @@ describe('Store', () => {
await actAsync(() => render(null));
expect(store).toMatchInlineSnapshot(``);
});

// @reactVersion >= 19
it('should keep suspended boundaries in the Suspense tree but not hidden Activity', async () => {
const Activity = React.Activity || React.unstable_Activity;

const never = new Promise(() => {});
function Never() {
readValue(never);
return null;
}
function Component({children}) {
return <div>{children}</div>;
}

function App({hidden}) {
return (
<>
<Activity mode={hidden ? 'hidden' : 'visible'}>
<React.Suspense name="inside-activity">
<Component key="inside-activity">inside Activity</Component>
</React.Suspense>
</Activity>
<React.Suspense name="outer-suspense">
<React.Suspense name="inner-suspense">
<Component key="inside-suspense">inside Suspense</Component>
</React.Suspense>
{hidden ? <Never /> : null}
</React.Suspense>
</>
);
}

await actAsync(() => {
render(<App hidden={true} />);
});

expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
<Activity>
<Suspense name="outer-suspense">
[suspense-root] rects={[{x:1,y:2,width:15,height:1}]}
<Suspense name="outer-suspense" rects={null}>
`);

// mount as visible
await actAsync(() => {
render(null);
});
await actAsync(() => {
render(<App hidden={false} />);
});

expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <Activity>
▾ <Suspense name="inside-activity">
<Component key="inside-activity">
▾ <Suspense name="outer-suspense">
▾ <Suspense name="inner-suspense">
<Component key="inside-suspense">
[suspense-root] rects={[{x:1,y:2,width:15,height:1}, {x:1,y:2,width:15,height:1}]}
<Suspense name="inside-activity" rects={[{x:1,y:2,width:15,height:1}]}>
<Suspense name="outer-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
<Suspense name="inner-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
`);

await actAsync(() => {
render(<App hidden={true} />);
});

expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
<Activity>
<Suspense name="outer-suspense">
[suspense-root] rects={[{x:1,y:2,width:15,height:1}, {x:1,y:2,width:15,height:1}]}
<Suspense name="outer-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
<Suspense name="inner-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
`);

await actAsync(() => {
render(<App hidden={false} />);
});

expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <Activity>
▾ <Suspense name="inside-activity">
<Component key="inside-activity">
▾ <Suspense name="outer-suspense">
▾ <Suspense name="inner-suspense">
<Component key="inside-suspense">
[suspense-root] rects={[{x:1,y:2,width:15,height:1}, {x:1,y:2,width:15,height:1}]}
<Suspense name="inside-activity" rects={[{x:1,y:2,width:15,height:1}]}>
<Suspense name="outer-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
<Suspense name="inner-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
`);
});
});
44 changes: 37 additions & 7 deletions packages/react-devtools-shared/src/backend/fiber/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3144,12 +3144,30 @@ export function attach(
}
}

/**
* Offscreen of suspended Suspense
*/
function isSuspendedOffscreen(fiber: Fiber): boolean {
switch (fiber.tag) {
case LegacyHiddenComponent:
// fallthrough since all published implementations currently implement the same state as Offscreen.
case OffscreenComponent:
return (
fiber.memoizedState !== null &&
fiber.return !== null &&
fiber.return.tag === SuspenseComponent
);
default:
return false;
}
}

function unmountRemainingChildren() {
if (
reconcilingParent !== null &&
(reconcilingParent.kind === FIBER_INSTANCE ||
reconcilingParent.kind === FILTERED_FIBER_INSTANCE) &&
isHiddenOffscreen(reconcilingParent.data) &&
isSuspendedOffscreen(reconcilingParent.data) &&
!isInDisconnectedSubtree
) {
// This is a hidden offscreen, we need to execute this in the context of a disconnected subtree.
Expand Down Expand Up @@ -4026,7 +4044,7 @@ export function attach(
trackDebugInfoFromHostComponent(nearestInstance, fiber);
}

if (isHiddenOffscreen(fiber)) {
if (isSuspendedOffscreen(fiber)) {
// If an Offscreen component is hidden, mount its children as disconnected.
const stashedDisconnected = isInDisconnectedSubtree;
isInDisconnectedSubtree = true;
Expand All @@ -4037,6 +4055,9 @@ export function attach(
} finally {
isInDisconnectedSubtree = stashedDisconnected;
}
} else if (isHiddenOffscreen(fiber)) {
// hidden Activity is noisy.
// Including it may show overlapping Suspense rects
} else if (fiber.tag === SuspenseComponent && OffscreenComponent === -1) {
// Legacy Suspense without the Offscreen wrapper. For the modern Suspense we just handle the
// Offscreen wrapper itself specially.
Expand Down Expand Up @@ -4981,6 +5002,8 @@ export function attach(

const prevWasHidden = isHiddenOffscreen(prevFiber);
const nextIsHidden = isHiddenOffscreen(nextFiber);
const prevWasSuspended = isSuspendedOffscreen(prevFiber);
const nextIsSuspended = isSuspendedOffscreen(nextFiber);

if (isLegacySuspense) {
if (
Expand Down Expand Up @@ -5058,8 +5081,8 @@ export function attach(
);
updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
}
} else if (nextIsHidden) {
if (!prevWasHidden) {
} else if (nextIsSuspended) {
if (!prevWasSuspended) {
// We're hiding the children. Disconnect them from the front end but keep state.
if (fiberInstance !== null && !isInDisconnectedSubtree) {
disconnectChildrenRecursively(remainingReconcilingChildren);
Expand All @@ -5077,7 +5100,7 @@ export function attach(
} finally {
isInDisconnectedSubtree = stashedDisconnected;
}
} else if (prevWasHidden && !nextIsHidden) {
} else if (prevWasSuspended && !nextIsSuspended) {
// We're revealing the hidden children. We now need to update them to the latest state.
// We do this while still in the disconnected state and then we reconnect the new ones.
// This avoids reconnecting things that are about to be removed anyway.
Expand All @@ -5103,6 +5126,13 @@ export function attach(
// Children may have reordered while they were hidden.
updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
}
} else if (nextIsHidden) {
if (prevWasHidden) {
// still hidden. Nothing to do.
} else {
// We're hiding the children. Remove them from the Frontend
unmountRemainingChildren();
}
} else if (
nextFiber.tag === SuspenseComponent &&
OffscreenComponent !== -1 &&
Expand Down Expand Up @@ -5259,7 +5289,7 @@ export function attach(
// We need to crawl the subtree for closest non-filtered Fibers
// so that we can display them in a flat children set.
if (fiberInstance !== null && fiberInstance.kind === FIBER_INSTANCE) {
if (!nextIsHidden && !isInDisconnectedSubtree) {
if (!nextIsSuspended && !isInDisconnectedSubtree) {
recordResetChildren(fiberInstance);
}

Expand Down Expand Up @@ -5335,7 +5365,7 @@ export function attach(
if (
(child.kind === FIBER_INSTANCE ||
child.kind === FILTERED_FIBER_INSTANCE) &&
isHiddenOffscreen(child.data)
isSuspendedOffscreen(child.data)
) {
// This instance's children are already disconnected.
} else {
Expand Down
Loading