@samitouri / QOS-React / commits / c44fbf43b1

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