From e83e4914d53668326b0855ad57eaa1ad9a5a720a Mon Sep 17 00:00:00 2001 From: Jordan Becker Date: Sun, 11 Oct 2026 23:07:45 +0200 Subject: [PATCH 1/2] Fix LegacyViewManagerInterop unmounting the wrong child of a legacy view 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 --- ...CTLegacyViewManagerInteropComponentView.mm | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm index e0808af76e4f..9d16e0b201d3 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm @@ -195,10 +195,30 @@ - (void)mountChildComponentView:(UIView *)childCompone - (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { - if (_adapter && index < _adapter.paperView.reactSubviews.count) { - [_adapter.paperView removeReactSubview:_adapter.paperView.reactSubviews[index]]; + // `index` is the child's position among the Fabric children. It is not its position in the paper view's + // `reactSubviews` while mounts are queued in `_viewsToBeMounted`, as those are only inserted in `finalizeUpdates`. + // The child named by the mutation is therefore looked up instead of trusting `index`. + + // A child whose mount is still queued never reached the paper view: forget the mount. + for (NSDictionary *mountInstruction in [_viewsToBeMounted copy]) { + if (mountInstruction[kRCTLegacyInteropChildComponentKey] == childComponentView) { + [_viewsToBeMounted removeObject:mountInstruction]; + return; + } + } + + // `mountChildComponentView:index:` inserts the content view of a child that is itself an interop view. + UIView *target = childComponentView; + if ([childComponentView isKindOfClass:[RCTLegacyViewManagerInteropComponentView class]]) { + target = ((RCTLegacyViewManagerInteropComponentView *)childComponentView).contentView ?: childComponentView; + } + + if (_adapter && [_adapter.paperView.reactSubviews containsObject:target]) { + // Removed right away: a `Delete` mutation of this child in the same transaction recycles it before + // `finalizeUpdates:` runs, and a recycled view must not have a superview. + [_adapter.paperView removeReactSubview:target]; } else { - [_viewsToBeUnmounted addObject:childComponentView]; + [_viewsToBeUnmounted addObject:target]; } } From 7c0f70cde8a18377c95d873d1bdc37142b052a3f Mon Sep 17 00:00:00 2001 From: Jordan Becker Date: Sun, 11 Oct 2026 23:13:15 +0200 Subject: [PATCH 2/2] Mount children of a legacy view right away so its children stay in step 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 --- ...CTLegacyViewManagerInteropComponentView.mm | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm index 9d16e0b201d3..f2765bdc1b4e 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm @@ -173,17 +173,19 @@ - (void)prepareForRecycle - (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { - if (_adapter && index == _adapter.paperView.reactSubviews.count) { - // This is a new child view that is being added to the end of the children array. - // After the children is added, we need to call didUpdateReactSubviews to make sure that it is rendered. - // Without this change, the new child will not be rendered right away because the didUpdateReactSubviews is not - // called and the `finalizeUpdate` is not invoked. + if (_adapter) { + // The child is inserted right away, wherever it goes, so that the paper view's `reactSubviews` stay in step with + // the Fabric children. A mount that waited for `finalizeUpdates:` would be applied late (the host view is not + // updated by every transaction that changes its children) and would shift the position of every later child. After + // the child is added, we need to call didUpdateReactSubviews to make sure that it is rendered. Without this change, + // the new child will not be rendered right away because the didUpdateReactSubviews is not called and the + // `finalizeUpdate` is not invoked. + UIView *target = childComponentView; if ([childComponentView isKindOfClass:[RCTLegacyViewManagerInteropComponentView class]]) { - UIView *target = ((RCTLegacyViewManagerInteropComponentView *)childComponentView).contentView; - [_adapter.paperView insertReactSubview:target atIndex:index]; - } else { - [_adapter.paperView insertReactSubview:childComponentView atIndex:index]; + target = ((RCTLegacyViewManagerInteropComponentView *)childComponentView).contentView; } + [_adapter.paperView insertReactSubview:target + atIndex:MIN(index, (NSInteger)_adapter.paperView.reactSubviews.count)]; [_adapter.paperView didUpdateReactSubviews]; } else { [_viewsToBeMounted addObject:@{ @@ -195,9 +197,8 @@ - (void)mountChildComponentView:(UIView *)childCompone - (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { - // `index` is the child's position among the Fabric children. It is not its position in the paper view's - // `reactSubviews` while mounts are queued in `_viewsToBeMounted`, as those are only inserted in `finalizeUpdates`. - // The child named by the mutation is therefore looked up instead of trusting `index`. + // The child named by the mutation is looked up instead of using `index` as a position in the paper view's + // `reactSubviews`: the two differ whenever a mount is still queued in `_viewsToBeMounted`. // A child whose mount is still queued never reached the paper view: forget the mount. for (NSDictionary *mountInstruction in [_viewsToBeMounted copy]) {