[DevTools] Attach async info in filtered fallback to parent of Suspense (#35456)
Sebastian "Sebbie" Silbermann committed
Jan 10, 2026 at 11:33 UTC
5aec1b2a8d1bcafb43a7ddb64dc37eacad081bea
2 files changed
+78
-1
packages/react-devtools-shared/src/__tests__/store-test.js
+53
@@ -3143,6 +3143,59 @@ describe('Store', () => {
3143
expect(store).toMatchInlineSnapshot(``);
3144
});
3145
3146
+ // @reactVersion >= 17.0
3147
+ it('should track suspended by in filtered fallback', async () => {
3148
+ function IgnoreMe({promise}) {
3149
+ return readValue(promise);
3150
+ }
3151
+
3152
+ function Component({promise}) {
3153
+ return readValue(promise);
3154
+ }
3155
+
3156
+ await actAsync(
3157
+ async () =>
3158
+ (store.componentFilters = [createDisplayNameFilter('^IgnoreMe', true)]),
3159
+ );
3160
+
3161
+ let resolveFallback;
3162
+ const fallbackPromise = new Promise(resolve => {
3163
+ resolveFallback = resolve;
3164
+ });
3165
+ let resolveContent;
3166
+ const contentPromise = new Promise(resolve => {
3167
+ resolveContent = resolve;
3168
+ });
3169
+
3170
+ await actAsync(() =>
3171
+ render(
3172
+ <React.Suspense
3173
+ name="main"
3174
+ fallback={<IgnoreMe promise={fallbackPromise} />}>
3175
+ <Component promise={contentPromise} />
3176
+ </React.Suspense>,
3177
+ ),
3178
+ );
3179
+ expect(store).toMatchInlineSnapshot(``);
3180
+
3181
+ await actAsync(() => resolveFallback('loading'));
3182
+ expect(store).toMatchInlineSnapshot(`
3183
+ [root]
3184
+ <Suspense name="main">
3185
+ [suspense-root] rects={null}
3186
+ <Suspense name="main" rects={null}>
3187
+ `);
3188
+
3189
+ await actAsync(() => resolveContent('content'));
3190
+ expect(store).toMatchInlineSnapshot(`
3191
+ [root]
3192
+ ▾ <Suspense name="main">
3193
+ <Component>
3194
+ [suspense-root] rects={null}
3195
+ <Suspense name="main" rects={null}>
3196
+ `);
3197
+ });
3198
+
3199
// @reactVersion >= 19
3200
it('should keep suspended boundaries in the Suspense tree but not hidden Activity', async () => {
3201
const Activity = React.Activity || React.unstable_Activity;
packages/react-devtools-shared/src/backend/fiber/renderer.js
+25
-1
@@ -2992,6 +2992,30 @@ export function attach(
2992
) {
2993
parentInstance = parentInstance.parent;
2994
}
2995
+ if (parentInstance.kind === FIBER_INSTANCE) {
2996
+ const fiber = parentInstance.data;
2997
+
2998
+ if (
2999
+ fiber.tag === SuspenseComponent &&
3000
+ parentInstance !== parentSuspenseNode.instance
3001
+ ) {
3002
+ // We're about to attach async info to a Suspense boundary we're not
3003
+ // actually considering the parent Suspense boundary for this async info.
3004
+ // We must have not found a suitable Fiber inside the fallback (e.g. due to filtering).
3005
+ // Use the parent of this instance instead since we treat async info
3006
+ // attached to a Suspense boundary as that async info triggering the
3007
+ // fallback of that boundary.
3008
+ const parent = parentInstance.parent;
3009
+ if (parent === null) {
3010
+ // This shouldn't happen. Any <Suspense> would have at least have the
3011
+ // host root as the parent which can't have a fallback.
3012
+ throw new Error(
3013
+ 'Did not find a suitable instance for this async info. This is a bug in React.',
3014
+ );
3015
+ }
3016
+ parentInstance = parent;
3017
+ }
3018
+ }
3019
3020
const suspenseNodeSuspendedBy = parentSuspenseNode.suspendedBy;
3021
const ioInfo = asyncInfo.awaited;
@@ -5255,9 +5279,9 @@ export function attach(
5279
// It might even result in a bad user experience for e.g. node selection in the Elements panel.
5280
// The easiest fix is to strip out the intermediate Fragment fibers,
5281
// so the Elements panel and Profiler don't need to special case them.
5258
- // Suspense components only have a non-null memoizedState if they're timed-out.
5282
const isLegacySuspense =
5283
nextFiber.tag === SuspenseComponent && OffscreenComponent === -1;
5284
+ // Suspense components only have a non-null memoizedState if they're timed-out.
5285
const prevDidTimeout =
5286
isLegacySuspense && prevFiber.memoizedState !== null;
5287
const nextDidTimeOut =