From 7ba685e8716ac409fe073ea070321fbb60094fbe Mon Sep 17 00:00:00 2001 From: theeket Date: Thu, 29 Oct 2026 22:00:14 +0700 Subject: [PATCH 1/2] Fix iOS Fabric unmount assertions for zero-sized recycled views --- .../View/RCTViewComponentView.mm | 3 ++ .../RCTViewComponentViewUnmountTests.mm | 53 +++++++++++++++++++ 2 files changed, 56 insertions(+) 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 8a074cd2b604..ee148aacc57c 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -243,6 +243,9 @@ - (void)unmountChildComponentView:(UIView *)childCompo if (_removeClippedSubviews) { [_reactSubviews removeObjectAtIndex:index]; } else { + // This getter can migrate children from a recycled clipping container. + // Reconcile before reading superview, even when assertions are disabled. + (void)self.currentContainerView; RCTAssert( childComponentView.superview != nil, @"Attempt to unmount a view which is not mounted. (parent: %@, child: %@, index: %@)", diff --git a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewUnmountTests.mm b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewUnmountTests.mm index 1180a3374155..668a0404694d 100644 --- a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewUnmountTests.mm +++ b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewUnmountTests.mm @@ -8,6 +8,9 @@ #import #import #import +#import + +using namespace facebook::react; @interface RCTViewComponentViewUnmountTests : XCTestCase @end @@ -83,4 +86,54 @@ - (void)testUnmountWithInBoundsMismatchStillReportsTagAtIndex XCTAssertNotEqualObjects(thrownName, NSRangeException); } +// A recycled view can receive children before its own props are updated. If +// the new owner is zero-sized, layer invalidation does not reconcile the old +// clipping container. Unmount must do so before checking the child's parent. +- (void)testUnmountReconcilesRecycledContainerAtZeroSize +{ + RCTViewComponentView *parent = [[RCTViewComponentView alloc] initWithFrame:CGRectZero]; + auto clippingProps = std::make_shared(); + clippingProps->yogaStyle.setOverflow(facebook::yoga::Overflow::Hidden); + clippingProps->outlineWidth = 1; + [parent updateProps:clippingProps oldProps:parent.props]; + [parent finalizeUpdates:RNComponentViewUpdateMaskAll]; + + RCTViewComponentView *previousChild = [RCTViewComponentView new]; + [parent mountChildComponentView:previousChild index:0]; + UIView *previousContainer = previousChild.superview; + XCTAssertNotEqual(previousContainer, parent); + XCTAssertNotNil(previousContainer); + [parent unmountChildComponentView:previousChild index:0]; + [parent prepareForRecycle]; + + // Fabric mounts descendants before applying the recycled parent's new props. + RCTViewComponentView *child = [RCTViewComponentView new]; + [parent mountChildComponentView:child index:0]; + [parent updateProps:std::make_shared() oldProps:parent.props]; + [parent finalizeUpdates:RNComponentViewUpdateMaskAll]; + XCTAssertTrue(CGSizeEqualToSize(parent.bounds.size, CGSizeZero)); + XCTAssertEqual(child.superview, previousContainer); + + NSString *message = nil; + NSString *thrownName = RCTUnmountAndReportAssert(parent, child, 0, &message); + + XCTAssertNil(message, @"Container migration must precede the ownership check"); + XCTAssertNil(thrownName); + XCTAssertNil(child.superview); + XCTAssertNil(previousContainer.superview, @"The stale container must be removed"); +} + +- (void)testUnmountStillReportsChildMountedUnderAnotherParent +{ + RCTViewComponentView *parent = [RCTViewComponentView new]; + RCTViewComponentView *otherParent = [RCTViewComponentView new]; + RCTViewComponentView *child = [RCTViewComponentView new]; + [otherParent mountChildComponentView:child index:0]; + + NSString *message = nil; + RCTUnmountAndReportAssert(parent, child, 0, &message); + + XCTAssertTrue([message containsString:@"mounted inside a different view"]); +} + @end From 6002b876826c8217b883c306a4aeb3bcc639b79d Mon Sep 17 00:00:00 2001 From: theeket Date: Fri, 30 Oct 2026 22:51:35 +0700 Subject: [PATCH 2/2] update according to the suggestion --- .../Mounting/ComponentViews/View/RCTViewComponentView.mm | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 ee148aacc57c..ce85971d0b8f 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -245,7 +245,7 @@ - (void)unmountChildComponentView:(UIView *)childCompo } else { // This getter can migrate children from a recycled clipping container. // Reconcile before reading superview, even when assertions are disabled. - (void)self.currentContainerView; + [[maybe_unused]] UIView *containerView = self.currentContainerView; RCTAssert( childComponentView.superview != nil, @"Attempt to unmount a view which is not mounted. (parent: %@, child: %@, index: %@)", @@ -253,14 +253,14 @@ - (void)unmountChildComponentView:(UIView *)childCompo childComponentView, @(index)); RCTAssert( - childComponentView.superview == self.currentContainerView, + childComponentView.superview == containerView, @"Attempt to unmount a view which is mounted inside a different view. (parent: %@, child: %@, index: %@, existing parent: %@)", self, childComponentView, @(index), @([childComponentView.superview tag])); #ifndef NS_BLOCK_ASSERTIONS - NSArray *containerSubviews = self.currentContainerView.subviews; + NSArray *containerSubviews = containerView.subviews; BOOL isIndexInBounds = index >= 0 && (NSUInteger)index < containerSubviews.count; RCTAssert( isIndexInBounds && [containerSubviews objectAtIndex:index] == childComponentView,