From 1158209b777f89a43d8f312e2d54ce47f323c77f Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 23 Dec 2025 00:01:53 -0600 Subject: [PATCH 1/4] feat: Introduce RCTUIImage --- packages/react-native/React/Base/RCTUIKit.h | 21 ++++++-- .../react-native/React/Base/macOS/RCTUIKit.m | 50 +++++++++++++++++-- .../View/RCTViewComponentView.mm | 13 +---- .../React/Fabric/Utils/RCTBoxShadow.h | 2 +- .../React/Fabric/Utils/RCTBoxShadow.mm | 4 +- .../React/Fabric/Utils/RCTLinearGradient.mm | 8 +-- .../React/Views/RCTBorderDrawing.h | 2 +- .../React/Views/RCTBorderDrawing.m | 8 +-- packages/react-native/React/Views/RCTView.m | 2 +- 9 files changed, 74 insertions(+), 36 deletions(-) diff --git a/packages/react-native/React/Base/RCTUIKit.h b/packages/react-native/React/Base/RCTUIKit.h index e22bbba2c967e8..78037d1649ea59 100644 --- a/packages/react-native/React/Base/RCTUIKit.h +++ b/packages/react-native/React/Base/RCTUIKit.h @@ -60,7 +60,7 @@ UIKIT_STATIC_INLINE void UIBezierPathAppendPath(UIBezierPath *path, UIBezierPath #define RCTUIView UIView #define RCTUIScrollView UIScrollView #define RCTPlatformImage UIImage - +#define RCTUIImage UIImage UIKIT_STATIC_INLINE RCTPlatformView *RCTUIViewHitTestWithEvent(RCTPlatformView *view, CGPoint point, __unused UIEvent *__nullable event) { @@ -268,7 +268,6 @@ extern "C" { // UIGraphics.h CGContextRef UIGraphicsGetCurrentContext(void); -CGImageRef UIImageGetCGImageRef(NSImage *image); #ifdef __cplusplus } @@ -334,18 +333,30 @@ NS_INLINE NSEdgeInsets UIEdgeInsetsMake(CGFloat top, CGFloat left, CGFloat botto #define UIApplication NSApplication // UIImage +// RCTUIImage is a subclass of NSImage that caches its CGImage representation. +// This is needed because NSImage's CGImageForProposedRect: returns a new autoreleased +// CGImage each time, which causes issues when used with CALayer.contents. +@interface RCTUIImage : NSImage +@property (nonatomic, readonly, nullable) CGImageRef CGImage; +@property (nonatomic, readonly) CGFloat scale; +@end + typedef NS_ENUM(NSInteger, UIImageRenderingMode) { UIImageRenderingModeAlwaysOriginal, UIImageRenderingModeAlwaysTemplate, }; #ifdef __cplusplus -extern "C" +extern "C" { #endif -CGFloat UIImageGetScale(NSImage *image); +CGFloat UIImageGetScale(NSImage *image); CGImageRef UIImageGetCGImageRef(NSImage *image); +#ifdef __cplusplus +} +#endif + NS_INLINE NSImage *UIImageWithContentsOfFile(NSString *filePath) { return [[NSImage alloc] initWithContentsOfFile:filePath]; @@ -626,7 +637,7 @@ typedef void (^RCTUIGraphicsImageDrawingActions)(RCTUIGraphicsImageRendererConte - (instancetype)initWithSize:(CGSize)size; - (instancetype)initWithSize:(CGSize)size format:(RCTUIGraphicsImageRendererFormat *)format; -- (NSImage *)imageWithActions:(NS_NOESCAPE RCTUIGraphicsImageDrawingActions)actions; +- (RCTUIImage *)imageWithActions:(NS_NOESCAPE RCTUIGraphicsImageDrawingActions)actions; @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/Base/macOS/RCTUIKit.m b/packages/react-native/React/Base/macOS/RCTUIKit.m index e1af61da6ee982..5dfcf07436fc59 100644 --- a/packages/react-native/React/Base/macOS/RCTUIKit.m +++ b/packages/react-native/React/Base/macOS/RCTUIKit.m @@ -76,8 +76,43 @@ CGFloat UIImageGetScale(NSImage *image) return 1.0; } +// RCTUIImage - NSImage subclass with cached CGImage + +@implementation RCTUIImage { + CGImageRef _cachedCGImage; +} + +- (void)dealloc { + if (_cachedCGImage != NULL) { + CGImageRelease(_cachedCGImage); + } +} + +- (CGImageRef)CGImage { + if (_cachedCGImage == NULL) { + CGImageRef cgImage = [self CGImageForProposedRect:NULL context:NULL hints:NULL]; + if (cgImage != NULL) { + _cachedCGImage = CGImageRetain(cgImage); + } + } + return _cachedCGImage; +} + +- (CGFloat)scale { + return UIImageGetScale(self); +} + +@end + CGImageRef __nullable UIImageGetCGImageRef(NSImage *image) { + // If it's an RCTUIImage, use the cached CGImage property + if ([image isKindOfClass:[RCTUIImage class]]) { + return ((RCTUIImage *)image).CGImage; + } + + // Otherwise, fall back to the standard NSImage method + // Note: This returns an autoreleased CGImageRef return [image CGImageForProposedRect:NULL context:NULL hints:NULL]; } @@ -825,11 +860,10 @@ - (nonnull instancetype)initWithSize:(CGSize)size format:(nonnull RCTUIGraphicsI return self; } -- (nonnull NSImage *)imageWithActions:(NS_NOESCAPE RCTUIGraphicsImageDrawingActions)actions { - - NSImage *image = [NSImage imageWithSize:_size - flipped:YES - drawingHandler:^BOOL(NSRect dstRect) { +- (nonnull RCTUIImage *)imageWithActions:(NS_NOESCAPE RCTUIGraphicsImageDrawingActions)actions { + RCTUIImage *image = [RCTUIImage imageWithSize:_size + flipped:YES + drawingHandler:^BOOL(NSRect dstRect) { RCTUIGraphicsImageRendererContext *context = [NSGraphicsContext currentContext]; if (self->_format.opaque) { @@ -838,6 +872,12 @@ - (nonnull NSImage *)imageWithActions:(NS_NOESCAPE RCTUIGraphicsImageDrawingActi actions(context); return YES; }]; + + // Calling these in succession forces the image to render its contents immediately, + // rather than deferring until later. + [image lockFocus]; + [image unlockFocus]; + return image; } 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 5c27c490ef1319..6b8aee0d7b16d8 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -839,7 +839,7 @@ static void RCTAddContourEffectToLayer( const UIEdgeInsets &contourInsets, const RCTBorderStyle &contourStyle) { - RCTPlatformImage *image = RCTGetBorderImage( // [macOS] + RCTUIImage *image = RCTGetBorderImage( // [macOS] contourStyle, layer.bounds.size, cornerRadii, contourInsets, contourColors, [RCTUIColor clearColor], NO); // [macOS] if (image == nil) { @@ -850,13 +850,8 @@ static void RCTAddContourEffectToLayer( CGRect contentsCenter = CGRect{ CGPoint{imageCapInsets.left / imageSize.width, imageCapInsets.top / imageSize.height}, CGSize{(CGFloat)1.0 / imageSize.width, (CGFloat)1.0 / imageSize.height}}; -#if !TARGET_OS_OSX // [macOS] layer.contents = (id)image.CGImage; layer.contentsScale = image.scale; -#else // [macOS - layer.contents = (__bridge id) UIImageGetCGImageRef(image); - layer.contentsScale = UIImageGetScale(image); -#endif // macOS] BOOL isResizable = !UIEdgeInsetsEqualToEdgeInsets(image.capInsets, UIEdgeInsetsZero); if (isResizable) { @@ -1292,17 +1287,13 @@ - (void)invalidateLayer _boxShadowLayer.zPosition = _borderLayer.zPosition; _boxShadowLayer.frame = RCTGetBoundingRect(_props->boxShadow, self.layer.bounds.size); - RCTPlatformImage *boxShadowImage = RCTGetBoxShadowImage( // [macOS] + RCTUIImage *boxShadowImage = RCTGetBoxShadowImage( // [macOS] _props->boxShadow, RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), RCTUIEdgeInsetsFromEdgeInsets(borderMetrics.borderWidths), self.layer.bounds.size); -#if !TARGET_OS_OSX // [macOS] _boxShadowLayer.contents = (id)boxShadowImage.CGImage; -#else // [macOS - _boxShadowLayer.contents = (__bridge id)UIImageGetCGImageRef(boxShadowImage); -#endif // macOS] } // clipping diff --git a/packages/react-native/React/Fabric/Utils/RCTBoxShadow.h b/packages/react-native/React/Fabric/Utils/RCTBoxShadow.h index 919c959878e766..866d16bbbb9661 100644 --- a/packages/react-native/React/Fabric/Utils/RCTBoxShadow.h +++ b/packages/react-native/React/Fabric/Utils/RCTBoxShadow.h @@ -12,7 +12,7 @@ #import #import -RCT_EXTERN RCTPlatformImage *RCTGetBoxShadowImage( // [macOS] +RCT_EXTERN RCTUIImage *RCTGetBoxShadowImage( // [macOS] const std::vector &shadows, RCTCornerRadii cornerRadii, UIEdgeInsets edgeInsets, diff --git a/packages/react-native/React/Fabric/Utils/RCTBoxShadow.mm b/packages/react-native/React/Fabric/Utils/RCTBoxShadow.mm index 5d0262ec844d26..14450a1e03078e 100644 --- a/packages/react-native/React/Fabric/Utils/RCTBoxShadow.mm +++ b/packages/react-native/React/Fabric/Utils/RCTBoxShadow.mm @@ -281,7 +281,7 @@ static void renderInsetShadows( CGContextRestoreGState(context); } -RCTPlatformImage *RCTGetBoxShadowImage( // [macOS] +RCTUIImage *RCTGetBoxShadowImage( // [macOS] const std::vector &shadows, RCTCornerRadii cornerRadii, UIEdgeInsets edgeInsets, @@ -293,7 +293,7 @@ static void renderInsetShadows( RCTUIGraphicsImageRenderer *const renderer = [[RCTUIGraphicsImageRenderer alloc] initWithSize:boundingRect.size format:rendererFormat]; // macOS] - RCTPlatformImage *const boxShadowImage = // [macOS] + RCTUIImage *const boxShadowImage = // [macOS] [renderer imageWithActions:^(RCTUIGraphicsImageRendererContext *_Nonnull rendererContext) { // [macOS] auto [outsetShadows, insetShadows] = splitBoxShadowsByInset(shadows); const CGContextRef context = rendererContext.CGContext; diff --git a/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm b/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm index 2708e49534490c..934a5b0a84fb48 100644 --- a/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm +++ b/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm @@ -20,7 +20,7 @@ + (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const LinearGradient & { RCTUIGraphicsImageRenderer *renderer = [[RCTUIGraphicsImageRenderer alloc] initWithSize:size]; // [macOS] const auto &direction = gradient.direction; - RCTPlatformImage *gradientImage = [renderer imageWithActions:^(RCTUIGraphicsImageRendererContext *_Nonnull rendererContext) { // [macOS] + RCTUIImage *gradientImage = [renderer imageWithActions:^(RCTUIGraphicsImageRendererContext *_Nonnull rendererContext) { // [macOS] CGPoint startPoint; CGPoint endPoint; @@ -65,11 +65,7 @@ + (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const LinearGradient & }]; CALayer *gradientLayer = [CALayer layer]; -#if !TARGET_OS_OSX // [macOS] - gradientLayer.contents = (__bridge id)gradientImage.CGImage; -#else // [macOS - gradientLayer.contents = (__bridge id)UIImageGetCGImageRef(gradientImage); -#endif // macOS] + gradientLayer.contents = (id)gradientImage.CGImage; return gradientLayer; } diff --git a/packages/react-native/React/Views/RCTBorderDrawing.h b/packages/react-native/React/Views/RCTBorderDrawing.h index 7ee31df3df262f..2e07ffc2f1a2bd 100644 --- a/packages/react-native/React/Views/RCTBorderDrawing.h +++ b/packages/react-native/React/Views/RCTBorderDrawing.h @@ -62,7 +62,7 @@ RCTPathCreateWithRoundedRect(CGRect bounds, RCTCornerInsets cornerInsets, const * `borderInsets` defines the border widths for each edge. * `scaleFactor` defines the backing scale factor of the device for supporting high-resolution drawing. // [macOS] */ -RCT_EXTERN RCTPlatformImage *RCTGetBorderImage( // [macOS] +RCT_EXTERN RCTUIImage *RCTGetBorderImage( // [macOS] RCTBorderStyle borderStyle, CGSize viewSize, RCTCornerRadii cornerRadii, diff --git a/packages/react-native/React/Views/RCTBorderDrawing.m b/packages/react-native/React/Views/RCTBorderDrawing.m index 041c694a169d2e..4f0f6f54cea833 100644 --- a/packages/react-native/React/Views/RCTBorderDrawing.m +++ b/packages/react-native/React/Views/RCTBorderDrawing.m @@ -191,7 +191,7 @@ static CGPathRef RCTPathCreateOuterOutline(BOOL drawToEdge, CGRect rect, RCTCorn return renderer; } -static RCTPlatformImage *RCTGetSolidBorderImage( // [macOS] +static RCTUIImage *RCTGetSolidBorderImage( // [macOS] RCTCornerRadii cornerRadii, CGSize viewSize, UIEdgeInsets borderInsets, @@ -231,7 +231,7 @@ static CGPathRef RCTPathCreateOuterOutline(BOOL drawToEdge, CGRect rect, RCTCorn RCTUIGraphicsImageRenderer *const imageRenderer = RCTMakeUIGraphicsImageRenderer(size, backgroundColor, hasCornerRadii, drawToEdge); - RCTPlatformImage *image = [imageRenderer imageWithActions:^(RCTUIGraphicsImageRendererContext *_Nonnull rendererContext) { // [macOS] + RCTUIImage *image = [imageRenderer imageWithActions:^(RCTUIGraphicsImageRendererContext *_Nonnull rendererContext) { // [macOS] const CGContextRef context = rendererContext.CGContext; const CGRect rect = {.size = size}; CGPathRef path = RCTPathCreateOuterOutline(drawToEdge, rect, cornerRadii); @@ -461,7 +461,7 @@ static CGPathRef RCTPathCreateOuterOutline(BOOL drawToEdge, CGRect rect, RCTCorn // of gradients _along_ a path (NB: clipping a path and drawing a linear gradient // is _not_ equivalent). -static RCTPlatformImage *RCTGetDashedOrDottedBorderImage( // [macOS] +static RCTUIImage *RCTGetDashedOrDottedBorderImage( // [macOS] RCTBorderStyle borderStyle, RCTCornerRadii cornerRadii, CGSize viewSize, @@ -525,7 +525,7 @@ static CGPathRef RCTPathCreateOuterOutline(BOOL drawToEdge, CGRect rect, RCTCorn }]; } -RCTPlatformImage *RCTGetBorderImage( // [macOS] +RCTUIImage *RCTGetBorderImage( // [macOS] RCTBorderStyle borderStyle, CGSize viewSize, RCTCornerRadii cornerRadii, diff --git a/packages/react-native/React/Views/RCTView.m b/packages/react-native/React/Views/RCTView.m index 3d7271b4553525..513b5dc4fa67f1 100644 --- a/packages/react-native/React/Views/RCTView.m +++ b/packages/react-native/React/Views/RCTView.m @@ -1269,7 +1269,7 @@ - (void)displayLayer:(CALayer *)layer return; } - RCTPlatformImage *image = RCTGetBorderImage( // [macOS] + RCTUIImage *image = RCTGetBorderImage( // [macOS] _borderStyle, layer.bounds.size, cornerRadii, borderInsets, borderColors, backgroundColor, self.clipsToBounds); layer.backgroundColor = NULL; From 62a31d53a838519ffb9eec875c2986156c7bdd9b Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 30 Dec 2025 00:33:19 -0600 Subject: [PATCH 2/4] more fixes --- .../Libraries/Image/RCTImageBlurUtils.mm | 9 +++------ packages/react-native/React/Base/RCTUIKit.h | 18 ++++++++++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/react-native/Libraries/Image/RCTImageBlurUtils.mm b/packages/react-native/Libraries/Image/RCTImageBlurUtils.mm index 4b08ec2dff47e1..2391c1a164ce7b 100644 --- a/packages/react-native/Libraries/Image/RCTImageBlurUtils.mm +++ b/packages/react-native/Libraries/Image/RCTImageBlurUtils.mm @@ -30,16 +30,13 @@ RCTUIGraphicsImageRenderer *const renderer = [[RCTUIGraphicsImageRenderer alloc] initWithSize:inputImage.size // [macOS] format:rendererFormat]; + imageRef = [renderer imageWithActions:^(RCTUIGraphicsImageRendererContext *_Nonnull context) { // [macOS] #if !TARGET_OS_OSX // [macOS] - imageRef = [renderer imageWithActions:^(UIGraphicsImageRendererContext *_Nonnull context) { [inputImage drawAtPoint:CGPointZero]; - }].CGImage; #else // [macOS - NSImage *image = [renderer imageWithActions:^(RCTUIGraphicsImageRendererContext *_Nonnull context) { - [inputImage drawAtPoint:CGPointZero fromRect:NSZeroRect operation:NSCompositingOperationSourceOver fraction:1.0]; - }]; - imageRef = UIImageGetCGImageRef(image); + [inputImage drawAtPoint:CGPointZero fromRect:NSZeroRect operation:NSCompositingOperationSourceOver fraction:1.0]; #endif // macOS] + }].CGImage; } vImage_Buffer buffer1, buffer2; buffer1.width = buffer2.width = CGImageGetWidth(imageRef); diff --git a/packages/react-native/React/Base/RCTUIKit.h b/packages/react-native/React/Base/RCTUIKit.h index 78037d1649ea59..e89212ae887c66 100644 --- a/packages/react-native/React/Base/RCTUIKit.h +++ b/packages/react-native/React/Base/RCTUIKit.h @@ -332,13 +332,23 @@ NS_INLINE NSEdgeInsets UIEdgeInsetsMake(CGFloat top, CGFloat left, CGFloat botto // UIApplication #define UIApplication NSApplication -// UIImage -// RCTUIImage is a subclass of NSImage that caches its CGImage representation. -// This is needed because NSImage's CGImageForProposedRect: returns a new autoreleased -// CGImage each time, which causes issues when used with CALayer.contents. +/** + * An NSImage subclass that caches its CGImage representation. + * + * RCTUIImage solves an issue where NSImage's `CGImageForProposedRect:` returns a new + * autoreleased CGImage each time it's called. When assigned to `CALayer.contents`, these + * autoreleased CGImages get deallocated when the autorelease pool drains, causing rendering + * issues (e.g., blank borders and shadows). + * + * @warning Treat RCTUIImage instances as immutable after creation. Do not modify the image's + * representations or properties after accessing the CGImage property. + */ @interface RCTUIImage : NSImage + @property (nonatomic, readonly, nullable) CGImageRef CGImage; + @property (nonatomic, readonly) CGFloat scale; + @end typedef NS_ENUM(NSInteger, UIImageRenderingMode) { From 1d26a21bd664ddd29b7b2ba26043281336918e8b Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 23 Dec 2025 17:08:21 -0600 Subject: [PATCH 3/4] fixup clipsToBounds --- .../ComponentViews/View/RCTViewComponentView.mm | 11 +++++++++-- 1 file changed, 9 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 6b8aee0d7b16d8..b2c2ff050e0182 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -1045,8 +1045,15 @@ - (void)invalidateLayer } #if TARGET_OS_OSX // [macOS - // clipsToBounds is stubbed out on macOS because it's not part of NSView - layer.masksToBounds = self.clipsToBounds; + // On macOS, clipsToBounds doesn't automatically set layer.masksToBounds like iOS does. + // When _useCustomContainerView is true (boxShadow + overflow:hidden), the container + // view handles clipping children while the main layer stays unclipped for the shadow. + // The container view's masksToBounds is set in currentContainerView getter. + if (_useCustomContainerView) { + layer.masksToBounds = NO; + } else { + layer.masksToBounds = _props->getClipsContentToBounds(); + } #endif // macOS] const auto borderMetrics = _props->resolveBorderMetrics(_layoutMetrics); From b578b756b650868d65105ccc30852e59719f1d90 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 30 Dec 2025 14:24:35 -0600 Subject: [PATCH 4/4] Update UIImage+Compare --- .../rn-tester/RCTTest/FBSnapshotTestCase/UIImage+Compare.h | 4 ++-- .../rn-tester/RCTTest/FBSnapshotTestCase/UIImage+Compare.m | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/rn-tester/RCTTest/FBSnapshotTestCase/UIImage+Compare.h b/packages/rn-tester/RCTTest/FBSnapshotTestCase/UIImage+Compare.h index 66b03175ecba9e..4c5c3813218d52 100644 --- a/packages/rn-tester/RCTTest/FBSnapshotTestCase/UIImage+Compare.h +++ b/packages/rn-tester/RCTTest/FBSnapshotTestCase/UIImage+Compare.h @@ -7,8 +7,8 @@ #import // [macOS] -@interface UIImage (Compare) +@interface RCTPlatformImage (Compare) // [macOS] -- (BOOL)compareWithImage:(UIImage *)image; +- (BOOL)compareWithImage:(RCTPlatformImage *)image; @end diff --git a/packages/rn-tester/RCTTest/FBSnapshotTestCase/UIImage+Compare.m b/packages/rn-tester/RCTTest/FBSnapshotTestCase/UIImage+Compare.m index 9aa90fa83c6e0d..2f2212e01143e7 100644 --- a/packages/rn-tester/RCTTest/FBSnapshotTestCase/UIImage+Compare.m +++ b/packages/rn-tester/RCTTest/FBSnapshotTestCase/UIImage+Compare.m @@ -7,9 +7,9 @@ #import "UIImage+Compare.h" -@implementation UIImage (Compare) +@implementation RCTPlatformImage (Compare) // [macOS[ -- (BOOL)compareWithImage:(UIImage *)image +- (BOOL)compareWithImage:(RCTPlatformImage *)image // [macOS] { NSAssert(CGSizeEqualToSize(self.size, image.size), @"Images must be same size.");