[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
Open
Conversation
…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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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,
disconnectChildrenRecursivelywalks 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 inidToDevToolsInstanceMap.Later, if one of those children is removed from the React tree while the Suspense is still suspended,
unmountRemainingChildren()is called from insideupdateFiberRecursively'sfinallyblock. At that point theisInDisconnectedSubtreemodule level flag has already been restored tofalseby an inner try/finally, and the Activity's Offscreen fiber does not satisfyisSuspendedOffscreen()(its parent is ActivityComponent, not SuspenseComponent), sounmountRemainingChildrentakes the unguarded else branch and callsrecordDisconnectagain for the same instance — sending a second TREE_OPERATION_REMOVE for a node that is no longer in_idToElement.Fix
Add a per-instance
isDisconnectedboolean toFiberInstanceandVirtualInstance(initialized tofalse). This acts as a second, stateful guard independent of the globalisInDisconnectedSubtreeflag:recordDisconnect/recordVirtualDisconnect: return early ifinstance.isDisconnectedis alreadytrue; set it totruebefore pushing topendingRealUnmountedIDs.recordReconnect/recordVirtualReconnect: resetisDisconnectedtofalseso 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
recordDisconnectto 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