Repository navigation
Conversation
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>
…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
force-pushed
the
fix/legacy-interop-unmount-by-identity
branch
from
October 12, 2026 06:57
5945856 to
7c0f70c
Compare
This branch has not been deployed
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.
Summary:
RCTLegacyViewManagerInteropComponentViewhosts a legacy (Paper) view and keeps its children in the Paper view'sreactSubviews. Its bookkeeping mixes two different indexes: the child's index among the Fabric children, and its position inreactSubviews. They differ whenever a mount is queued in_viewsToBeMounted, which is only applied infinalizeUpdates:(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:
unmountChildComponentView:index:removesreactSubviews[index], not the child named by the mutation. The wrong child is removed, and the right one stays attached (a ghost view). Withindex >= reactSubviews.countthe removal is deferred tofinalizeUpdates:, after the same transaction has deleted the child.RCTAssert(view.superview == nil, "Attempt to recycle a mounted view")in-[RCTComponentViewRegistry _enqueueComponentViewWithComponentHandle:componentViewDescriptor:]in debug builds (the app aborts inRCTPerformMountInstructions; it also happens insideSurfaceHandler::stop()when a surface is released, which mounts the final transaction synchronously). Release builds keep the stale view in the recycle pool.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 ofreactSubviewsdrifts from Fabric's). A deferred removal also queues the interop wrapper while the insertion used itscontentView, 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 (thecontentViewfor an interop child). Only when there is no adapter yet is the removal queued.mountChildComponentView:index:inserts the child at its index (clamped toreactSubviews.count) as soon as there is an adapter, followed bydidUpdateReactSubviewsas for appended children. The queue is only used before the adapter exists.Not changed:
didUpdateReactSubviewsruns 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 throughRCTMountingTransactionObservingif 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):main-equivalent code (0.87.1, this file is the same apart from an unrelatedmoduleNamechange)MISMATCHlines (wrong subview count), then the app aborts withAttempt to recycle a mounted view; 3 runs out of 3 (also with the prebuilt core)MISMATCHlines remain (children mounted late)OK, 0MISMATCH, no crashScenario "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 (3OK).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 frommain; the unmount/mount methods are identical there.AI disclaimer:
I used Claude Code to help in the investigation, fix and repro project.