Skip to content

[DevTools] Fix double-REMOVE crash when Activity hidden children are inside a suspended Subtree (#36315, #36375)#36478

Open
arnieS06 wants to merge 1 commit into
facebook:mainfrom
arnieS06:fix-devtools-double-remove
Open

[DevTools] Fix double-REMOVE crash when Activity hidden children are inside a suspended Subtree (#36315, #36375)#36478
arnieS06 wants to merge 1 commit into
facebook:mainfrom
arnieS06:fix-devtools-double-remove

Conversation

@arnieS06
Copy link
Copy Markdown

The error "Cannot remove node X because no matching node was found in the Store" was introduced by the Activity hidden-subtree feature (ed69815), which made Activity's hidden children visible in the DevTools Store.

Why?
When a Suspense boundary suspends, disconnectChildrenRecursively walks the DevTools instance tree and sends TREE_OPERATION_REMOVE for every visible node including children of hidden Activity components, which are now tracked in the Store. The backend FiberInstances remain alive (React keeps the fiber tree for the suspended content), so their IDs are still in idToDevToolsInstanceMap.

Later, if one of those children is removed from the React tree while the Suspense is still suspended, unmountRemainingChildren() is called from inside updateFiberRecursively's finally block. At that point the isInDisconnectedSubtree module level flag has already been restored to false by an inner try/finally, and the Activity's Offscreen fiber does not satisfy isSuspendedOffscreen() (its parent is ActivityComponent, not SuspenseComponent), so unmountRemainingChildren takes the unguarded else branch and calls recordDisconnect again for the same instance — sending a second TREE_OPERATION_REMOVE for a node that is no longer in _idToElement.

Fix
Add a per-instance isDisconnected boolean to FiberInstance and VirtualInstance (initialized to false). This acts as a second, stateful guard independent of the global isInDisconnectedSubtree flag:

  • recordDisconnect / recordVirtualDisconnect: return early if instance.isDisconnected is already true; set it to true before pushing to pendingRealUnmountedIDs.
  • recordReconnect / recordVirtualReconnect: reset isDisconnected to false so that the normal suspend → reveal → suspend cycle continues to work correctly.

The per-instance flag prevents any double-REMOVE regardless of which code path causes recordDisconnect to be called a second time, making the fix robust against future interactions with complex Suspense / Activity trees.

Summary

Fixes #36315
Fixes #36375

The root cause and fix are described above

How did you test this change?

Added a regression test in store-test.js that exercises the full
lifecycle: hidden Activity inside Suspense → suspend → remove child
from Activity → reveal Suspense. The new test passes and confirms no "Cannot remove node" error is thrown. Ran the full DevTools test suite via yarn test-build-devtools. The 11 pre-existing failures on main are unrelated to this change
zero new regressions introduced.

Additional checks:
yarn prettier, no formatting changes needed
yarn linc, lint passed for all changed files
yarn flow, 0 errors

…inside a suspended Subtree (facebook#36315, facebook#36375)

The error "Cannot remove node X because no matching node was found in the
Store" was introduced by the Activity hidden-subtree feature (ed69815),
which made Activity's hidden children visible in the DevTools Store.

## Root cause

When a Suspense boundary suspends, `disconnectChildrenRecursively` walks the
DevTools instance tree and sends TREE_OPERATION_REMOVE for every visible node
— including children of hidden Activity components, which are now tracked in
the Store. The backend FiberInstances remain alive (React keeps the fiber tree
for the suspended content), so their IDs are still in `idToDevToolsInstanceMap`.

Later, if one of those children is removed from the React tree while the
Suspense is still suspended, `unmountRemainingChildren()` is called from
inside `updateFiberRecursively`'s `finally` block. At that point the
`isInDisconnectedSubtree` module-level flag has already been restored to
`false` by an inner try/finally, and the Activity's Offscreen fiber does not
satisfy `isSuspendedOffscreen()` (its parent is ActivityComponent, not
SuspenseComponent), so `unmountRemainingChildren` takes the unguarded else
branch and calls `recordDisconnect` again for the same instance — sending a
second TREE_OPERATION_REMOVE for a node that is no longer in `_idToElement`.

## Fix

Add a per-instance `isDisconnected` boolean to `FiberInstance` and
`VirtualInstance` (initialized to `false`). This acts as a second, stateful
guard independent of the global `isInDisconnectedSubtree` flag:

- `recordDisconnect` / `recordVirtualDisconnect`: return early if
  `instance.isDisconnected` is already `true`; set it to `true` before
  pushing to `pendingRealUnmountedIDs`.
- `recordReconnect` / `recordVirtualReconnect`: reset `isDisconnected` to
  `false` so that the normal suspend → reveal → suspend cycle continues to
  work correctly.

The per-instance flag prevents any double-REMOVE regardless of which code
path causes `recordDisconnect` to be called a second time, making the fix
robust against future interactions with complex Suspense / Activity trees.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@meta-cla meta-cla Bot added the CLA Signed label May 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants