[DevTools] Exclude Suspense boundaries in hidden Activity (#34756)
Sebastian "Sebbie" Silbermann committed
Oct 14, 2025 at 13:57 UTC
73507ec457349263bffa4c8132b2674664d5f421
2 files changed
+138
-7
packages/react-devtools-shared/src/__tests__/store-test.js
+101
@@ -3142,4 +3142,105 @@ describe('Store', () => {
3142
await actAsync(() => render(null));
3143
expect(store).toMatchInlineSnapshot(``);
3144
});
3145
+
3146
+ // @reactVersion >= 19
3147
+ it('should keep suspended boundaries in the Suspense tree but not hidden Activity', async () => {
3148
+ const Activity = React.Activity || React.unstable_Activity;
3149
+
3150
+ const never = new Promise(() => {});
3151
+ function Never() {
3152
+ readValue(never);
3153
+ return null;
3154
+ }
3155
+ function Component({children}) {
3156
+ return <div>{children}</div>;
3157
+ }
3158
+
3159
+ function App({hidden}) {
3160
+ return (
3161
+ <>
3162
+ <Activity mode={hidden ? 'hidden' : 'visible'}>
3163
+ <React.Suspense name="inside-activity">
3164
+ <Component key="inside-activity">inside Activity</Component>
3165
+ </React.Suspense>
3166
+ </Activity>
3167
+ <React.Suspense name="outer-suspense">
3168
+ <React.Suspense name="inner-suspense">
3169
+ <Component key="inside-suspense">inside Suspense</Component>
3170
+ </React.Suspense>
3171
+ {hidden ? <Never /> : null}
3172
+ </React.Suspense>
3173
+ </>
3174
+ );
3175
+ }
3176
+
3177
+ await actAsync(() => {
3178
+ render(<App hidden={true} />);
3179
+ });
3180
+
3181
+ expect(store).toMatchInlineSnapshot(`
3182
+ [root]
3183
+ ▾ <App>
3184
+ <Activity>
3185
+ <Suspense name="outer-suspense">
3186
+ [suspense-root] rects={[{x:1,y:2,width:15,height:1}]}
3187
+ <Suspense name="outer-suspense" rects={null}>
3188
+ `);
3189
+
3190
+ // mount as visible
3191
+ await actAsync(() => {
3192
+ render(null);
3193
+ });
3194
+ await actAsync(() => {
3195
+ render(<App hidden={false} />);
3196
+ });
3197
+
3198
+ expect(store).toMatchInlineSnapshot(`
3199
+ [root]
3200
+ ▾ <App>
3201
+ ▾ <Activity>
3202
+ ▾ <Suspense name="inside-activity">
3203
+ <Component key="inside-activity">
3204
+ ▾ <Suspense name="outer-suspense">
3205
+ ▾ <Suspense name="inner-suspense">
3206
+ <Component key="inside-suspense">
3207
+ [suspense-root] rects={[{x:1,y:2,width:15,height:1}, {x:1,y:2,width:15,height:1}]}
3208
+ <Suspense name="inside-activity" rects={[{x:1,y:2,width:15,height:1}]}>
3209
+ <Suspense name="outer-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
3210
+ <Suspense name="inner-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
3211
+ `);
3212
+
3213
+ await actAsync(() => {
3214
+ render(<App hidden={true} />);
3215
+ });
3216
+
3217
+ expect(store).toMatchInlineSnapshot(`
3218
+ [root]
3219
+ ▾ <App>
3220
+ <Activity>
3221
+ <Suspense name="outer-suspense">
3222
+ [suspense-root] rects={[{x:1,y:2,width:15,height:1}, {x:1,y:2,width:15,height:1}]}
3223
+ <Suspense name="outer-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
3224
+ <Suspense name="inner-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
3225
+ `);
3226
+
3227
+ await actAsync(() => {
3228
+ render(<App hidden={false} />);
3229
+ });
3230
+
3231
+ expect(store).toMatchInlineSnapshot(`
3232
+ [root]
3233
+ ▾ <App>
3234
+ ▾ <Activity>
3235
+ ▾ <Suspense name="inside-activity">
3236
+ <Component key="inside-activity">
3237
+ ▾ <Suspense name="outer-suspense">
3238
+ ▾ <Suspense name="inner-suspense">
3239
+ <Component key="inside-suspense">
3240
+ [suspense-root] rects={[{x:1,y:2,width:15,height:1}, {x:1,y:2,width:15,height:1}]}
3241
+ <Suspense name="inside-activity" rects={[{x:1,y:2,width:15,height:1}]}>
3242
+ <Suspense name="outer-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
3243
+ <Suspense name="inner-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
3244
+ `);
3245
+ });
3246
});
packages/react-devtools-shared/src/backend/fiber/renderer.js
+37
-7
@@ -3144,12 +3144,30 @@ export function attach(
3144
}
3145
}
3146
3147
+ /**
3148
+ * Offscreen of suspended Suspense
3149
+ */
3150
+ function isSuspendedOffscreen(fiber: Fiber): boolean {
3151
+ switch (fiber.tag) {
3152
+ case LegacyHiddenComponent:
3153
+ // fallthrough since all published implementations currently implement the same state as Offscreen.
3154
+ case OffscreenComponent:
3155
+ return (
3156
+ fiber.memoizedState !== null &&
3157
+ fiber.return !== null &&
3158
+ fiber.return.tag === SuspenseComponent
3159
+ );
3160
+ default:
3161
+ return false;
3162
+ }
3163
+ }
3164
+
3165
function unmountRemainingChildren() {
3166
if (
3167
reconcilingParent !== null &&
3168
(reconcilingParent.kind === FIBER_INSTANCE ||
3169
reconcilingParent.kind === FILTERED_FIBER_INSTANCE) &&
3152
- isHiddenOffscreen(reconcilingParent.data) &&
3170
+ isSuspendedOffscreen(reconcilingParent.data) &&
3171
!isInDisconnectedSubtree
3172
) {
3173
// This is a hidden offscreen, we need to execute this in the context of a disconnected subtree.
@@ -4026,7 +4044,7 @@ export function attach(
4044
trackDebugInfoFromHostComponent(nearestInstance, fiber);
4045
}
4046
4029
- if (isHiddenOffscreen(fiber)) {
4047
+ if (isSuspendedOffscreen(fiber)) {
4048
// If an Offscreen component is hidden, mount its children as disconnected.
4049
const stashedDisconnected = isInDisconnectedSubtree;
4050
isInDisconnectedSubtree = true;
@@ -4037,6 +4055,9 @@ export function attach(
4055
} finally {
4056
isInDisconnectedSubtree = stashedDisconnected;
4057
}
4058
+ } else if (isHiddenOffscreen(fiber)) {
4059
+ // hidden Activity is noisy.
4060
+ // Including it may show overlapping Suspense rects
4061
} else if (fiber.tag === SuspenseComponent && OffscreenComponent === -1) {
4062
// Legacy Suspense without the Offscreen wrapper. For the modern Suspense we just handle the
4063
// Offscreen wrapper itself specially.
@@ -4981,6 +5002,8 @@ export function attach(
5002
5003
const prevWasHidden = isHiddenOffscreen(prevFiber);
5004
const nextIsHidden = isHiddenOffscreen(nextFiber);
5005
+ const prevWasSuspended = isSuspendedOffscreen(prevFiber);
5006
+ const nextIsSuspended = isSuspendedOffscreen(nextFiber);
5007
5008
if (isLegacySuspense) {
5009
if (
@@ -5058,8 +5081,8 @@ export function attach(
5081
);
5082
updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
5083
}
5061
- } else if (nextIsHidden) {
5062
- if (!prevWasHidden) {
5084
+ } else if (nextIsSuspended) {
5085
+ if (!prevWasSuspended) {
5086
// We're hiding the children. Disconnect them from the front end but keep state.
5087
if (fiberInstance !== null && !isInDisconnectedSubtree) {
5088
disconnectChildrenRecursively(remainingReconcilingChildren);
@@ -5077,7 +5100,7 @@ export function attach(
5100
} finally {
5101
isInDisconnectedSubtree = stashedDisconnected;
5102
}
5080
- } else if (prevWasHidden && !nextIsHidden) {
5103
+ } else if (prevWasSuspended && !nextIsSuspended) {
5104
// We're revealing the hidden children. We now need to update them to the latest state.
5105
// We do this while still in the disconnected state and then we reconnect the new ones.
5106
// This avoids reconnecting things that are about to be removed anyway.
@@ -5103,6 +5126,13 @@ export function attach(
5126
// Children may have reordered while they were hidden.
5127
updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
5128
}
5129
+ } else if (nextIsHidden) {
5130
+ if (prevWasHidden) {
5131
+ // still hidden. Nothing to do.
5132
+ } else {
5133
+ // We're hiding the children. Remove them from the Frontend
5134
+ unmountRemainingChildren();
5135
+ }
5136
} else if (
5137
nextFiber.tag === SuspenseComponent &&
5138
OffscreenComponent !== -1 &&
@@ -5259,7 +5289,7 @@ export function attach(
5289
// We need to crawl the subtree for closest non-filtered Fibers
5290
// so that we can display them in a flat children set.
5291
if (fiberInstance !== null && fiberInstance.kind === FIBER_INSTANCE) {
5262
- if (!nextIsHidden && !isInDisconnectedSubtree) {
5292
+ if (!nextIsSuspended && !isInDisconnectedSubtree) {
5293
recordResetChildren(fiberInstance);
5294
}
5295
@@ -5335,7 +5365,7 @@ export function attach(
5365
if (
5366
(child.kind === FIBER_INSTANCE ||
5367
child.kind === FILTERED_FIBER_INSTANCE) &&
5338
- isHiddenOffscreen(child.data)
5368
+ isSuspendedOffscreen(child.data)
5369
) {
5370
// This instance's children are already disconnected.
5371
} else {