Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: %@)",
Expand All @@ -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];

Copy link
Copy Markdown
Contributor

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 _removeClippedSubviews was set to true.
Is this expected?

Copy link
Copy Markdown
Author

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. mountChildComponentView now 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.

[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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not executed anymore

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, correct. _reactSubviews is now maintained by mount / unmount before clipping is enabled, so rebuilding it from currentContainerView.subviews is no longer needed.

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];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and this as well.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This removal is intentional too. _reactSubviews now tracks Fabric children even when clipping is disabled, so clearing it here would lose the list needed for subsequent mounting operations. Entries are removed on every unmount, and prepareForRecycle still resets the array. The added weak-reference tests should cover cleanup after clipping toggles and recycling.

}
}

Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ - (void)testToggleRemoveClippedSubviewsOffPreservesOrder
XCTAssertEqual(parent.subviews[2], child3);
}

- (void)testToggleRemoveClippedSubviewsOffClearsReactSubviews
- (void)testRepeatedRemoveClippedSubviewsTogglesPreserveChildren
{
RCTViewComponentView *parent = [RCTViewComponentView new];
UIView *child1 = [UIView new];
Expand All @@ -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
Expand Down
Loading
Loading