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..3b834fc8ffb4 --- /dev/null +++ b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewChildMountingTests.mm @@ -0,0 +1,269 @@ +/* + * 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); + // 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 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;