From 119c7d1d87ee9a83be8071dad254e9b19f9d1ba6 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Mon, 19 Oct 2026 20:40:16 +0200 Subject: [PATCH 1/2] Reuse solid border images across iOS Views with the same border A stretchable solid border image depends only on the corner radii, border widths and colors, not on the View's size, yet every invalidateLayer drew a new one. Keep stretchable images in an NSCache keyed by those inputs so Views with the same border share one image. Images drawn with dynamic colors are not cached, since they depend on the trait collection. Co-Authored-By: Claude Opus 5.5 --- .../React/Views/RCTBorderDrawing.m | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/react-native/React/Views/RCTBorderDrawing.m b/packages/react-native/React/Views/RCTBorderDrawing.m index 472885ca0c39..d47163a4db8d 100644 --- a/packages/react-native/React/Views/RCTBorderDrawing.m +++ b/packages/react-native/React/Views/RCTBorderDrawing.m @@ -213,6 +213,55 @@ static CGPathRef RCTPathCreateOuterOutline(BOOL drawToEdge, CGRect rect, RCTCorn return renderer; } +static id RCTSolidBorderImageCacheColor(UIColor *color) +{ + if (color == nil) { + return [NSNull null]; + } + // A dynamic color resolves differently per trait collection, so an image drawn with it can't be reused. + return [color resolvedColorWithTraitCollection:[UITraitCollection currentTraitCollection]] == color ? color : nil; +} + +// A stretchable border image doesn't depend on the view size, only on these inputs, so views with the +// same border can share one image instead of each drawing its own. +static NSArray *RCTSolidBorderImageCacheKey( + RCTCornerRadii cornerRadii, + UIEdgeInsets borderInsets, + RCTBorderColors borderColors, + UIColor *backgroundColor, + BOOL drawToEdge) +{ + id top = RCTSolidBorderImageCacheColor(borderColors.top); + id left = RCTSolidBorderImageCacheColor(borderColors.left); + id bottom = RCTSolidBorderImageCacheColor(borderColors.bottom); + id right = RCTSolidBorderImageCacheColor(borderColors.right); + id background = RCTSolidBorderImageCacheColor(backgroundColor); + if (top == nil || left == nil || bottom == nil || right == nil || background == nil) { + return nil; + } + return @[ + [NSValue valueWithBytes:&cornerRadii objCType:@encode(RCTCornerRadii)], + [NSValue valueWithUIEdgeInsets:borderInsets], + top, + left, + bottom, + right, + background, + @(drawToEdge), + ]; +} + +static NSCache *RCTSolidBorderImageCache(void) +{ + static NSCache *cache; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + cache = [NSCache new]; + cache.countLimit = 64; + }); + return cache; +} + static UIImage *RCTGetSolidBorderImage( RCTCornerRadii cornerRadii, CGSize viewSize, @@ -223,6 +272,9 @@ static CGPathRef RCTPathCreateOuterOutline(BOOL drawToEdge, CGRect rect, RCTCorn { const BOOL hasCornerRadii = RCTCornerRadiiAreAboveThreshold(cornerRadii); const RCTCornerInsets cornerInsets = RCTGetCornerInsets(cornerRadii, borderInsets); + // Keyed by the widths as passed in, since the corner insets above are computed from them before rounding. + NSArray *const cacheKey = + RCTSolidBorderImageCacheKey(cornerRadii, borderInsets, borderColors, backgroundColor, drawToEdge); // Incorrect render for borders that are not proportional to device pixel: borders get stretched and become // significantly bigger than expected. @@ -239,6 +291,13 @@ static CGPathRef RCTPathCreateOuterOutline(BOOL drawToEdge, CGRect rect, RCTCorn (borderInsets.top + cornerInsets.topRight.height + borderInsets.bottom + cornerInsets.bottomLeft.height <= viewSize.height); + if (makeStretchable && cacheKey != nil) { + UIImage *cachedImage = [RCTSolidBorderImageCache() objectForKey:cacheKey]; + if (cachedImage != nil) { + return cachedImage; + } + } + UIEdgeInsets edgeInsets = (UIEdgeInsets){borderInsets.top + MAX(cornerInsets.topLeft.height, cornerInsets.topRight.height), borderInsets.left + MAX(cornerInsets.topLeft.width, cornerInsets.bottomLeft.width), @@ -417,6 +476,9 @@ static CGPathRef RCTPathCreateOuterOutline(BOOL drawToEdge, CGRect rect, RCTCorn if (makeStretchable) { image = [image resizableImageWithCapInsets:edgeInsets]; + if (cacheKey != nil) { + [RCTSolidBorderImageCache() setObject:image forKey:cacheKey]; + } } return image; From 4855cb0ffa3cdeefc666f1103ad03cea47e60df6 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Tue, 20 Oct 2026 12:20:19 +0200 Subject: [PATCH 2/2] Hash the border image cache key An NSArray key hashes by its element count and NSValue gives every struct of a custom type the same hash, so lookups compared against every cached key. Use a small key class that hashes the radii and widths itself and compares all inputs exactly. Co-Authored-By: Claude Opus 5.5 --- .../React/Views/RCTBorderDrawing.m | 94 ++++++++++++++++--- 1 file changed, 79 insertions(+), 15 deletions(-) diff --git a/packages/react-native/React/Views/RCTBorderDrawing.m b/packages/react-native/React/Views/RCTBorderDrawing.m index d47163a4db8d..d13327752da6 100644 --- a/packages/react-native/React/Views/RCTBorderDrawing.m +++ b/packages/react-native/React/Views/RCTBorderDrawing.m @@ -222,9 +222,79 @@ static id RCTSolidBorderImageCacheColor(UIColor *color) return [color resolvedColorWithTraitCollection:[UITraitCollection currentTraitCollection]] == color ? color : nil; } +// Key of the solid border image cache. It hashes the radii and widths itself: NSArray hashes by count +// and NSValue gives every struct of a custom type the same hash. +@interface RCTSolidBorderImageCacheKey : NSObject +@end + +@implementation RCTSolidBorderImageCacheKey { + RCTCornerRadii _cornerRadii; + UIEdgeInsets _borderInsets; + NSArray *_colors; + BOOL _drawToEdge; + NSUInteger _hash; +} + +- (instancetype)initWithCornerRadii:(RCTCornerRadii)cornerRadii + borderInsets:(UIEdgeInsets)borderInsets + colors:(NSArray *)colors + drawToEdge:(BOOL)drawToEdge +{ + if (self = [super init]) { + _cornerRadii = cornerRadii; + _borderInsets = borderInsets; + _colors = colors; + _drawToEdge = drawToEdge; + const CGFloat values[] = { + cornerRadii.topLeftHorizontal, + cornerRadii.topLeftVertical, + cornerRadii.topRightHorizontal, + cornerRadii.topRightVertical, + cornerRadii.bottomLeftHorizontal, + cornerRadii.bottomLeftVertical, + cornerRadii.bottomRightHorizontal, + cornerRadii.bottomRightVertical, + borderInsets.top, + borderInsets.left, + borderInsets.bottom, + borderInsets.right, + }; + NSUInteger hash = drawToEdge; + for (size_t i = 0; i < sizeof(values) / sizeof(values[0]); i++) { + uint64_t bits; + memcpy(&bits, &values[i], sizeof(bits)); + hash = hash * 31 + (NSUInteger)(bits ^ (bits >> 32)); + } + for (id color in colors) { + hash = hash * 31 + [color hash]; + } + _hash = hash; + } + return self; +} + +- (NSUInteger)hash +{ + return _hash; +} + +- (BOOL)isEqual:(id)object +{ + if (![object isKindOfClass:[RCTSolidBorderImageCacheKey class]]) { + return NO; + } + RCTSolidBorderImageCacheKey *other = object; + return _hash == other->_hash && _drawToEdge == other->_drawToEdge && + memcmp(&_cornerRadii, &other->_cornerRadii, sizeof(RCTCornerRadii)) == 0 && + memcmp(&_borderInsets, &other->_borderInsets, sizeof(UIEdgeInsets)) == 0 && + [_colors isEqualToArray:other->_colors]; +} + +@end + // A stretchable border image doesn't depend on the view size, only on these inputs, so views with the // same border can share one image instead of each drawing its own. -static NSArray *RCTSolidBorderImageCacheKey( +static RCTSolidBorderImageCacheKey *RCTSolidBorderImageCacheKeyFor( RCTCornerRadii cornerRadii, UIEdgeInsets borderInsets, RCTBorderColors borderColors, @@ -239,21 +309,15 @@ static id RCTSolidBorderImageCacheColor(UIColor *color) if (top == nil || left == nil || bottom == nil || right == nil || background == nil) { return nil; } - return @[ - [NSValue valueWithBytes:&cornerRadii objCType:@encode(RCTCornerRadii)], - [NSValue valueWithUIEdgeInsets:borderInsets], - top, - left, - bottom, - right, - background, - @(drawToEdge), - ]; + return [[RCTSolidBorderImageCacheKey alloc] initWithCornerRadii:cornerRadii + borderInsets:borderInsets + colors:@[ top, left, bottom, right, background ] + drawToEdge:drawToEdge]; } -static NSCache *RCTSolidBorderImageCache(void) +static NSCache *RCTSolidBorderImageCache(void) { - static NSCache *cache; + static NSCache *cache; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ cache = [NSCache new]; @@ -273,8 +337,8 @@ static id RCTSolidBorderImageCacheColor(UIColor *color) const BOOL hasCornerRadii = RCTCornerRadiiAreAboveThreshold(cornerRadii); const RCTCornerInsets cornerInsets = RCTGetCornerInsets(cornerRadii, borderInsets); // Keyed by the widths as passed in, since the corner insets above are computed from them before rounding. - NSArray *const cacheKey = - RCTSolidBorderImageCacheKey(cornerRadii, borderInsets, borderColors, backgroundColor, drawToEdge); + RCTSolidBorderImageCacheKey *const cacheKey = + RCTSolidBorderImageCacheKeyFor(cornerRadii, borderInsets, borderColors, backgroundColor, drawToEdge); // Incorrect render for borders that are not proportional to device pixel: borders get stretched and become // significantly bigger than expected.