Skip to content

Fix LegacyViewManagerInterop unmounting the wrong child, and mounting children late, in a hosted legacy view - #59005

Open
habovh wants to merge 2 commits into
react:mainfrom
habovh:fix/legacy-interop-unmount-by-identity
Open

habovh wants to merge 2 commits into
react:mainfrom
habovh:fix/legacy-interop-unmount-by-identity

Conversation

@habovh

@habovh habovh commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

RCTLegacyViewManagerInteropComponentView hosts a legacy (Paper) view and keeps its children in the Paper view's reactSubviews. Its bookkeeping mixes two different indexes: the child's index among the Fabric children, and its position in reactSubviews. They differ whenever a mount is queued in _viewsToBeMounted, which is only applied in finalizeUpdates: (and that only runs when the host view itself gets an update: props, state or layout).

This causes three problems, all in the same file:

  1. unmountChildComponentView:index: removes reactSubviews[index], not the child named by the mutation. The wrong child is removed, and the right one stays attached (a ghost view). With index >= reactSubviews.count the removal is deferred to finalizeUpdates:, after the same transaction has deleted the child.
  2. A deleted view that is still a subview is recycled, which fails RCTAssert(view.superview == nil, "Attempt to recycle a mounted view") in -[RCTComponentViewRegistry _enqueueComponentViewWithComponentHandle:componentViewDescriptor:] in debug builds (the app aborts in RCTPerformMountInstructions; it also happens inside SurfaceHandler::stop() when a surface is released, which mounts the final transaction synchronously). Release builds keep the stale view in the recycle pool.
  3. mountChildComponentView:index: only inserts a child directly when it is appended. A child inserted anywhere else waits for the host view's next update, so it is missing until then, and every later append is queued too (the order of reactSubviews drifts from Fabric's). A deferred removal also queues the interop wrapper while the insertion used its contentView, so a child that is itself an interop view is never removed.

This is the defect described in #57872, which proposes unmounting by identity; this PR does that (first commit) and also makes the mounts immediate when the adapter exists (second commit), because with only the first the child count still disagrees with React's until a later update (see the Test Plan). The commits can be reviewed, or split, separately.

Changes in RCTLegacyViewManagerInteropComponentView.mm:

  • unmountChildComponentView:index: looks up the child. A child whose mount is still queued is dropped from the queue. Any other child is removed from the Paper view right away, as the view that was inserted (the contentView for an interop child). Only when there is no adapter yet is the removal queued.
  • mountChildComponentView:index: inserts the child at its index (clamped to reactSubviews.count) as soon as there is an adapter, followed by didUpdateReactSubviews as for appended children. The queue is only used before the adapter exists.

Not changed: didUpdateReactSubviews runs once per inserted child now (it already did for appended children); a bulk insert therefore re-attaches the subviews once per child (each already attached, so this is cheap) rather than once per flush. Happy to batch it through RCTMountingTransactionObserving if reviewers prefer.

Changelog:

[IOS] [FIXED] - Fixed LegacyViewManagerInterop removing the wrong child, asserting "Attempt to recycle a mounted view", and mounting children late in a legacy view with children

Test Plan:

Reproducer: https://github.com/habovh/react-native-legacy-interop-unmount-by-identity-repro (a React Native 0.87.1 app with one small Paper view manager, no third-party dependency; see its
README). The container logs its inserts and removals, and 0.5 s after the last update compares its subview count with the number of children React rendered (OK / MISMATCH).

Scenario "random keyed swaps" (40 seeded pseudo-random lists of keys, 1 s apart), iOS 27 simulator, Debug, React Native built from source (RCT_USE_PREBUILT_RNCORE=0):

result
main-equivalent code (0.87.1, this file is the same apart from an unrelated moduleName change) MISMATCH lines (wrong subview count), then the app aborts with Attempt to recycle a mounted view; 3 runs out of 3 (also with the prebuilt core)
first commit only no crash, but MISMATCH lines remain (children mounted late)
both commits 40 OK, 0 MISMATCH, no crash

Scenario "insert at the front" (a b c -> x a b -> x a): without the second commit the container shows 2 children instead of 3 until the next commit; with it, always the right number (3 OK).

Not tested: Android (not affected, iOS only), the old architecture, RNTester (the change is a small edit to one file that the reproducer exercises through the same code path), and no unit test is added: this mounting code has no test harness upstream. The fix was applied to 0.87.1 (patch), not built from main; the unmount/mount methods are identical there.

AI disclaimer:

I used Claude Code to help in the investigation, fix and repro project.

Summary:
`RCTLegacyViewManagerInteropComponentView` removed a child of the hosted legacy (Paper) view with
`removeReactSubview:reactSubviews[index]`, using the Fabric index of the child as a position in the Paper view's
`reactSubviews`. The two differ while a mount is queued in `_viewsToBeMounted`, so the wrong child was removed (a child that
should be gone stayed attached, "ghost view") and the child named by the mutation was left attached.

When the removal was deferred instead (`index >= reactSubviews.count`), it only ran in `finalizeUpdates:`, after the same
transaction had already deleted the child. The deleted view was recycled while it still had a superview, which fails
`RCTAssert(view.superview == nil, "Attempt to recycle a mounted view")` in debug builds. It also queued the interop
wrapper rather than the content view that `mountChildComponentView:index:` inserted, so a child that is itself an interop view
was never removed.

The child named by the mutation is now looked up: a child whose mount is still queued is dropped from the queue, any other is
removed from the Paper view immediately (as the view that was inserted), so it has no superview when the transaction deletes it.

Changelog:
[IOS] [FIXED] - LegacyViewManagerInterop removes the child named by the unmount mutation, not the one at its index in the legacy view

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 12, 2026
…ep with Fabric's

Summary:
`mountChildComponentView:index:` inserted a child only when it was appended (`index == reactSubviews.count`); any other
insertion was queued in `_viewsToBeMounted` until `finalizeUpdates:`, which only runs when the host view itself is updated
(props, state or layout). A transaction that inserts a child before others and leaves the host view unchanged therefore left the
child unmounted until some later update, and every later `index == count` check failed in the meantime, so more children were
queued (the paper view's order drifted from Fabric's).

With an adapter the child is now inserted at its index (clamped to the number of children) straight away, and
`didUpdateReactSubviews` is called as it was for appended children. The queue is only used before the adapter exists.

Changelog:
[IOS] [FIXED] - LegacyViewManagerInterop mounts a child inserted before other children of a legacy view right away

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@habovh
habovh force-pushed the fix/legacy-interop-unmount-by-identity branch from 5945856 to 7c0f70c Compare October 12, 2026 06:57
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 12, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant