From 0b5640c4f7301eed34ac4cea3d1953904b103189 Mon Sep 17 00:00:00 2001 From: Lorenc Tomasz Date: Fri, 25 Sep 2026 20:01:05 +0200 Subject: [PATCH 1/2] Fix iOS Fabric child mounting around pointer hover effects UIKit can insert native hover-effect views alongside Fabric children, shifting the subview indices used during mounting and unmounting. Track Fabric children separately and insert them relative to their predecessor. Use that same list when toggling clipping so UIKit-owned views are not tracked as Fabric children. Add six native regression tests and an RNTester cursor example. Update existing clipping expectations and the unmount assertion comment to match the new child tracking behavior. Changelog: [IOS] [FIXED] - Fix Fabric child mounting and unmounting around pointer hover effects. Test Plan: - 56 native tests passed, including all six regression tests; five of the six failed before the fix. The unrelated paragraph test file was excluded because of an existing ComponentBuilder linkage issue; local include-path overrides were also needed to build the test suite. - RNTester Debug simulator build succeeded. Baseline hover crash confirmed on iPad mini (A17 Pro); fixed-build touch interaction passed. Fixed-build pointer hover still needs manual verification. - Targeted clang-format, Prettier, ESLint, and git diff --check passed. Refs https://github.com/react/react-native/issues/55489 --- .../View/RCTViewComponentView.mm | 56 +++---- .../RCTViewComponentViewChildMountingTests.mm | 155 ++++++++++++++++++ .../Mounting/RCTViewComponentViewTests.mm | 10 +- .../RCTViewComponentViewUnmountTests.mm | 8 +- .../js/examples/Cursor/CursorExample.js | 25 ++- 5 files changed, 212 insertions(+), 42 deletions(-) create mode 100644 packages/react-native/React/Tests/Mounting/RCTViewComponentViewChildMountingTests.mm diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 61ef98f921cd..9ec1b0cd45dd 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -115,6 +115,7 @@ @implementation RCTViewComponentView { BOOL _needsInvalidateLayer; BOOL _isJSResponder; BOOL _removeClippedSubviews; + // Fabric children in mounting order. UIKit may insert additional subviews for hover effects. NSMutableArray *_reactSubviews; NSSet *_Nullable _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN; UIView *_containerView; @@ -230,18 +231,21 @@ - (void)mountChildComponentView:(UIView *)childCompone @(index), @([childComponentView.superview tag])); - if (_removeClippedSubviews) { - [_reactSubviews insertObject:childComponentView atIndex:index]; - } else { - [self.currentContainerView insertSubview:childComponentView atIndex:index]; + [_reactSubviews insertObject:childComponentView atIndex:index]; + if (!_removeClippedSubviews) { + // A Fabric index is not necessarily a UIKit subview index. Position new children + // relative to the preceding Fabric child to preserve their mounting order. + if (index == 0) { + [self.currentContainerView insertSubview:childComponentView atIndex:0]; + } else { + [self.currentContainerView insertSubview:childComponentView aboveSubview:_reactSubviews[index - 1]]; + } } } - (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { - if (_removeClippedSubviews) { - [_reactSubviews removeObjectAtIndex:index]; - } else { + if (!_removeClippedSubviews) { RCTAssert( childComponentView.superview != nil, @"Attempt to unmount a view which is not mounted. (parent: %@, child: %@, index: %@)", @@ -255,44 +259,32 @@ - (void)unmountChildComponentView:(UIView *)childCompo childComponentView, @(index), @([childComponentView.superview tag])); + } #ifndef NS_BLOCK_ASSERTIONS - NSArray *containerSubviews = self.currentContainerView.subviews; - BOOL isIndexInBounds = index >= 0 && (NSUInteger)index < containerSubviews.count; - RCTAssert( - isIndexInBounds && [containerSubviews objectAtIndex:index] == childComponentView, - @"Attempt to unmount a view which has a different index. (parent: %@, child: %@, index: %@, actual index: %@, tag at index: %@)", - self, - childComponentView, - @(index), - @([containerSubviews indexOfObject:childComponentView]), - isIndexInBounds ? @([[containerSubviews objectAtIndex:index] tag]) : @"out of bounds"); + BOOL isIndexInBounds = index >= 0 && (NSUInteger)index < _reactSubviews.count; + RCTAssert( + isIndexInBounds && [_reactSubviews objectAtIndex:index] == childComponentView, + @"Attempt to unmount a view which has a different index. (parent: %@, child: %@, index: %@, actual index: %@, tag at index: %@)", + self, + childComponentView, + @(index), + @([_reactSubviews indexOfObject:childComponentView]), + isIndexInBounds ? @([[_reactSubviews objectAtIndex:index] tag]) : @"out of bounds"); #endif - } + [_reactSubviews removeObjectAtIndex:index]; [childComponentView removeFromSuperview]; } - (void)_updateRemoveClippedSubviewsState { - if (_removeClippedSubviews) { - // Toggled ON: populate _reactSubviews from the current view hierarchy. - // Actual clipping will happen on the next scroll event. - RCTAssert( - _reactSubviews.count == 0, - @"_reactSubviews should be empty when toggling removeClippedSubviews on. (view: %@, count: %@)", - self, - @(_reactSubviews.count)); - if (self.currentContainerView.subviews.count > 0) { - _reactSubviews = [NSMutableArray arrayWithArray:self.currentContainerView.subviews]; - } - } else { - // Toggled OFF: re-mount all children in the correct order, then clear the tracking array. + if (!_removeClippedSubviews) { + // Toggled OFF: re-mount all Fabric children in the correct order. // addSubview: on an already-present child moves it to the front, so iterating in order // produces the correct subview ordering. for (UIView *view in _reactSubviews) { [self.currentContainerView addSubview:view]; } - [_reactSubviews removeAllObjects]; } } diff --git a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewChildMountingTests.mm b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewChildMountingTests.mm new file mode 100644 index 000000000000..23e44aa01a9f --- /dev/null +++ b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewChildMountingTests.mm @@ -0,0 +1,155 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import +#import +#import + +using namespace facebook::react; + +@interface RCTViewComponentViewChildMountingTests : XCTestCase +@end + +@implementation RCTViewComponentViewChildMountingTests + +- (void)testUnmountIgnoresNativeSiblings +{ + RCTViewComponentView *parent = [RCTViewComponentView new]; + RCTViewComponentView *first = [RCTViewComponentView new]; + RCTViewComponentView *second = [RCTViewComponentView new]; + [parent mountChildComponentView:first index:0]; + [parent mountChildComponentView:second index:1]; + + // UIKit's pointer effects insert native siblings before and between Fabric children. + UIView *leadingEffect = [UIView new]; + UIView *middleEffect = [UIView new]; + [parent insertSubview:leadingEffect atIndex:0]; + [parent insertSubview:middleEffect aboveSubview:first]; + + XCTAssertNoThrow([parent unmountChildComponentView:second index:1]); + XCTAssertNil(second.superview); + XCTAssertNoThrow([parent unmountChildComponentView:first index:0]); + XCTAssertNil(first.superview); + XCTAssertEqualObjects(parent.subviews, (@[ leadingEffect, middleEffect ])); +} + +- (void)testMountPreservesFabricOrderWithNativeSiblings +{ + RCTViewComponentView *parent = [RCTViewComponentView new]; + RCTViewComponentView *first = [RCTViewComponentView new]; + RCTViewComponentView *last = [RCTViewComponentView new]; + [parent mountChildComponentView:first index:0]; + [parent mountChildComponentView:last index:1]; + + UIView *leadingEffect = [UIView new]; + UIView *middleEffect = [UIView new]; + [parent insertSubview:leadingEffect atIndex:0]; + [parent insertSubview:middleEffect aboveSubview:first]; + + RCTViewComponentView *middle = [RCTViewComponentView new]; + RCTViewComponentView *newFirst = [RCTViewComponentView new]; + RCTViewComponentView *newLast = [RCTViewComponentView new]; + [parent mountChildComponentView:middle index:1]; + [parent mountChildComponentView:newFirst index:0]; + [parent mountChildComponentView:newLast index:4]; + + NSArray *children = @[ newFirst, first, middle, last, newLast ]; + NSArray *mountedChildren = [parent.subviews + filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(UIView *view, NSDictionary *bindings) { + return [children containsObject:view]; + }]]; + XCTAssertEqualObjects(mountedChildren, children); + XCTAssertEqual(leadingEffect.superview, parent); + XCTAssertEqual(middleEffect.superview, parent); + + // Effect views may also disappear between mounting transactions. + [leadingEffect removeFromSuperview]; + [middleEffect removeFromSuperview]; + XCTAssertNoThrow([parent unmountChildComponentView:middle index:2]); + XCTAssertEqualObjects(parent.subviews, (@[ newFirst, first, last, newLast ])); +} + +- (void)testClippingDoesNotRemoveNativeSubviews +{ + RCTViewComponentView *parent = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + RCTViewComponentView *visible = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; + RCTViewComponentView *clipped = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 200, 50, 50)]; + [parent mountChildComponentView:visible index:0]; + [parent mountChildComponentView:clipped index:1]; + + UIView *effect = [[UIView alloc] initWithFrame:CGRectMake(0, 200, 50, 50)]; + [parent insertSubview:effect atIndex:0]; + auto props = std::make_shared(); + props->removeClippedSubviews = true; + [parent updateProps:props oldProps:parent.props]; + [parent updateClippedSubviewsWithClipRect:parent.bounds relativeToView:parent]; + + XCTAssertEqual(visible.superview, parent); + XCTAssertNil(clipped.superview); + XCTAssertEqual(effect.superview, parent); + XCTAssertNoThrow([parent unmountChildComponentView:clipped index:1]); + + [parent updateProps:std::make_shared() oldProps:props]; + XCTAssertNil(clipped.superview); + XCTAssertNoThrow([parent unmountChildComponentView:visible index:0]); + XCTAssertEqualObjects(parent.subviews, (@[ effect ])); +} + +- (void)testDisablingClippingDoesNotRestoreRemovedNativeSubviews +{ + RCTViewComponentView *parent = [RCTViewComponentView new]; + RCTViewComponentView *child = [RCTViewComponentView new]; + [parent mountChildComponentView:child index:0]; + UIView *effect = [UIView new]; + [parent insertSubview:effect atIndex:0]; + + auto props = std::make_shared(); + props->removeClippedSubviews = true; + [parent updateProps:props oldProps:parent.props]; + [effect removeFromSuperview]; + [child removeFromSuperview]; + + [parent updateProps:std::make_shared() oldProps:props]; + XCTAssertEqualObjects(parent.subviews, (@[ child ])); + XCTAssertNil(effect.superview); + XCTAssertNoThrow([parent unmountChildComponentView:child index:0]); +} + +- (void)testClippingDoesNotTrackContentView +{ + RCTViewComponentView *parent = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + UIView *content = [UIView new]; + parent.contentView = content; + content.frame = CGRectMake(0, 200, 50, 50); + RCTViewComponentView *child = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; + [parent mountChildComponentView:child index:0]; + + auto props = std::make_shared(); + props->removeClippedSubviews = true; + [parent updateProps:props oldProps:parent.props]; + [parent updateClippedSubviewsWithClipRect:parent.bounds relativeToView:parent]; + + XCTAssertEqual(content.superview, parent); + XCTAssertEqual(child.superview, parent); + XCTAssertNoThrow([parent unmountChildComponentView:child index:0]); + XCTAssertEqualObjects(parent.subviews, (@[ content ])); +} + +- (void)testUnmountReleasesTrackedChild +{ + RCTViewComponentView *parent = [RCTViewComponentView new]; + __weak RCTViewComponentView *weakChild; + @autoreleasepool { + RCTViewComponentView *child = [RCTViewComponentView new]; + weakChild = child; + [parent mountChildComponentView:child index:0]; + [parent unmountChildComponentView:child index:0]; + } + XCTAssertNil(weakChild); +} + +@end diff --git a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm index 91e100d28df1..323340200281 100644 --- a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm +++ b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm @@ -96,7 +96,7 @@ - (void)testToggleRemoveClippedSubviewsOffPreservesOrder XCTAssertEqual(parent.subviews[2], child3); } -- (void)testToggleRemoveClippedSubviewsOffClearsReactSubviews +- (void)testRepeatedRemoveClippedSubviewsTogglesPreserveChildren { RCTViewComponentView *parent = [RCTViewComponentView new]; UIView *child1 = [UIView new]; @@ -112,9 +112,11 @@ - (void)testToggleRemoveClippedSubviewsOffClearsReactSubviews auto propsOff = makeViewProps(false); [parent updateProps:propsOff oldProps:propsOn]; - // _reactSubviews should be cleared - NSMutableArray *reactSubviews = [parent valueForKey:@"_reactSubviews"]; - XCTAssertEqual(reactSubviews.count, 0u); + // A subsequent toggle should still track and restore the same children. + [parent updateProps:propsOn oldProps:propsOff]; + [child1 removeFromSuperview]; + [parent updateProps:propsOff oldProps:propsOn]; + XCTAssertEqualObjects(parent.subviews, (@[ child1 ])); } - (void)testUnmountAfterToggleOffCleansUpReactSubviews diff --git a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewUnmountTests.mm b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewUnmountTests.mm index 1180a3374155..75b59a6c61d4 100644 --- a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewUnmountTests.mm +++ b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewUnmountTests.mm @@ -44,11 +44,9 @@ @implementation RCTViewComponentViewUnmountTests return thrownName; } -// The index is read only inside RCTAssert here, so a mismatch is harmless once -// assertions are compiled out. While they are compiled in, the arguments that -// build the failure message are evaluated at the call site, and an out-of-range -// index used to reach objectAtIndex: there. The assert that exists to report the -// mismatch raised NSRangeException instead of reporting it. +// The arguments that build the assertion's failure message are evaluated at +// the call site. An out-of-range index used to reach objectAtIndex: there, so +// the assertion raised NSRangeException instead of reporting the mismatch. - (void)testUnmountWithOutOfBoundsIndexReportsRatherThanRaisingRangeException { RCTViewComponentView *parent = [[RCTViewComponentView alloc] initWithFrame:CGRectZero]; diff --git a/packages/rn-tester/js/examples/Cursor/CursorExample.js b/packages/rn-tester/js/examples/Cursor/CursorExample.js index 4a1b04f8d7ce..eccd256d99b2 100644 --- a/packages/rn-tester/js/examples/Cursor/CursorExample.js +++ b/packages/rn-tester/js/examples/Cursor/CursorExample.js @@ -11,7 +11,7 @@ import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; import * as React from 'react'; -import {StyleSheet, Text, View} from 'react-native'; +import {Pressable, StyleSheet, Text, View} from 'react-native'; const styles = StyleSheet.create({ invisibleBox: { @@ -89,6 +89,23 @@ function CursorExampleViewFlattening(): React.Node { ); } +function CursorExampleMounting(): React.Node { + const [visible, setVisible] = React.useState(true); + + return ( + + setVisible(value => !value)} + style={[styles.box, styles.centerContent, styles.pointer]}> + Toggle sibling + + {visible && Removable sibling} + This should always be last. + + ); +} + exports.title = 'Cursor'; exports.category = 'UI'; exports.description = @@ -109,4 +126,10 @@ exports.examples = [ description: 'Views with a cursor do not get flattened', render: CursorExampleViewFlattening, }, + { + title: 'Mount and unmount while hovering', + description: + 'Hover over the button with a pointer or Apple Pencil, then press repeatedly. The sibling should toggle without crashing or changing the text order. In the iOS Simulator, enable Send Pointer to Device.', + render: CursorExampleMounting, + }, ] as Array; From 71beeebf8788049a67d136f952086b6a8ba51775 Mon Sep 17 00:00:00 2001 From: Lorenc Tomasz Date: Mon, 28 Sep 2026 11:39:18 +0200 Subject: [PATCH 2/2] Add Fabric child retention regression tests --- .../RCTViewComponentViewChildMountingTests.mm | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewChildMountingTests.mm b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewChildMountingTests.mm index 23e44aa01a9f..3b834fc8ffb4 100644 --- a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewChildMountingTests.mm +++ b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewChildMountingTests.mm @@ -150,6 +150,120 @@ - (void)testUnmountReleasesTrackedChild [parent unmountChildComponentView:child index:0]; } XCTAssertNil(weakChild); + // Keep the parent alive so its deallocation cannot hide a retained child. + XCTAssertEqual(parent.subviews.count, 0u); +} + +- (void)testUnmountReleasesClippedChild +{ + RCTViewComponentView *parent = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + __weak RCTViewComponentView *weakChild; + @autoreleasepool { + RCTViewComponentView *child = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 200, 50, 50)]; + weakChild = child; + [parent mountChildComponentView:child index:0]; + + auto props = std::make_shared(); + props->removeClippedSubviews = true; + [parent updateProps:props oldProps:parent.props]; + [parent updateClippedSubviewsWithClipRect:parent.bounds relativeToView:parent]; + XCTAssertNil(child.superview); + } + + @autoreleasepool { + // Clipping keeps the logical child alive until Fabric unmounts it. + XCTAssertNotNil(weakChild); + [parent unmountChildComponentView:weakChild index:0]; + } + XCTAssertNil(weakChild); + XCTAssertEqual(parent.subviews.count, 0u); +} + +- (void)testUnmountReleasesChildrenAfterRepeatedClippingToggles +{ + RCTViewComponentView *parent = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + auto clippingProps = std::make_shared(); + clippingProps->removeClippedSubviews = true; + auto defaultProps = std::make_shared(); + + for (NSInteger iteration = 0; iteration < 3; iteration++) { + __weak RCTViewComponentView *weakChild; + @autoreleasepool { + RCTViewComponentView *child = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 200, 50, 50)]; + weakChild = child; + [parent mountChildComponentView:child index:0]; + [parent updateProps:clippingProps oldProps:parent.props]; + [parent updateClippedSubviewsWithClipRect:parent.bounds relativeToView:parent]; + XCTAssertNil(child.superview); + } + + @autoreleasepool { + XCTAssertNotNil(weakChild); + [parent updateProps:defaultProps oldProps:parent.props]; + XCTAssertEqual(weakChild.superview, parent); + [parent unmountChildComponentView:weakChild index:0]; + } + XCTAssertNil(weakChild, @"Child retained after clipping toggle %ld", (long)iteration); + XCTAssertEqual(parent.subviews.count, 0u); + } +} + +- (void)testClippingDoesNotRetainRemovedNativeSubview +{ + RCTViewComponentView *parent = [RCTViewComponentView new]; + RCTViewComponentView *child = [RCTViewComponentView new]; + [parent mountChildComponentView:child index:0]; + + __weak UIView *weakEffect; + @autoreleasepool { + UIView *effect = [UIView new]; + weakEffect = effect; + [parent insertSubview:effect atIndex:0]; + + auto props = std::make_shared(); + props->removeClippedSubviews = true; + [parent updateProps:props oldProps:parent.props]; + [effect removeFromSuperview]; + } + + XCTAssertNil(weakEffect); + [parent updateProps:std::make_shared() oldProps:parent.props]; + XCTAssertEqualObjects(parent.subviews, (@[ child ])); +} + +- (void)testPrepareForRecycleReleasesClippedChildren +{ + RCTViewComponentView *parent = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + __weak RCTViewComponentView *weakChild; + @autoreleasepool { + RCTViewComponentView *child = [[RCTViewComponentView alloc] initWithFrame:CGRectMake(0, 200, 50, 50)]; + weakChild = child; + [parent mountChildComponentView:child index:0]; + + auto props = std::make_shared(); + props->removeClippedSubviews = true; + [parent updateProps:props oldProps:parent.props]; + [parent updateClippedSubviewsWithClipRect:parent.bounds relativeToView:parent]; + XCTAssertNil(child.superview); + } + + @autoreleasepool { + XCTAssertNotNil(weakChild); + [parent prepareForRecycle]; + } + XCTAssertNil(weakChild); + + // Reusing the parent must not retain children from its previous lifecycle. + __weak RCTViewComponentView *weakNewChild; + @autoreleasepool { + RCTViewComponentView *child = [RCTViewComponentView new]; + weakNewChild = child; + [parent mountChildComponentView:child index:0]; + XCTAssertEqualObjects(parent.subviews, (@[ child ])); + [parent unmountChildComponentView:child index:0]; + } + XCTAssertNil(weakNewChild); + XCTAssertEqual(parent.subviews.count, 0u); } @end