-
Notifications
You must be signed in to change notification settings - Fork 25.3k
Fix iOS crash when unmounting views near pointer hover effects #58686
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<UIView *> *_reactSubviews; | ||
| NSSet<NSString *> *_Nullable _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN; | ||
| UIView *_containerView; | ||
|
|
@@ -230,18 +231,21 @@ - (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)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<RCTComponentViewProtocol> *)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<RCTComponentViewProtocol> *)childCompo | |
| childComponentView, | ||
| @(index), | ||
| @([childComponentView.superview tag])); | ||
| } | ||
| #ifndef NS_BLOCK_ASSERTIONS | ||
| NSArray<UIView *> *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]; | ||
| } | ||
|
Comment on lines
-285
to
-287
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is not executed anymore
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, correct. Copying the native subviews would also include UIKit owned effect views. The added test verifies that removing such a view allows it to deallocate while the parent remains alive. |
||
| } 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]; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. and this as well.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This removal is intentional too. |
||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <React/RCTViewComponentView.h> | ||
| #import <XCTest/XCTest.h> | ||
| #import <react/renderer/components/view/ViewProps.h> | ||
|
|
||
| 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<UIView *> *children = @[ newFirst, first, middle, last, newLast ]; | ||
| NSArray<UIView *> *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<ViewProps>(); | ||
| 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<ViewProps>() 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<ViewProps>(); | ||
| props->removeClippedSubviews = true; | ||
| [parent updateProps:props oldProps:parent.props]; | ||
| [effect removeFromSuperview]; | ||
| [child removeFromSuperview]; | ||
|
|
||
| [parent updateProps:std::make_shared<ViewProps>() 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<ViewProps>(); | ||
| 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<ViewProps>(); | ||
| 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<ViewProps>(); | ||
| clippingProps->removeClippedSubviews = true; | ||
| auto defaultProps = std::make_shared<ViewProps>(); | ||
|
|
||
| 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<ViewProps>(); | ||
| props->removeClippedSubviews = true; | ||
| [parent updateProps:props oldProps:parent.props]; | ||
| [effect removeFromSuperview]; | ||
| } | ||
|
|
||
| XCTAssertNil(weakEffect); | ||
| [parent updateProps:std::make_shared<ViewProps>() 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<ViewProps>(); | ||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this line is now always execute, even if before it was executed only when
_removeClippedSubviewswas set to true.Is this expected?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi,
yes, this is intentional.
mountChildComponentViewnow adds every Fabric child to_reactSubviews, regardless of whether clipping is enabled, so unmount must always remove the corresponding entry.I've added tests covering unmount with clipping enabled and after repeated clipping toggles.