From 4157ab470fadccd3266d67df67e22c1c6b9a85dd Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Thu, 23 Jul 2026 09:18:04 -0700 Subject: [PATCH] PlatformColor lazy fallback: honor a raw-color fallback on iOS and macOS (native) (#57556) Summary: An implementation for the RFC in https://github.com/react-native-community/discussions-and-proposals/pull/1008 This is the first diff in a stack that adds an optional trailing `{fallback: ''}` argument to `PlatformColor(...)`. The fallback is carried lazily to native and parsed only when every supplied color token fails to resolve. This diff wires up the iOS and macOS native color resolvers to honor that fallback; a later diff adds the JS argument that emits it, so on its own this change is a no-op for existing call sites. To distinguish a genuine miss (no token resolves) from a token that legitimately resolves to a transparent color, the semantic-color resolver gains an `OrNil` variant: - `RCTPlatformColorFromSemanticItemsOrNil` returns `nil` on a miss instead of collapsing to `clearColor`. `RCTPlatformColorFromSemanticItems` keeps its existing non-null contract by wrapping the `OrNil` variant. - On Fabric, `Color::createSemanticColor` returns the undefined-color sentinel (a null underlying `UIColor`) on a miss, so `PlatformColorParser.mm` can apply the fallback only on a true miss and otherwise render transparent exactly as before. - The fallback string is parsed with the shared CSS color parser (`parseCSSProperty`), so `transparent`/`rgba(0,0,0,0)` are honored rather than mistaken for a parse failure. - On the iOS Paper (legacy) path, `RCTConvert.mm` parses the common hex forms (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`, alpha last) as the fallback when every semantic name misses. This spans the `xplat` iOS/macOS graphics resolvers and the vendored `third-party/react-native-macos` copies. Changelog: [Internal] - PlatformColor: iOS/macOS native support for a lazy raw-color fallback Differential Revision: D111837102 --- .../react-native/React/Base/RCTConvert.mm | 76 +++++++++++++++++++ .../renderer/graphics/HostPlatformColor.h | 5 ++ .../renderer/graphics/HostPlatformColor.mm | 9 ++- .../renderer/graphics/PlatformColorParser.mm | 41 +++++++++- .../renderer/graphics/RCTPlatformColorUtils.h | 12 +++ .../graphics/RCTPlatformColorUtils.mm | 15 +++- 6 files changed, 154 insertions(+), 4 deletions(-) diff --git a/packages/react-native/React/Base/RCTConvert.mm b/packages/react-native/React/Base/RCTConvert.mm index 9b7dd699ce6b..992a19fd947d 100644 --- a/packages/react-native/React/Base/RCTConvert.mm +++ b/packages/react-native/React/Base/RCTConvert.mm @@ -944,6 +944,74 @@ + (RCTColorSpace)RCTColorSpaceFromString:(NSString *)colorSpace return RCTGetDefaultColorSpace(); } +// Parses a raw hex color string (#RGB, #RGBA, #RRGGBB, #RRGGBBAA — alpha last, as +// in CSS) into a UIColor, or nil if it is not a supported hex color. Used only as +// a PlatformColor fallback when every semantic name misses. (On the iOS New +// Architecture the fallback is parsed by the shared CSS color parser, which also +// accepts rgb()/rgba() and named colors; this legacy path handles the common hex +// forms.) +static UIColor *RCTFallbackUIColorFromHexString(NSString *colorString) +{ + if (![colorString isKindOfClass:[NSString class]]) { + return nil; + } + NSString *hex = [colorString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (![hex hasPrefix:@"#"]) { + return nil; + } + hex = [hex substringFromIndex:1]; + NSUInteger length = hex.length; + // Expand shorthand #RGB / #RGBA to #RRGGBB / #RRGGBBAA. + if (length == 3 || length == 4) { + NSMutableString *expanded = [NSMutableString stringWithCapacity:length * 2]; + for (NSUInteger i = 0; i < length; i++) { + unichar c = [hex characterAtIndex:i]; + [expanded appendFormat:@"%C%C", c, c]; + } + hex = expanded; + length = hex.length; + } + if (length != 6 && length != 8) { + return nil; + } + // Initialized once: the inverted hex-digit set is immutable, so there is no + // need to reallocate it on every fallback parse. + static NSCharacterSet *const nonHex = + [[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"] invertedSet]; + if ([hex rangeOfCharacterFromSet:nonHex].location != NSNotFound) { + return nil; + } + unsigned int value = 0; + if (![[NSScanner scannerWithString:hex] scanHexInt:&value]) { + return nil; + } + CGFloat r = NAN; + CGFloat g = NAN; + CGFloat b = NAN; + CGFloat a = NAN; + if (length == 6) { + r = ((value >> 16) & 0xFF) / 255.0; + g = ((value >> 8) & 0xFF) / 255.0; + b = (value & 0xFF) / 255.0; + a = 1.0; + } else { + r = ((value >> 24) & 0xFF) / 255.0; + g = ((value >> 16) & 0xFF) / 255.0; + b = ((value >> 8) & 0xFF) / 255.0; + a = (value & 0xFF) / 255.0; + } + return [UIColor colorWithRed:r green:g blue:b alpha:a]; +} + +static UIColor *RCTFallbackUIColorFromColorDictionary(NSDictionary *dictionary) +{ + id fallback = [dictionary objectForKey:@"fallback"]; + if ([fallback isKindOfClass:[NSString class]]) { + return RCTFallbackUIColorFromHexString(fallback); + } + return nil; +} + + (UIColor *)UIColor:(id)json { if (!json) { @@ -983,6 +1051,10 @@ + (UIColor *)UIColor:(id)json } color = RCTColorFromSemanticColorName(semanticName); if (color == nil) { + UIColor *fallbackColor = RCTFallbackUIColorFromColorDictionary(dictionary); + if (fallbackColor != nil) { + return fallbackColor; + } RCTLogConvertError( json, [@"a UIColor. Expected one of the following values: " stringByAppendingString:RCTSemanticColorNames()]); @@ -999,6 +1071,10 @@ + (UIColor *)UIColor:(id)json return color; } } + UIColor *fallbackColor = RCTFallbackUIColorFromColorDictionary(dictionary); + if (fallbackColor != nil) { + return fallbackColor; + } RCTLogConvertError( json, [@"a UIColor. None of the names in the array were one of the following values: " diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.h b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.h index b9535f14e363..778b7fa71f11 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.h @@ -28,6 +28,11 @@ struct Color { int32_t getColor() const; std::size_t getUIColorHash() const; + // Returns UndefinedColor (a Color whose underlying UIColor is null) when no + // semantic name resolves, so callers can distinguish a genuine miss from a name + // that resolves to a transparent color. getColor() still yields 0 for it, but + // callers that reach into the underlying UIColor (e.g. -CGColor) must null-check + // getUIColor() first. static Color createSemanticColor(std::vector &semanticItems); std::shared_ptr getUIColor() const diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm index 93ecb7e33190..cdf776338106 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm @@ -237,7 +237,14 @@ int32_t ColorFromUIColor(const std::shared_ptr &uiColor) Color Color::createSemanticColor(std::vector &semanticItems) { - auto semanticColor = RCTPlatformColorFromSemanticItems(semanticItems); + UIColor *semanticColor = RCTPlatformColorFromSemanticItemsOrNil(semanticItems); + if (semanticColor == nil) { + // No semantic name resolved. Return the undefined-color sentinel (a null + // underlying UIColor) so the caller can tell a genuine miss apart from a name + // that legitimately resolves to a transparent color; getColor() still yields + // 0 (transparent) for it, preserving the previous render on a miss. + return HostPlatformColor::UndefinedColor; + } return Color(wrapManagedObject(semanticColor)); } diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/PlatformColorParser.mm b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/PlatformColorParser.mm index 377783f0f195..71dfd3e345f8 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/PlatformColorParser.mm +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/PlatformColorParser.mm @@ -8,9 +8,13 @@ #import "PlatformColorParser.h" #import +#import +#import +#import #import #import #import +#import #import #import @@ -51,13 +55,48 @@ return SharedColor(color); } +// Parses a raw color string (e.g. "#f00", "#ff0000", "#ff000080", "rgba(...)") +// using the shared CSS color parser. Returns std::nullopt when the string is not +// a parseable color, so a fallback that parses to a transparent color (e.g. +// "transparent" or "rgba(0,0,0,0)") is still honored rather than being mistaken +// for a parse failure. +static std::optional fallbackColorFromString(const std::string &fallbackString) +{ + auto cssColor = parseCSSProperty(fallbackString); + if (std::holds_alternative(cssColor)) { + const auto &c = std::get(cssColor); + return colorFromRGBA(c.r, c.g, c.b, c.a); + } + return std::nullopt; +} + SharedColor parsePlatformColor(const ContextContainer &contextContainer, int32_t surfaceId, const RawValue &value) { if (value.hasType>()) { auto items = (std::unordered_map)value; if (items.find("semantic") != items.end() && items.at("semantic").hasType>()) { auto semanticItems = (std::vector)items.at("semantic"); - return SharedColor(Color::createSemanticColor(semanticItems)); + auto semanticColor = SharedColor(Color::createSemanticColor(semanticItems)); + // createSemanticColor yields the undefined-color sentinel (a null underlying + // UIColor) only when no semantic name resolves. That explicit miss signal is + // distinct from a name that legitimately resolves to a transparent color, so + // the fallback is applied only on a true miss. + bool semanticResolved = ((*semanticColor).getUIColor() != nullptr); + if (!semanticResolved) { + if (items.find("fallback") != items.end() && items.at("fallback").hasType()) { + auto fallbackColor = fallbackColorFromString((std::string)items.at("fallback")); + // has_value() (not getColor() != 0) so a fallback that parses to a + // transparent color is honored rather than rejected. + if (fallbackColor.has_value()) { + return *fallbackColor; + } + } + // A miss with no usable fallback renders transparent, as before. Return a + // real clearColor rather than the sentinel so the null-UIColor sentinel + // never escapes this function. + return clearColor(); + } + return semanticColor; } else if ( items.find("dynamic") != items.end() && items.at("dynamic").hasType>()) { diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.h b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.h index f487564c043d..54ba5c26ab44 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.h @@ -15,6 +15,18 @@ struct ColorComponents; struct Color; } // namespace facebook::react +NS_ASSUME_NONNULL_BEGIN + facebook::react::ColorComponents RCTPlatformColorComponentsFromSemanticItems(std::vector &semanticItems); UIColor *RCTPlatformColorFromSemanticItems(std::vector &semanticItems); +// Like RCTPlatformColorFromSemanticItems, but returns nil (instead of collapsing +// to clearColor) when no name resolves, so callers can tell a genuine miss apart +// from a name that legitimately resolves to a transparent color. +UIColor *_Nullable RCTPlatformColorFromSemanticItemsOrNil(std::vector &semanticItems); +// Precondition: `color` must be a resolved color, never the undefined-color +// sentinel (a null underlying UIColor) that Color::createSemanticColor returns on +// a miss — callers resolve that to a concrete color first — so the result is +// non-null and stays _Nonnull under this region. UIColor *RCTPlatformColorFromColor(const facebook::react::Color &color); + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.mm b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.mm index 4a0a53e2b504..9dfcb336fd3e 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.mm @@ -192,7 +192,7 @@ return _ColorComponentsFromUIColor(RCTPlatformColorFromSemanticItems(semanticItems)); } -UIColor *RCTPlatformColorFromSemanticItems(std::vector &semanticItems) +UIColor *_Nullable RCTPlatformColorFromSemanticItemsOrNil(std::vector &semanticItems) { for (const auto &semanticCString : semanticItems) { NSString *semanticNSString = _NSStringFromCString(semanticCString); @@ -206,7 +206,18 @@ } } - return UIColor.clearColor; + return nil; +} + +UIColor *RCTPlatformColorFromSemanticItems(std::vector &semanticItems) +{ + // Legacy contract: callers that assume a non-null UIColor (e.g. + // RCTPlatformColorComponentsFromSemanticItems, which reads the result with + // -getRed:...) still get clearColor when no name resolves. Callers that must + // distinguish a miss from a name that resolves to a transparent color use the + // OrNil variant instead. + UIColor *uiColor = RCTPlatformColorFromSemanticItemsOrNil(semanticItems); + return uiColor != nil ? uiColor : UIColor.clearColor; } UIColor *RCTPlatformColorFromColor(const facebook::react::Color &color)