From 7c405e517ba2541806aaf732a980ec15c952daaa Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Sat, 21 Mar 2026 13:56:06 -0500 Subject: [PATCH 1/6] fix(macOS): add transform3D property to RCTUIView with anchor point and hit testing fixes On macOS, AppKit resets layer.transform to identity during its display cycle because NSView has no built-in transform property. This adds a transform3D property to RCTUIView that persists the transform and re-applies it in updateLayer. Also fixes two related issues in the compat layer: - Compensates for macOS layer.anchorPoint defaulting to {0,0} instead of {0.5, 0.5}, so transforms apply from the view's center. - Uses CALayer coordinate conversion in hitTest: and RCTUIViewHitTestWithEvent, which correctly accounts for layer.transform (NSView's convertPoint:fromView: does not). Co-Authored-By: Claude Opus 4.6 --- .../react-native/React/RCTUIKit/RCTUIView.h | 16 +++--- .../react-native/React/RCTUIKit/RCTUIView.m | 52 ++++++++++++++++--- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/packages/react-native/React/RCTUIKit/RCTUIView.h b/packages/react-native/React/RCTUIKit/RCTUIView.h index 991018bc4fb9..c87faaa94497 100644 --- a/packages/react-native/React/RCTUIKit/RCTUIView.h +++ b/packages/react-native/React/RCTUIKit/RCTUIView.h @@ -56,6 +56,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy) NSColor *backgroundColor; @property (nonatomic) CGAffineTransform transform; +@property (nonatomic) CATransform3D transform3D; /** * Specifies whether the view should receive the mouse down event when the @@ -75,7 +76,6 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, assign) BOOL enableFocusRing; -// [macOS /** * iOS compatibility shim. On macOS, this forwards to accessibilityChildren. */ @@ -90,9 +90,9 @@ NS_ASSUME_NONNULL_BEGIN #if !TARGET_OS_OSX -UIKIT_STATIC_INLINE RCTPlatformView *RCTUIViewHitTestWithEvent(RCTPlatformView *view, CGPoint point, __unused UIEvent *__nullable event) +UIKIT_STATIC_INLINE RCTPlatformView *RCTUIViewHitTestWithEvent(RCTPlatformView *view, CGPoint point, RCTPlatformView *fromView, __unused UIEvent *__nullable event) { - return [view hitTest:point withEvent:event]; + return [view hitTest:[view convertPoint:point fromView:fromView] withEvent:event]; } UIKIT_STATIC_INLINE void RCTUIViewSetContentModeRedraw(UIView *view) @@ -107,11 +107,15 @@ UIKIT_STATIC_INLINE BOOL RCTUIViewIsDescendantOfView(RCTPlatformView *view, RCTP #else // TARGET_OS_OSX -NS_INLINE RCTPlatformView *RCTUIViewHitTestWithEvent(RCTPlatformView *view, CGPoint point, __unused UIEvent *__nullable event) +// Use CALayer coordinate conversion which correctly accounts for layer.transform. +// NSView's convertPoint:fromView: does not account for layer transforms on macOS. +// IMPORTANT -- NSView's hitTest: expects a point in the superview's coordinate space, +// so we convert from fromView → superview using CALayer, which handles layer transforms correctly. +// This allows hit testing to work correctly between nested RCTUIViews and plain NSViews. +NS_INLINE RCTPlatformView *RCTUIViewHitTestWithEvent(RCTPlatformView *view, CGPoint point, RCTPlatformView *fromView, __unused UIEvent *__nullable event) { - // [macOS IMPORTANT -- point is in local coordinate space, but OSX expects super coordinate space for hitTest: NSView *superview = [view superview]; - NSPoint pointInSuperview = superview != nil ? [view convertPoint:point toView:superview] : point; + NSPoint pointInSuperview = superview != nil ? [superview.layer convertPoint:point fromLayer:fromView.layer] : point; return [view hitTest:pointInSuperview]; } diff --git a/packages/react-native/React/RCTUIKit/RCTUIView.m b/packages/react-native/React/RCTUIKit/RCTUIView.m index 152f7b31c007..0976c309ff90 100644 --- a/packages/react-native/React/RCTUIKit/RCTUIView.m +++ b/packages/react-native/React/RCTUIKit/RCTUIView.m @@ -9,6 +9,7 @@ #if TARGET_OS_OSX +#import #import // UIView @@ -21,6 +22,8 @@ @implementation RCTUIView BOOL _userInteractionEnabled; BOOL _mouseDownCanMoveWindow; BOOL _respondsToDisplayLayer; + CATransform3D _transform3D; + BOOL _hasCustomTransform3D; } + (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key @@ -51,6 +54,8 @@ @implementation RCTUIView self->_enableFocusRing = YES; self->_mouseDownCanMoveWindow = YES; self->_respondsToDisplayLayer = [self respondsToSelector:@selector(displayLayer:)]; + self->_transform3D = CATransform3DIdentity; + self->_hasCustomTransform3D = NO; } return self; } @@ -127,20 +132,46 @@ - (CGAffineTransform)transform - (void)setTransform:(CGAffineTransform)transform { - self.layer.affineTransform = transform; + self.transform3D = CATransform3DMakeAffineTransform(transform); +} + +- (CATransform3D)transform3D +{ + return _transform3D; +} + +- (void)setTransform3D:(CATransform3D)transform3D +{ + // On macOS, layer.anchorPoint defaults to {0, 0} instead of {0.5, 0.5} on iOS. + // Compensate so transforms are applied from the view's center as expected. + CGPoint anchorPoint = self.layer.anchorPoint; + if (CGPointEqualToPoint(anchorPoint, CGPointZero) && !CATransform3DEqualToTransform(transform3D, CATransform3DIdentity)) { + CATransform3D originAdjust = CATransform3DTranslate(CATransform3DIdentity, self.frame.size.width / 2, self.frame.size.height / 2, 0); + transform3D = CATransform3DConcat(CATransform3DConcat(CATransform3DInvert(originAdjust), transform3D), originAdjust); + } + + _transform3D = transform3D; + _hasCustomTransform3D = !CATransform3DEqualToTransform(transform3D, CATransform3DIdentity); + self.layer.transform = transform3D; } - (NSView *)hitTest:(NSPoint)point { - // IMPORTANT point is passed in super coordinates by OSX, but expected to be passed in local coordinates - NSView *superview = [self superview]; - NSPoint pointInSelf = superview != nil ? [self convertPoint:point fromView:superview] : point; - return [self hitTest:pointInSelf withEvent:nil]; + // NSView's hitTest: receives a point in superview coordinates. Convert to local + // coordinates using CALayer, which correctly accounts for layer.transform. + // NSView's convertPoint:fromView: does NOT account for layer transforms. + CGPoint localPoint; + if (self.layer.superlayer) { + localPoint = [self.layer convertPoint:point fromLayer:self.layer.superlayer]; + } else { + localPoint = point; + } + return [self hitTest:localPoint withEvent:nil]; } - (BOOL)wantsUpdateLayer { - return [self respondsToSelector:@selector(displayLayer:)]; + return _respondsToDisplayLayer || _hasCustomTransform3D; } - (void)updateLayer @@ -153,8 +184,13 @@ - (void)updateLayer [layer setBackgroundColor:[_backgroundColor CGColor]]; } - // In Fabric, wantsUpdateLayer is always enabled and doesn't guarantee that - // the instance has a displayLayer method. + // On macOS, AppKit's layer-backed view system resets layer.transform to identity + // during its layout/display cycle because NSView has no built-in transform property + // (unlike UIView on iOS). We must re-apply the stored transform after each cycle. + if (_hasCustomTransform3D && !CATransform3DEqualToTransform(layer.transform, _transform3D)) { + layer.transform = _transform3D; + } + if (_respondsToDisplayLayer) { [(id)self displayLayer:layer]; } From 48c9d3267df3facc170f0aceceb01ebba42d6b1e Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Sat, 21 Mar 2026 13:56:13 -0500 Subject: [PATCH 2/6] fix(macOS): use transform3D in Fabric component views Use self.transform3D instead of self.layer.transform on macOS in RCTViewComponentView, which routes through RCTUIView's persistence and anchor point compensation. Remove duplicated anchor point compensation and hitTest: override that are now handled by RCTUIView. Update hit testing callers to use the new RCTUIViewHitTestWithEvent signature with fromView parameter. Co-Authored-By: Claude Opus 4.6 --- .../ScrollView/RCTScrollViewComponentView.mm | 2 +- .../View/RCTViewComponentView.mm | 21 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 57746520d786..798855eca16f 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -536,7 +536,7 @@ - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event // } for (RCTPlatformView *subview in [_containerView.subviews reverseObjectEnumerator]) { // [macOS] - RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, [subview convertPoint:point fromView:self], event); // [macOS] + RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, point, self, event); // [macOS] if (hitView) { return hitView; } 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 78b616a03a7a..6db50f3242ab 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -368,16 +368,10 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & auto newTransform = newViewProps.resolveTransform(_layoutMetrics); CATransform3D caTransform = RCTCATransform3DFromTransformMatrix(newTransform); #if TARGET_OS_OSX // [macOS - CGPoint anchorPoint = self.layer.anchorPoint; - if (CGPointEqualToPoint(anchorPoint, CGPointZero) && !CATransform3DEqualToTransform(caTransform, CATransform3DIdentity)) { - // https://developer.apple.com/documentation/quartzcore/calayer/1410817-anchorpoint - // This compensates for the fact that layer.anchorPoint is {0, 0} instead of {0.5, 0.5} on macOS for some reason. - CATransform3D originAdjust = CATransform3DTranslate(CATransform3DIdentity, self.frame.size.width / 2, self.frame.size.height / 2, 0); - caTransform = CATransform3DConcat(CATransform3DConcat(CATransform3DInvert(originAdjust), caTransform), originAdjust); - } -#endif // macOS] - + self.transform3D = caTransform; +#else self.layer.transform = caTransform; +#endif // macOS] // Enable edge antialiasing in rotation, skew, or perspective transforms self.layer.allowsEdgeAntialiasing = caTransform.m12 != 0.0f || caTransform.m21 != 0.0f || caTransform.m34 != 0.0f; } @@ -718,7 +712,12 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics if ((_props->transformOrigin.isSet() || _props->transform.operations.size() > 0) && layoutMetrics.frame.size != oldLayoutMetrics.frame.size) { auto newTransform = _props->resolveTransform(layoutMetrics); - self.layer.transform = RCTCATransform3DFromTransformMatrix(newTransform); + CATransform3D caTransform = RCTCATransform3DFromTransformMatrix(newTransform); +#if TARGET_OS_OSX // [macOS + self.transform3D = caTransform; +#else + self.layer.transform = caTransform; +#endif // macOS] } } @@ -812,7 +811,7 @@ - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event // } for (RCTPlatformView *subview in [self.subviews reverseObjectEnumerator]) { // [macOS] - RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, [subview convertPoint:point fromView:self], event); // [macOS] + RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, point, self, event); // [macOS] if (hitView) { return hitView; } From 08dc34eb1c52fffa52b80ff076b3d6ca0c1abdb9 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Sat, 21 Mar 2026 13:56:19 -0500 Subject: [PATCH 3/6] fix(macOS): update Paper hit testing callers for transform-aware coordinate conversion Update RCTUIViewHitTestWithEvent callers in Paper architecture to use the new fromView parameter, enabling transform-aware hit testing in old architecture as well. Co-Authored-By: Claude Opus 4.6 --- packages/react-native/React/Modules/RCTUIManager.mm | 2 +- packages/react-native/React/Views/RCTView.m | 3 +-- packages/react-native/React/Views/UIView+React.m | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/react-native/React/Modules/RCTUIManager.mm b/packages/react-native/React/Modules/RCTUIManager.mm index 45e7ea7d1275..7f1aeff436b8 100644 --- a/packages/react-native/React/Modules/RCTUIManager.mm +++ b/packages/react-native/React/Modules/RCTUIManager.mm @@ -1186,7 +1186,7 @@ - (void)synchronouslyUpdateViewOnUIThread:(NSNumber *)reactTag viewName:(NSStrin { [self addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry) { // [macOS] RCTPlatformView *view = viewRegistry[reactTag]; // [macOS] - RCTPlatformView *target = RCTUIViewHitTestWithEvent(view, point, nil); // [macOS] + RCTPlatformView *target = RCTUIViewHitTestWithEvent(view, point, view, nil); // [macOS] CGRect frame = [target convertRect:target.bounds toView:view]; while (target.reactTag == nil && target.superview != nil) { diff --git a/packages/react-native/React/Views/RCTView.m b/packages/react-native/React/Views/RCTView.m index 5562d9b4e20b..e21035af2c47 100644 --- a/packages/react-native/React/Views/RCTView.m +++ b/packages/react-native/React/Views/RCTView.m @@ -257,8 +257,7 @@ - (RCTPlatformView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event // [macOS // of the hit view will return YES from -pointInside:withEvent:). See: // - https://developer.apple.com/library/ios/qa/qa2013/qa1812.html for (RCTUIView *subview in [sortedSubviews reverseObjectEnumerator]) { // [macOS] - CGPoint pointForHitTest = [subview convertPoint:point fromView:self]; - hitSubview = RCTUIViewHitTestWithEvent(subview, pointForHitTest, event); // macOS] + hitSubview = RCTUIViewHitTestWithEvent(subview, point, self, event); // [macOS] if (hitSubview != nil) { break; } diff --git a/packages/react-native/React/Views/UIView+React.m b/packages/react-native/React/Views/UIView+React.m index 9af40b67b8de..30c4f1e53e7b 100644 --- a/packages/react-native/React/Views/UIView+React.m +++ b/packages/react-native/React/Views/UIView+React.m @@ -68,7 +68,7 @@ - (BOOL)isReactRootView - (NSNumber *)reactTagAtPoint:(CGPoint)point { - RCTPlatformView *view = RCTUIViewHitTestWithEvent(self, point, nil); // [macOS] + RCTPlatformView *view = RCTUIViewHitTestWithEvent(self, point, self, nil); // [macOS] while (view && !view.reactTag) { view = view.superview; } From 8d0f38551f73dc48e0cb171fcbad318efa986695 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Sat, 21 Mar 2026 14:14:20 -0500 Subject: [PATCH 4/6] Apply suggestion from @Saadnajmi --- .../Mounting/ComponentViews/View/RCTViewComponentView.mm | 8 ++++---- 1 file changed, 4 insertions(+), 4 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 6db50f3242ab..d05b86a22a02 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -713,10 +713,10 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics layoutMetrics.frame.size != oldLayoutMetrics.frame.size) { auto newTransform = _props->resolveTransform(layoutMetrics); CATransform3D caTransform = RCTCATransform3DFromTransformMatrix(newTransform); -#if TARGET_OS_OSX // [macOS - self.transform3D = caTransform; -#else - self.layer.transform = caTransform; +#if !TARGET_OS_OSX // [macOS] + self.layer.transform = RCTCATransform3DFromTransformMatrix(newTransform); +#else // [macOS + self.transform3D = RCTCATransform3DFromTransformMatrix(newTransform); #endif // macOS] } } From 3793d156c48bd180c8e41add0c19493da6536e8c Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Mon, 23 Mar 2026 10:50:27 -0500 Subject: [PATCH 5/6] fix typo --- .../ComponentViews/View/RCTViewComponentView.mm | 10 ++++------ 1 file changed, 4 insertions(+), 6 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 d05b86a22a02..1aaeb95585d0 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -366,11 +366,10 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & oldViewProps.transformOrigin != newViewProps.transformOrigin) && ![_propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN containsObject:@"transform"]) { auto newTransform = newViewProps.resolveTransform(_layoutMetrics); - CATransform3D caTransform = RCTCATransform3DFromTransformMatrix(newTransform); -#if TARGET_OS_OSX // [macOS - self.transform3D = caTransform; -#else - self.layer.transform = caTransform; +#if !TARGET_OS_OSX // [macOS] + self.layer.transform = RCTCATransform3DFromTransformMatrix(newTransform); +#else // [macOS + self.transform3D = RCTCATransform3DFromTransformMatrix(newTransform); #endif // macOS] // Enable edge antialiasing in rotation, skew, or perspective transforms self.layer.allowsEdgeAntialiasing = caTransform.m12 != 0.0f || caTransform.m21 != 0.0f || caTransform.m34 != 0.0f; @@ -712,7 +711,6 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics if ((_props->transformOrigin.isSet() || _props->transform.operations.size() > 0) && layoutMetrics.frame.size != oldLayoutMetrics.frame.size) { auto newTransform = _props->resolveTransform(layoutMetrics); - CATransform3D caTransform = RCTCATransform3DFromTransformMatrix(newTransform); #if !TARGET_OS_OSX // [macOS] self.layer.transform = RCTCATransform3DFromTransformMatrix(newTransform); #else // [macOS From 3be34a9c5945772e1a653cf7a64e8c5040eaf97c Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Mon, 23 Mar 2026 13:38:55 -0500 Subject: [PATCH 6/6] Apply suggestion from @Saadnajmi --- .../Mounting/ComponentViews/View/RCTViewComponentView.mm | 6 ++++-- 1 file changed, 4 insertions(+), 2 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 1aaeb95585d0..2290babfdbd3 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -366,11 +366,13 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & oldViewProps.transformOrigin != newViewProps.transformOrigin) && ![_propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN containsObject:@"transform"]) { auto newTransform = newViewProps.resolveTransform(_layoutMetrics); + CATransform3D caTransform = RCTCATransform3DFromTransformMatrix(newTransform); #if !TARGET_OS_OSX // [macOS] - self.layer.transform = RCTCATransform3DFromTransformMatrix(newTransform); + self.layer.transform = caTransform; #else // [macOS - self.transform3D = RCTCATransform3DFromTransformMatrix(newTransform); + self.transform3D = caTransform; #endif // macOS] + // Enable edge antialiasing in rotation, skew, or perspective transforms self.layer.allowsEdgeAntialiasing = caTransform.m12 != 0.0f || caTransform.m21 != 0.0f || caTransform.m34 != 0.0f; }