Skip to content

Fix iOS crash when unmounting views near pointer hover effects - #58686

Open
lorenc-tomasz wants to merge 3 commits into
react:mainfrom
lorenc-tomasz:bugfix/ios-pointer-hover-mounting
Open

lorenc-tomasz wants to merge 3 commits into
react:mainfrom
lorenc-tomasz:bugfix/ios-pointer-hover-mounting

Conversation

@lorenc-tomasz

@lorenc-tomasz lorenc-tomasz commented Sep 25, 2026 •

Copy link
Copy Markdown

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:

  • 56 native tests passed, including six new regression tests; five of those failed before the fix. Paragraph tests were excluded due to a local build issue.
  • RNTester Debug build succeeded. Reproduced the original crash on iPad mini (A17 Pro). Touch interaction passed with the fix; pointer-hover verification passed with the fix.
  • Targeted formatting, ESLint, and git diff --check passed.

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
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 25, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Sep 25, 2026

@cipolleschi cipolleschi left a comment

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

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.

Comment on lines -285 to -287
if (self.currentContainerView.subviews.count > 0) {
_reactSubviews = [NSMutableArray arrayWithArray:self.currentContainerView.subviews];
}

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.

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.

@lorenc-tomasz

Copy link
Copy Markdown
Author

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.

Hi,
thanks for raising this. I've added four lifetime tests covering clipped child unmount, repeated clipping toggles, removed UIKit owned subviews, and recycling followed by parent reuse. I also strengthened the existing unmount test to keep the parent alive while checking child deallocation. These tests cover retention introduced by the tracking array, they don't establish that the entire mounting system is leak free.

I also considered a few alternative approaches, although I haven't implemented or benchmarked them:

  • Track children only after detecting UIKit-owned subviews: This could preserve the existing mounting path for ordinary views, but requires reliable detection and a correct transition to explicit tracking, including when clipping is enabled.
  • Filter native subviews during mounting: This would avoid retaining an additional child list when clipping is disabled, but requires scanning siblings and reliably identifying Fabric children. Checking RCTComponentViewProtocolalone would be insufficient becauseUIView+ComponentViewProtocol` adds conformance to UIView itself.
  • Use a dedicated child container: As proposed in callstack/liquid-glass#46. Applying this to the generic View component would add another native view and require validation of layout, hit testing, accessibility, and whether UIKit can also insert effect views into that container.

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.

@lorenc-tomasz

lorenc-tomasz commented Sep 28, 2026 •

Copy link
Copy Markdown
Author

@cipolleschi

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;
+  }
+#endif

In 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:
After trying this out, I’m leaning toward keeping the original approach in this PR. Tracking Fabric children separately addresses the underlying indexing problem and also covers the mounting and clipping cases that this alternative leaves open.

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS RCTAssert crash when unmounting near cursor pointer hover effect

2 participants