[DevTools] Fix instrumentation error when reconciling promise-as-a-child (#34587)
Sebastian "Sebbie" Silbermann committed
Sep 24, 2025 at 22:50 UTC
c44fbf43b1e05417619c3b9411d0559824739569
2 files changed
+55
-7
packages/react-devtools-shared/src/__tests__/store-test.js
+41
@@ -3107,4 +3107,45 @@ describe('Store', () => {
3107
await actAsync(() => render(<span />));
3108
expect(store).toMatchInlineSnapshot(`[root]`);
3109
});
3110
+
3111
+ // @reactVersion >= 19.0
3112
+ it('should reconcile promise-as-a-child', async () => {
3113
+ function Component({children}) {
3114
+ return <div>{children}</div>;
3115
+ }
3116
+
3117
+ await actAsync(() =>
3118
+ render(
3119
+ <React.Suspense>
3120
+ {Promise.resolve(<Component key="A">A</Component>)}
3121
+ </React.Suspense>,
3122
+ ),
3123
+ );
3124
+ expect(store).toMatchInlineSnapshot(`
3125
+ [root]
3126
+ ▾ <Suspense>
3127
+ <Component key="A">
3128
+ [suspense-root] rects={[{x:1,y:2,width:1,height:1}]}
3129
+ <Suspense name="Unknown" rects={[{x:1,y:2,width:1,height:1}]}>
3130
+ `);
3131
+
3132
+ await actAsync(() =>
3133
+ render(
3134
+ <React.Suspense>
3135
+ {Promise.resolve(<Component key="not-A">not A</Component>)}
3136
+ </React.Suspense>,
3137
+ ),
3138
+ );
3139
+
3140
+ expect(store).toMatchInlineSnapshot(`
3141
+ [root]
3142
+ ▾ <Suspense>
3143
+ <Component key="not-A">
3144
+ [suspense-root] rects={[{x:1,y:2,width:5,height:1}]}
3145
+ <Suspense name="Unknown" rects={[{x:1,y:2,width:5,height:1}]}>
3146
+ `);
3147
+
3148
+ await actAsync(() => render(null));
3149
+ expect(store).toMatchInlineSnapshot(``);
3150
+ });
3151
});
packages/react-devtools-shared/src/backend/fiber/renderer.js
+14
-7
@@ -2886,9 +2886,16 @@ export function attach(
2886
previousSuspendedBy: null | Array<ReactAsyncInfo>,
2887
parentSuspenseNode: null | SuspenseNode,
2888
): void {
2889
- // Remove any async info from the parent, if they were in the previous set but
2889
+ // Remove any async info if they were in the previous set but
2890
// is no longer in the new set.
2891
- if (previousSuspendedBy !== null && parentSuspenseNode !== null) {
2891
+ // If we just reconciled a SuspenseNode, we need to remove from that node instead of the parent.
2892
+ // This is different from inserting because inserting is done during reconiliation
2893
+ // whereas removal is done after we're done reconciling.
2894
+ const suspenseNode =
2895
+ instance.suspenseNode === null
2896
+ ? parentSuspenseNode
2897
+ : instance.suspenseNode;
2898
+ if (previousSuspendedBy !== null && suspenseNode !== null) {
2899
const nextSuspendedBy = instance.suspendedBy;
2900
for (let i = 0; i < previousSuspendedBy.length; i++) {
2901
const asyncInfo = previousSuspendedBy[i];
@@ -2901,7 +2908,7 @@ export function attach(
2908
// This IO entry is no longer blocking the current tree.
2909
// Let's remove it from the parent SuspenseNode.
2910
const ioInfo = asyncInfo.awaited;
2904
- const suspendedBySet = parentSuspenseNode.suspendedBy.get(ioInfo);
2911
+ const suspendedBySet = suspenseNode.suspendedBy.get(ioInfo);
2912
2913
if (
2914
suspendedBySet === undefined ||
@@ -2928,16 +2935,16 @@ export function attach(
2935
}
2936
}
2937
if (suspendedBySet !== undefined && suspendedBySet.size === 0) {
2931
- parentSuspenseNode.suspendedBy.delete(asyncInfo.awaited);
2938
+ suspenseNode.suspendedBy.delete(asyncInfo.awaited);
2939
}
2940
if (
2934
- parentSuspenseNode.hasUniqueSuspenders &&
2935
- !ioExistsInSuspenseAncestor(parentSuspenseNode, ioInfo)
2941
+ suspenseNode.hasUniqueSuspenders &&
2942
+ !ioExistsInSuspenseAncestor(suspenseNode, ioInfo)
2943
) {
2944
// This entry wasn't in any ancestor and is no longer in this suspense boundary.
2945
// This means that a child might now be the unique suspender for this IO.
2946
// Search the child boundaries to see if we can reveal any of them.
2940
- unblockSuspendedBy(parentSuspenseNode, ioInfo);
2947
+ unblockSuspendedBy(suspenseNode, ioInfo);
2948
}
2949
}
2950
}