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,