Fix iOS crash when unmounting views near pointer hover effects - #58686
lorenc-tomasz wants to merge 3 commits into
Conversation
UIKit can insert native hover-effect views alongside Fabric children, shifting the subview indices used during mounting and unmounting. Track Fabric children separately and insert them relative to their predecessor. Use that same list when toggling clipping so UIKit-owned views are not tracked as Fabric children. Add six native regression tests and an RNTester cursor example. Update existing clipping expectations and the unmount assertion comment to match the new child tracking behavior. Changelog: [IOS] [FIXED] - Fix Fabric child mounting and unmounting around pointer hover effects. Test Plan: - 56 native tests passed, including all six regression tests; five of the six failed before the fix. The unrelated paragraph test file was excluded because of an existing ComponentBuilder linkage issue; local include-path overrides were also needed to build the test suite. - RNTester Debug simulator build succeeded. Baseline hover crash confirmed on iPad mini (A17 Pro); fixed-build touch interaction passed. Fixed-build pointer hover still needs manual verification. - Targeted clang-format, Prettier, ESLint, and git diff --check passed. Refs react#55489
cipolleschi
left a comment
There was a problem hiding this comment.
This is a delicate change, as it changes mounting ans unmounting. If we land this, we are removing some of the code that remove views from the arrays, and therefore we might be creating memory leaks.
| #endif | ||
| } | ||
|
|
||
| [_reactSubviews removeObjectAtIndex:index]; |
There was a problem hiding this comment.
this line is now always execute, even if before it was executed only when _removeClippedSubviews was set to true.
Is this expected?
There was a problem hiding this comment.
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.
| if (self.currentContainerView.subviews.count > 0) { | ||
| _reactSubviews = [NSMutableArray arrayWithArray:self.currentContainerView.subviews]; | ||
| } |
There was a problem hiding this comment.
this is not executed anymore
There was a problem hiding this comment.
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.
| for (UIView *view in _reactSubviews) { | ||
| [self.currentContainerView addSubview:view]; | ||
| } | ||
| [_reactSubviews removeAllObjects]; |
There was a problem hiding this comment.
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.
Hi, I also considered a few alternative approaches, although I haven't implemented or benchmarked them:
I think that current approach provides one consistent child order for mounting, unmounting, and clipping, without depending on UIKit's private view class names. Its trade-off is additional array storage and maintenance when clipping is disabled. |
|
I explored another approach: keep the original mounting/unmounting logic and move the hover target inside the Fabric component. The relevant changes are below. Unchanged code is omitted. @@ Instance variables
+ UIView *_hoverContentView;
@@ - (UIView *)effectiveContentView
+{
+ UIView *base = [self contentViewWithoutHover];
+#if !TARGET_OS_TV && defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && \
+ __IPHONE_OS_VERSION_MAX_ALLOWED >= 170000
+ if (@available(iOS 17.0, *)) {
+ if (!_hoverContentView && _props->cursor == Cursor::Pointer) {
+ _hoverContentView = [[UIView alloc] initWithFrame:self.bounds];
+ _hoverContentView.autoresizingMask =
+ UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
+ _hoverContentView.isAccessibilityElement = NO;
+
+ for (UIView *child in base.subviews) {
+ [_hoverContentView addSubview:child];
+ }
+
+ _hoverContentView.clipsToBounds = base.clipsToBounds;
+ base.clipsToBounds = NO;
+ _hoverContentView.layer.mask = base.layer.mask;
+ base.layer.mask = nil;
+
+ [self transferVisualPropertiesFromView:base toView:_hoverContentView];
+ [base addSubview:_hoverContentView];
+ }
+ }
+#endif
+ // Keep the container stable across cursor changes and recycling.
+ return _hoverContentView ?: base;
+}
+
+// Existing effectiveContentView implementation, including SwiftUI filters.
+- (UIView *)contentViewWithoutHover
{
if (!ReactNativeFeatureFlags::enableSwiftUIBasedFilters()) {
return self;
}
@@ - (void)updateProps:oldProps:
+ // Update the layer currently rendering the shadow. Subsequent container
+ // transitions transfer these values after the new props are stored.
+ CALayer *shadowLayer =
+ (_hoverContentView ?: _swiftUIWrapper.contentView ?: self).layer;
- self.layer.shadowColor = shadowColor.CGColor;
+ shadowLayer.shadowColor = shadowColor.CGColor;
- self.layer.shadowOffset = RCTCGSizeFromSize(newViewProps.shadowOffset);
+ shadowLayer.shadowOffset = RCTCGSizeFromSize(newViewProps.shadowOffset);
- self.layer.shadowOpacity = (float)newViewProps.shadowOpacity;
+ shadowLayer.shadowOpacity = (float)newViewProps.shadowOpacity;
- self.layer.shadowRadius = (CGFloat)newViewProps.shadowRadius;
+ shadowLayer.shadowRadius = (CGFloat)newViewProps.shadowRadius;
@@ - (void)invalidateLayer
- CGPathRef borderPath = RCTPathCreateWithRoundedRect(self.frame, cornerInsets, NULL, NO);
+ CGPathRef borderPath = RCTPathCreateWithRoundedRect(
+ _hoverContentView ? _hoverContentView.frame : self.frame,
+ cornerInsets, NULL, NO);
- [self setHoverStyle:hoverStyle];
+ [self setHoverStyle:nil];
+ [_hoverContentView setHoverStyle:hoverStyle];
@@ - (void)updateLayoutMetrics:oldLayoutMetrics:
+ _hoverContentView.frame = self.bounds;
@@ - (UIView *)betterHitTest:withEvent:
- return isPointInside ? self : nil;
+ return isPointInside ? (_hoverContentView ?: self) : nil;
@@ - (UIView *)hitTest:withEvent:
case PointerEventsMode::BoxOnly:
- return [self pointInside:point withEvent:event] ? self : nil;
+ return [self pointInside:point withEvent:event] ? (_hoverContentView ?: self) : nil;
case PointerEventsMode::BoxNone:
UIView *view = [self betterHitTest:point withEvent:event];
- return view != self ? view : nil;
+ return view != self && view != _hoverContentView ? view : nil;
@@ - (void)prepareForRecycle
+#if !TARGET_OS_TV && defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && \
+ __IPHONE_OS_VERSION_MAX_ALLOWED >= 170000
+ if (@available(iOS 17.0, *)) {
+ _hoverContentView.hoverStyle = nil;
+ }
+#endifIn the reproduced case, UIKit’s effect views become siblings of the inner target, inside the component, so they no longer shift the component’s index in its Fabric parent. I tried this locally and the hover effect is still visible, repeated clicks no longer crash the app, and the children stay in the correct order. It looks promising for this particular crash, although other native views inserted alongside Fabric children could still cause mounting or clipping problems. The downside is an extra UIView that stays around after hover is disabled and when the component is recycled. I also had to adjust touch handling and shadow updates, so the change ended up being a bit more involved than I initially expected. This is still a local prototype; I haven’t pushed these changes yet. Do you think this narrower approach is worth pursuing? EDIT: I hoped moving the hover target would make the fix simpler, but the extra view and changes to touch handling, recycling, and styles add up. The original approach looks simpler overall and provides broader protection. |
Summary:
Fixes #55489.
UIKit can insert hover-effect views alongside Fabric children when using
cursor: 'pointer'. These views shift native subview indices, causing an assertion failure when unmounting a child.This change tracks Fabric children independently of UIKit’s subviews and mounts new children relative to the preceding Fabric child. Clipping uses the same list to avoid tracking UIKit-owned views.
Includes native regression tests and an RNTester example.
Changelog:
[iOS] [Fixed] - Fix a crash when unmounting views near pointer hover effects.
Test Plan:
git diff --checkpassed.