From c44fbf43b1e05417619c3b9411d0559824739569 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Wed, 24 Sep 2025 22:50:12 +0200 Subject: [PATCH] [DevTools] Fix instrumentation error when reconciling promise-as-a-child (#34587) --- .../src/__tests__/store-test.js | 41 +++++++++++++++++++ .../src/backend/fiber/renderer.js | 21 ++++++---- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 4d4d5b6affc4f..48a42f71045b4 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -3107,4 +3107,45 @@ describe('Store', () => { await actAsync(() => render()); expect(store).toMatchInlineSnapshot(`[root]`); }); + + // @reactVersion >= 19.0 + it('should reconcile promise-as-a-child', async () => { + function Component({children}) { + return
{children}
; + } + + await actAsync(() => + render( + + {Promise.resolve(A)} + , + ), + ); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + + [suspense-root] rects={[{x:1,y:2,width:1,height:1}]} + + `); + + await actAsync(() => + render( + + {Promise.resolve(not A)} + , + ), + ); + + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + + [suspense-root] rects={[{x:1,y:2,width:5,height:1}]} + + `); + + await actAsync(() => render(null)); + expect(store).toMatchInlineSnapshot(``); + }); }); diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 39179bc6f472f..f2b6700163719 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2886,9 +2886,16 @@ export function attach( previousSuspendedBy: null | Array, parentSuspenseNode: null | SuspenseNode, ): void { - // Remove any async info from the parent, if they were in the previous set but + // Remove any async info if they were in the previous set but // is no longer in the new set. - if (previousSuspendedBy !== null && parentSuspenseNode !== null) { + // If we just reconciled a SuspenseNode, we need to remove from that node instead of the parent. + // This is different from inserting because inserting is done during reconiliation + // whereas removal is done after we're done reconciling. + const suspenseNode = + instance.suspenseNode === null + ? parentSuspenseNode + : instance.suspenseNode; + if (previousSuspendedBy !== null && suspenseNode !== null) { const nextSuspendedBy = instance.suspendedBy; for (let i = 0; i < previousSuspendedBy.length; i++) { const asyncInfo = previousSuspendedBy[i]; @@ -2901,7 +2908,7 @@ export function attach( // This IO entry is no longer blocking the current tree. // Let's remove it from the parent SuspenseNode. const ioInfo = asyncInfo.awaited; - const suspendedBySet = parentSuspenseNode.suspendedBy.get(ioInfo); + const suspendedBySet = suspenseNode.suspendedBy.get(ioInfo); if ( suspendedBySet === undefined || @@ -2928,16 +2935,16 @@ export function attach( } } if (suspendedBySet !== undefined && suspendedBySet.size === 0) { - parentSuspenseNode.suspendedBy.delete(asyncInfo.awaited); + suspenseNode.suspendedBy.delete(asyncInfo.awaited); } if ( - parentSuspenseNode.hasUniqueSuspenders && - !ioExistsInSuspenseAncestor(parentSuspenseNode, ioInfo) + suspenseNode.hasUniqueSuspenders && + !ioExistsInSuspenseAncestor(suspenseNode, ioInfo) ) { // This entry wasn't in any ancestor and is no longer in this suspense boundary. // This means that a child might now be the unique suspender for this IO. // Search the child boundaries to see if we can reveal any of them. - unblockSuspendedBy(parentSuspenseNode, ioInfo); + unblockSuspendedBy(suspenseNode, ioInfo); } } }