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 @@ -159,11 +159,18 @@ - (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)childCompo
[_reactSubviews removeObjectAtIndex:index];
} else {
RCTAssert(
childComponentView.superview == self.currentContainerView,
@"Attempt to unmount a view which is mounted inside different view. (parent: %@, child: %@, index: %@)",
childComponentView.superview != nil,
@"Attempt to unmount a view which is not mounted. (parent: %@, child: %@, index: %@)",
self,
childComponentView,
@(index));
RCTAssert(
childComponentView.superview == self.currentContainerView,
@"Attempt to unmount a view which is mounted inside a different view. (parent: %@, child: %@, index: %@, existing parent: %@)",
self,
childComponentView,
@(index),
@([childComponentView.superview tag]));
RCTAssert(
(self.currentContainerView.subviews.count > index) &&
[self.currentContainerView.subviews objectAtIndex:index] == childComponentView,
Expand All @@ -178,6 +185,30 @@ - (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)childCompo
[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.
// 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];
}
}

- (void)updateClippedSubviewsWithClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView
{
if (!_removeClippedSubviews) {
Expand Down Expand Up @@ -243,9 +274,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
if (!ReactNativeFeatureFlags::enableViewCulling()) {
if (oldViewProps.removeClippedSubviews != newViewProps.removeClippedSubviews) {
_removeClippedSubviews = newViewProps.removeClippedSubviews;
if (_removeClippedSubviews && self.currentContainerView.subviews.count > 0) {
_reactSubviews = [NSMutableArray arrayWithArray:self.currentContainerView.subviews];
}
[self _updateRemoveClippedSubviewsState];
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* 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>
#import <react/renderer/components/view/ViewShadowNode.h>

using namespace facebook::react;

static Props::Shared makeViewProps(bool removeClippedSubviews)
{
auto props = std::make_shared<ViewProps>();
props->removeClippedSubviews = removeClippedSubviews;
return props;
}

@interface RCTViewComponentViewTests : XCTestCase
@end

@implementation RCTViewComponentViewTests

#pragma mark - removeClippedSubviews toggle

- (void)testToggleRemoveClippedSubviewsOffRemountsClippedChildren
{
RCTViewComponentView *parent = [RCTViewComponentView new];
UIView *child1 = [UIView new];
child1.frame = CGRectMake(0, 0, 50, 50);
UIView *child2 = [UIView new];
child2.frame = CGRectMake(0, 200, 50, 50);
UIView *child3 = [UIView new];
child3.frame = CGRectMake(0, 400, 50, 50);

// Mount children normally
[parent mountChildComponentView:(id)child1 index:0];
[parent mountChildComponentView:(id)child2 index:1];
[parent mountChildComponentView:(id)child3 index:2];

XCTAssertEqual(parent.subviews.count, 3u);

// Toggle removeClippedSubviews ON via props
auto propsOn = makeViewProps(true);
[parent updateProps:propsOn oldProps:ViewShadowNode::defaultSharedProps()];

// Simulate clipping: remove child2 and child3 from superview (as updateClippedSubviewsWithClipRect would)
[child2 removeFromSuperview];
[child3 removeFromSuperview];
XCTAssertEqual(parent.subviews.count, 1u);
XCTAssertNil(child2.superview);
XCTAssertNil(child3.superview);

// Toggle removeClippedSubviews OFF via props
auto propsOff = makeViewProps(false);
[parent updateProps:propsOff oldProps:propsOn];

// All children should be re-mounted
XCTAssertEqual(parent.subviews.count, 3u);
XCTAssertEqual(child1.superview, parent);
XCTAssertEqual(child2.superview, parent);
XCTAssertEqual(child3.superview, parent);
}

- (void)testToggleRemoveClippedSubviewsOffPreservesOrder
{
RCTViewComponentView *parent = [RCTViewComponentView new];
UIView *child1 = [UIView new];
child1.frame = CGRectMake(0, 0, 50, 50);
UIView *child2 = [UIView new];
child2.frame = CGRectMake(0, 100, 50, 50);
UIView *child3 = [UIView new];
child3.frame = CGRectMake(0, 200, 50, 50);

[parent mountChildComponentView:(id)child1 index:0];
[parent mountChildComponentView:(id)child2 index:1];
[parent mountChildComponentView:(id)child3 index:2];

// Toggle ON and clip child1 (first child)
auto propsOn = makeViewProps(true);
[parent updateProps:propsOn oldProps:ViewShadowNode::defaultSharedProps()];
[child1 removeFromSuperview];
XCTAssertEqual(parent.subviews.count, 2u);

// Toggle OFF — all children re-mounted in correct order
auto propsOff = makeViewProps(false);
[parent updateProps:propsOff oldProps:propsOn];

XCTAssertEqual(parent.subviews.count, 3u);
XCTAssertEqual(parent.subviews[0], child1);
XCTAssertEqual(parent.subviews[1], child2);
XCTAssertEqual(parent.subviews[2], child3);
}

- (void)testToggleRemoveClippedSubviewsOffClearsReactSubviews
{
RCTViewComponentView *parent = [RCTViewComponentView new];
UIView *child1 = [UIView new];
child1.frame = CGRectMake(0, 0, 50, 50);

[parent mountChildComponentView:(id)child1 index:0];

// Toggle ON
auto propsOn = makeViewProps(true);
[parent updateProps:propsOn oldProps:ViewShadowNode::defaultSharedProps()];

// Toggle OFF
auto propsOff = makeViewProps(false);
[parent updateProps:propsOff oldProps:propsOn];

// _reactSubviews should be cleared
NSMutableArray *reactSubviews = [parent valueForKey:@"_reactSubviews"];
XCTAssertEqual(reactSubviews.count, 0u);
}

- (void)testUnmountAfterToggleOffCleansUpReactSubviews
{
RCTViewComponentView *parent = [RCTViewComponentView new];
UIView *child1 = [UIView new];
child1.frame = CGRectMake(0, 0, 50, 50);
UIView *child2 = [UIView new];
child2.frame = CGRectMake(0, 100, 50, 50);

// Toggle ON first, then mount children
auto propsOn = makeViewProps(true);
[parent updateProps:propsOn oldProps:ViewShadowNode::defaultSharedProps()];
[parent mountChildComponentView:(id)child1 index:0];
[parent mountChildComponentView:(id)child2 index:1];

// Toggle OFF — re-mounts children
auto propsOff = makeViewProps(false);
[parent updateProps:propsOff oldProps:propsOn];

XCTAssertEqual(parent.subviews.count, 2u);

// Unmount child2 — should succeed without assert failures
[parent unmountChildComponentView:(id)child2 index:1];
XCTAssertEqual(parent.subviews.count, 1u);
XCTAssertNil(child2.superview);
}

@end
Loading