[DevTools] Only block child Suspense boundaries if the parent has all shared suspenders removed (#35737)
Sebastian "Sebbie" Silbermann committed
Feb 10, 2026 at 17:52 UTC
57b79b0388b755096216b2b5308113e54eac3be8
2 files changed
+107
-9
packages/react-devtools-shared/src/__tests__/store-test.js
+97
@@ -3617,6 +3617,103 @@ describe('Store', () => {
3617
`);
3618
});
3619
3620
+ // @reactVersion >= 17.0
3621
+ it('continues to consider Suspense boundary as blocking if some child still is suspended on removed io', async () => {
3622
+ function Component({promise}) {
3623
+ readValue(promise);
3624
+ return null;
3625
+ }
3626
+
3627
+ let resolve;
3628
+ const promise = new Promise(_resolve => {
3629
+ resolve = _resolve;
3630
+ });
3631
+
3632
+ await actAsync(() => {
3633
+ render(
3634
+ <React.Suspense fallback={null} name="outer">
3635
+ <Component key="one" promise={promise} />
3636
+ <Component key="two" promise={promise} />
3637
+ <React.Suspense fallback={null} name="inner">
3638
+ <Component key="three" promise={promise} />
3639
+ </React.Suspense>
3640
+ </React.Suspense>,
3641
+ );
3642
+ });
3643
+
3644
+ expect(store).toMatchInlineSnapshot(`
3645
+ [root]
3646
+ <Suspense name="outer">
3647
+ [suspense-root] rects={null}
3648
+ <Suspense name="outer" uniqueSuspenders={true} rects={null}>
3649
+ `);
3650
+
3651
+ await actAsync(() => {
3652
+ resolve('Hello, World!');
3653
+ });
3654
+
3655
+ expect(store).toMatchInlineSnapshot(`
3656
+ [root]
3657
+ ▾ <Suspense name="outer">
3658
+ <Component key="one">
3659
+ <Component key="two">
3660
+ ▾ <Suspense name="inner">
3661
+ <Component key="three">
3662
+ [suspense-root] rects={null}
3663
+ <Suspense name="outer" uniqueSuspenders={true} rects={null}>
3664
+ <Suspense name="inner" uniqueSuspenders={false} rects={null}>
3665
+ `);
3666
+
3667
+ // We remove one suspender.
3668
+ // The inner one shouldn't have unique suspenders because it's still blocked
3669
+ // by the outer one.
3670
+ await actAsync(() => {
3671
+ render(
3672
+ <React.Suspense fallback={null} name="outer">
3673
+ <Component key="one" promise={promise} />
3674
+ <React.Suspense fallback={null} name="inner">
3675
+ <Component key="three" promise={promise} />
3676
+ </React.Suspense>
3677
+ </React.Suspense>,
3678
+ );
3679
+ });
3680
+
3681
+ expect(store).toMatchInlineSnapshot(`
3682
+ [root]
3683
+ ▾ <Suspense name="outer">
3684
+ <Component key="one">
3685
+ ▾ <Suspense name="inner">
3686
+ <Component key="three">
3687
+ [suspense-root] rects={null}
3688
+ <Suspense name="outer" uniqueSuspenders={true} rects={null}>
3689
+ <Suspense name="inner" uniqueSuspenders={false} rects={null}>
3690
+ `);
3691
+
3692
+ // Now we remove all unique suspenders of the outer Suspense boundary.
3693
+ // The inner one is now independently revealed from the parent and should
3694
+ // be marked as having unique suspenders.
3695
+ // TODO: The outer boundary no longer has unique suspenders.
3696
+ await actAsync(() => {
3697
+ render(
3698
+ <React.Suspense fallback={null} name="outer">
3699
+ <React.Suspense fallback={null} name="inner">
3700
+ <Component key="three" promise={promise} />
3701
+ </React.Suspense>
3702
+ </React.Suspense>,
3703
+ );
3704
+ });
3705
+
3706
+ expect(store).toMatchInlineSnapshot(`
3707
+ [root]
3708
+ ▾ <Suspense name="outer">
3709
+ ▾ <Suspense name="inner">
3710
+ <Component key="three">
3711
+ [suspense-root] rects={null}
3712
+ <Suspense name="outer" uniqueSuspenders={true} rects={null}>
3713
+ <Suspense name="inner" uniqueSuspenders={true} rects={null}>
3714
+ `);
3715
+ });
3716
+
3717
// @reactVersion >= 19
3718
it('cleans up host hoistables', async () => {
3719
function Left() {
packages/react-devtools-shared/src/backend/fiber/renderer.js
+10
-9
@@ -3197,15 +3197,16 @@ export function attach(
3197
environmentCounts.set(env, count - 1);
3198
}
3199
}
3200
- }
3201
- if (
3202
- suspenseNode.hasUniqueSuspenders &&
3203
- !ioExistsInSuspenseAncestor(suspenseNode, ioInfo)
3204
- ) {
3205
- // This entry wasn't in any ancestor and is no longer in this suspense boundary.
3206
- // This means that a child might now be the unique suspender for this IO.
3207
- // Search the child boundaries to see if we can reveal any of them.
3208
- unblockSuspendedBy(suspenseNode, ioInfo);
3200
+
3201
+ if (
3202
+ suspenseNode.hasUniqueSuspenders &&
3203
+ !ioExistsInSuspenseAncestor(suspenseNode, ioInfo)
3204
+ ) {
3205
+ // This entry wasn't in any ancestor and is no longer in this suspense boundary.
3206
+ // This means that a child might now be the unique suspender for this IO.
3207
+ // Search the child boundaries to see if we can reveal any of them.
3208
+ unblockSuspendedBy(suspenseNode, ioInfo);
3209
+ }
3210
}
3211
}
3212
}