diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 0a62c089dd13..a848d04750fc 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -558,6 +558,78 @@ describe('Store', () => { expect(leaf.displayName).toBe('Leaf'); expect(leaf.isInsideHiddenActivity).toBe(true); }); + + // Regression test for https://github.com/facebook/react/issues/36315 + // and https://github.com/facebook/react/issues/36375. + // @reactVersion >= 19 + it('should not throw when a child of hidden Activity is removed while a parent Suspense is suspended', async () => { + const Activity = React.Activity || React.unstable_Activity; + + let resolve; + const never = new Promise(_resolve => { + resolve = _resolve; + }); + + function Suspender() { + readValue(never); + return null; + } + + function Child() { + return
child
; + } + + function App({showChild, suspend}) { + return ( + Loading}> + {suspend ? : null} + {showChild ? : null} + + ); + } + + // Mount with child visible inside hidden Activity (child is in the Store) + await actAsync(() => { + render(); + }); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + ▸ + [suspense-root] rects={[{x:1,y:2,width:5,height:1}]} + + `); + + // Suspend the Suspense — this sends REMOVE for Activity and Child + await actAsync(() => { + render(); + }); + + // Remove Child from Activity's content while Suspense is still suspended. + // This must NOT send a second REMOVE for Child (which is no longer in the Store). + await actAsync(() => { + render(); + }); + + // Reveal Suspense — Activity and any remaining children should be re-added + await actAsync(() => { + render(); + }); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + [suspense-root] rects={null} + + `); + + // Resolve the promise to clean up + await actAsync(() => { + resolve(); + }); + }); }); describe('collapseNodesByDefault:false', () => { diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 037ce1c5cc3b..1f4e1773dc8a 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -209,6 +209,7 @@ function createFiberInstance(fiber: Fiber): FiberInstance { treeBaseDuration: 0, suspendedBy: null, suspenseNode: null, + isDisconnected: false, data: fiber, }; } @@ -244,6 +245,7 @@ function createVirtualInstance( treeBaseDuration: 0, suspendedBy: null, suspenseNode: null, + isDisconnected: false, data: debugEntry, }; } @@ -1784,6 +1786,7 @@ export function attach( // We're disconnected. We'll reconnect a hidden mount after the parent reappears. return; } + fiberInstance.isDisconnected = false; const id = fiberInstance.id; const fiber = fiberInstance.data; @@ -1968,6 +1971,7 @@ export function attach( // We're disconnected. We'll reconnect a hidden mount after the parent reappears. return; } + instance.isDisconnected = false; const componentInfo = instance.data; const key = @@ -2128,6 +2132,13 @@ export function attach( return; } + if (fiberInstance.isDisconnected) { + // A REMOVE was already sent for this instance (e.g. via disconnectChildrenRecursively + // when a parent Suspense suspended). Don't send a second REMOVE. + return; + } + fiberInstance.isDisconnected = true; + if (trackedPathMatchInstance === fiberInstance) { // We're in the process of trying to restore previous selection. // If this fiber matched but is being hidden, there's no use trying. @@ -2853,6 +2864,13 @@ export function attach( if (isInDisconnectedSubtree) { return; } + + if (instance.isDisconnected) { + // A REMOVE was already sent for this instance. Don't send a second REMOVE. + return; + } + instance.isDisconnected = true; + if (trackedPathMatchInstance === instance) { // We're in the process of trying to restore previous selection. // If this fiber matched but is being unmounted, there's no use trying. diff --git a/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberTypes.js b/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberTypes.js index 1b01dfbc5bbc..046f1fd67bd6 100644 --- a/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberTypes.js +++ b/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberTypes.js @@ -34,6 +34,7 @@ export type FiberInstance = { treeBaseDuration: number, // the profiled time of the last render of this subtree suspendedBy: null | Array, // things that suspended in the children position of this component suspenseNode: null | SuspenseNode, + isDisconnected: boolean, // true if a REMOVE was sent to the frontend for this instance data: Fiber, // one of a Fiber pair }; @@ -69,6 +70,7 @@ export type VirtualInstance = { treeBaseDuration: number, // the profiled time of the last render of this subtree suspendedBy: null | Array, // things that blocked the server component's child from rendering suspenseNode: null, + isDisconnected: boolean, // true if a REMOVE was sent to the frontend for this instance // The latest info for this instance. This can be updated over time and the // same info can appear in more than once ServerComponentInstance. data: ReactComponentInfo,