@samitouri / QOS-React-1 / commits / 73507ec457

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