From 8627d42ba225428e9142a1e810640a68fb3cc46b Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Thu, 23 Jul 2026 11:10:11 -0700 Subject: [PATCH 1/2] PlatformColor lazy fallback: honor a raw-color fallback on iOS and macOS (native) Summary: 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) From c43f71d9967dc60d67849fb8edab67d8625706ea Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Thu, 23 Jul 2026 11:10:11 -0700 Subject: [PATCH 2/2] PlatformColor lazy fallback: honor a raw-color fallback on Android (native) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: An implementation for the RFC in https://github.com/react-native-community/discussions-and-proposals/pull/1008 Wires up the Android native color resolvers (Fabric and Paper) to honor the lazy `{fallback}` carried by `PlatformColor(...)`. 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 tell a genuine miss apart from a token that resolves to transparent black (ARGB 0): - `FabricUIManager.getColor` now returns a boxed `Nullable Integer` — `null` means no resource path resolved. The Fabric C++ `PlatformColorParser.h` reads that boxed result over JNI, caches the explicit-miss signal, and on a miss parses the raw fallback string. The color object is now read as a `map` because it mixes an array (`resource_paths`) with an optional string (`fallback`). - On the Paper path, `ColorPropConverter` parses the raw fallback string when every resource path misses, and degrades to transparent (rather than crashing) when a fallback is present but unparseable. - `NativeDrawable` carries an optional `colorFallback` alongside `resource_paths` so ripple drawables degrade to the fallback too. The fallback string parser accepts `#RGB`/`#RGBA`/`#RRGGBB`/`#RRGGBBAA` (alpha last, as in CSS and iOS — not Android's native `#AARRGGBB`), `rgb()`/`rgba()`, and named colors. `rgb()`/`rgba()` require exactly 3 / 4 components respectively, matching the CSS parser used on the iOS Fabric path, so lenient inputs are rejected consistently across platforms. Unknown named colors return quietly rather than logging on every resolve. Generated files (`ReactAndroid.api` and the `ReactAndroid*Cxx.api` snapshots) are regenerated to match the new public signatures. Changelog: [Internal] - PlatformColor: Android native support for a lazy raw-color fallback Differential Revision: D113329136 --- .../ReactAndroid/api/ReactAndroid.api | 2 +- .../react/bridge/ColorPropConverter.kt | 138 ++++++++++++++++++ .../react/fabric/FabricUIManager.java | 9 +- .../components/view/HostPlatformViewProps.cpp | 4 + .../renderer/components/view/NativeDrawable.h | 33 ++++- .../renderer/graphics/PlatformColorParser.h | 98 +++++++++---- .../api-snapshots/ReactAndroidDebugCxx.api | 1 + .../api-snapshots/ReactAndroidNewarchCxx.api | 1 + .../api-snapshots/ReactAndroidReleaseCxx.api | 1 + 9 files changed, 248 insertions(+), 39 deletions(-) diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index cd91a0af37fc..f95aa27e7d05 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -2252,7 +2252,7 @@ public class com/facebook/react/fabric/FabricUIManager : com/facebook/react/brid public fun dispatchCommand (IILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V public fun dispatchCommand (ILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V public fun findNextFocusableElement (III)Ljava/lang/Integer; - public fun getColor (I[Ljava/lang/String;)I + public fun getColor (I[Ljava/lang/String;)Ljava/lang/Integer; public fun getEventDispatcher ()Lcom/facebook/react/uimanager/events/EventDispatcher; public fun getPerformanceCounters ()Ljava/util/Map; public fun getRelativeAncestorList (II)[I diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ColorPropConverter.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ColorPropConverter.kt index b70c2171fc0c..fd184284f0a6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ColorPropConverter.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ColorPropConverter.kt @@ -7,6 +7,7 @@ package com.facebook.react.bridge +import android.annotation.SuppressLint import android.content.Context import android.content.res.Resources import android.graphics.Color @@ -15,14 +16,17 @@ import android.os.Build import android.util.TypedValue import androidx.annotation.ColorLong import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.toColorInt import com.facebook.common.logging.FLog import com.facebook.react.common.ReactConstants +import kotlin.math.roundToInt public object ColorPropConverter { private fun supportWideGamut(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O private const val JSON_KEY = "resource_paths" + private const val FALLBACK_KEY = "fallback" private const val PREFIX_RESOURCE = "@" private const val PREFIX_ATTR = "?" private const val PACKAGE_DELIMITER = ":" @@ -71,6 +75,17 @@ public object ColorPropConverter { "ColorValue: Failed to resolve resource paths: ${attemptedPaths.joinToString(", ")}", ) + val fallback = parseFallbackColor(value) + if (fallback != null) { + return fallback + } + // A fallback was provided but could not be parsed: degrade to transparent + // instead of crashing, matching iOS and the New Architecture path. + if (value.hasKey(FALLBACK_KEY)) { + FLog.w(ReactConstants.TAG, "ColorValue: Unparseable fallback color; using transparent.") + return 0 + } + throw JSApplicationCausedNativeException( "ColorValue: None of the paths in the `$JSON_KEY` array resolved to a color resource." ) @@ -127,6 +142,22 @@ public object ColorPropConverter { "ColorValue: Failed to resolve resource paths: ${attemptedPaths.joinToString(", ")}", ) + // Color.valueOf is API 26+, the same requirement as supportWideGamut(), so + // gate the whole fallback block on it: parsed fallbacks return directly, and + // a present-but-unparseable fallback degrades to transparent instead of + // crashing (matching iOS and the New Architecture path). On older API levels + // this method throws and the caller falls back to getColorInteger. + if (supportWideGamut()) { + val fallback = parseFallbackColor(value) + if (fallback != null) { + return Color.valueOf(fallback) + } + if (value.hasKey(FALLBACK_KEY)) { + FLog.w(ReactConstants.TAG, "ColorValue: Unparseable fallback color; using transparent.") + return Color.valueOf(0) + } + } + throw JSApplicationCausedNativeException( "ColorValue: None of the paths in the `$JSON_KEY` array resolved to a color resource." ) @@ -184,6 +215,9 @@ public object ColorPropConverter { } } + // getIdentifier is required here: the resource name arrives dynamically from JS + // at runtime, so a compile-time R.id reference is not possible. + @SuppressLint("DiscouragedApi") private fun resolveResource(context: Context, resourcePath: String): Int { val pathTokens = resourcePath.split(PACKAGE_DELIMITER) var packageName = context.packageName @@ -203,6 +237,9 @@ public object ColorPropConverter { return ResourcesCompat.getColor(context.resources, resourceId, context.theme) } + // getIdentifier is required here: the theme attribute name arrives dynamically + // from JS at runtime, so a compile-time R.attr reference is not possible. + @SuppressLint("DiscouragedApi") @Throws(Resources.NotFoundException::class) private fun resolveThemeAttribute(context: Context, resourcePath: String): Int { val path = resourcePath.replace(ATTR_SEGMENT, "") @@ -231,4 +268,105 @@ public object ColorPropConverter { throw Resources.NotFoundException() } + + // Lazy fallback: a raw color string carried alongside `resource_paths` that is + // parsed only when every resource path fails to resolve, so an unresolved token + // degrades to a developer-provided color instead of crashing. + private fun parseFallbackColor(value: ReadableMap): Int? { + if (!value.hasKey(FALLBACK_KEY)) { + return null + } + val fallback = value.getString(FALLBACK_KEY) + if (fallback.isNullOrEmpty()) { + return null + } + return parseColorString(fallback) + } + + private fun parseColorString(raw: String): Int? { + // CSS color syntax is case-insensitive, so normalize before dispatching: + // otherwise `RGB(...)`, `#ABC`, or `RED` would fall through to null here while + // the shared CSS parser on the Fabric path accepts them, diverging by + // platform. + val color = raw.trim().lowercase() + if (color.isEmpty()) { + return null + } + return when { + color.startsWith("rgb(") || color.startsWith("rgba(") -> parseRgbaColor(color) + // Hex is parsed manually so 8-digit hex is read as #RRGGBBAA (alpha last, + // matching CSS and iOS). Color.parseColor would treat 8-digit hex as + // #AARRGGBB (alpha first), disagreeing with the other platforms for the + // same fallback string. + color.startsWith("#") -> parseHexColor(color) + // Named colors (e.g. "red") are resolved natively, but only for purely + // alphabetic input. Gating on that keeps unsupported CSS forms (hsl() / + // hsla()) and other arbitrary strings from reaching toColorInt. An unknown + // name (e.g. a typo) still fails to parse; return null quietly rather than + // logging a warning on every resolve. + color.all { it.isLetter() } -> + try { + color.toColorInt() + } catch (e: IllegalArgumentException) { + null + } + else -> null + } + } + + private fun parseHexColor(color: String): Int? { + val hex = color.substring(1) + if (hex.isEmpty() || hex.any { Character.digit(it, 16) < 0 }) { + return null + } + // Parse via substring rather than per-index access: indexing a String + // compiles to charAt, which lint flags as UTF-16-unsafe. It is harmless for + // ASCII hex, but substring keeps the code lint-clean and just as clear. + // nibble() expands one hex digit (0xN -> 0xNN); byte() reads two. + fun nibble(i: Int): Int = hex.substring(i, i + 1).toInt(16) * 17 + fun byte(i: Int): Int = hex.substring(i, i + 2).toInt(16) + return when (hex.length) { + // #RGB — expand each nibble. + 3 -> Color.argb(255, nibble(0), nibble(1), nibble(2)) + // #RGBA — expand each nibble; alpha is the last nibble. + 4 -> Color.argb(nibble(3), nibble(0), nibble(1), nibble(2)) + 6 -> Color.argb(255, byte(0), byte(2), byte(4)) + // #RRGGBBAA — alpha is the LAST byte. + 8 -> Color.argb(byte(6), byte(0), byte(2), byte(4)) + else -> null + } + } + + private fun parseRgbaColor(raw: String): Int? { + // `rgb(...)` takes exactly 3 components; `rgba(...)` takes exactly 4. Enforce + // this so lenient inputs (e.g. `rgb(r,g,b,a)` or `rgba(r,g,b)`) are rejected, + // matching the CSS parser used on the iOS Fabric path. + val hasAlphaFn = raw.startsWith("rgba(") + // Require the closing paren: substringBefore(')') would otherwise silently + // accept an unterminated input like `rgb(1,2,3`, disagreeing with the shared + // CSS parser used on the Fabric path. + if (!raw.endsWith(")")) { + return null + } + val inner = raw.substringAfter('(').substringBefore(')') + val parts = inner.split(',').map { it.trim() } + if (parts.size != (if (hasAlphaFn) 4 else 3)) { + return null + } + val r = parts[0].toIntOrNull() ?: return null + val g = parts[1].toIntOrNull() ?: return null + val b = parts[2].toIntOrNull() ?: return null + val a = + if (hasAlphaFn) { + (parts[3].toFloatOrNull() ?: return null).times(255).roundToInt() + } else { + 255 + } + return Color.argb( + a.coerceIn(0, 255), + r.coerceIn(0, 255), + g.coerceIn(0, 255), + b.coerceIn(0, 255), + ) + } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index d96a0fa1e59c..fd86acafa57f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -565,12 +565,12 @@ private NativeArray measureLines( mTextEffectRegistry); } - public int getColor(int surfaceId, String[] resourcePaths) { + public @Nullable Integer getColor(int surfaceId, String[] resourcePaths) { ThemedReactContext context = mMountingManager.getSurfaceManagerEnforced(surfaceId, "getColor").getContext(); // Surface may have been stopped if (context == null) { - return 0; + return null; } for (String resourcePath : resourcePaths) { @@ -579,7 +579,10 @@ public int getColor(int surfaceId, String[] resourcePaths) { return color; } } - return 0; + // No resource path resolved: return null (an explicit miss) rather than 0, so + // the native caller can distinguish an unresolved color from one that resolves + // to transparent black (ARGB 0). + return null; } /** diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp index 19cde3251e84..32289a2c43f1 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp @@ -379,6 +379,10 @@ inline static void updateNativeDrawableProp( } folly::dynamic platformColorMap = folly::dynamic::object(); platformColorMap["resource_paths"] = resourcePaths; + if (nativeDrawableValue.ripple.colorFallback.has_value()) { + platformColorMap["fallback"] = + nativeDrawableValue.ripple.colorFallback.value(); + } nativeDrawableResult["color"] = platformColorMap; } else { nativeDrawableResult["color"] = diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/NativeDrawable.h b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/NativeDrawable.h index 67c5a2bb27b5..4703f5fee5aa 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/NativeDrawable.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/NativeDrawable.h @@ -25,14 +25,21 @@ struct NativeDrawable { struct Ripple { std::optional color{}; std::optional> colorResourcePaths{}; + std::optional colorFallback{}; std::optional rippleRadius{}; bool borderless{false}; std::optional alpha{}; bool operator==(const Ripple &rhs) const { - return std::tie(this->color, this->colorResourcePaths, this->borderless, this->rippleRadius, this->alpha) == - std::tie(rhs.color, rhs.colorResourcePaths, rhs.borderless, rhs.rippleRadius, rhs.alpha); + return std::tie( + this->color, + this->colorResourcePaths, + this->colorFallback, + this->borderless, + this->rippleRadius, + this->alpha) == + std::tie(rhs.color, rhs.colorResourcePaths, rhs.colorFallback, rhs.borderless, rhs.rippleRadius, rhs.alpha); } }; @@ -87,14 +94,25 @@ static inline void fromRawValue(const PropsParserContext &context, const RawValu std::optional parsedColor{}; std::optional> parsedColorResourcePaths{}; + std::optional parsedColorFallback{}; if (color != map.end()) { - if (color->second.hasType>>()) { - auto colorMap = (std::unordered_map>)color->second; + // The color object mixes an array (`resource_paths`) with an optional + // string (`fallback`), so read it as a map of RawValue rather than a map + // of vector (which would assert on the string value). + bool handledAsResourcePaths = false; + if (color->second.hasType>()) { + auto colorMap = (std::unordered_map)color->second; auto pathsIt = colorMap.find("resource_paths"); - if (pathsIt != colorMap.end()) { - parsedColorResourcePaths = pathsIt->second; + if (pathsIt != colorMap.end() && pathsIt->second.hasType>()) { + parsedColorResourcePaths = (std::vector)pathsIt->second; + auto fallbackIt = colorMap.find("fallback"); + if (fallbackIt != colorMap.end() && fallbackIt->second.hasType()) { + parsedColorFallback = (std::string)fallbackIt->second; + } + handledAsResourcePaths = true; } - } else { + } + if (!handledAsResourcePaths) { SharedColor resolved; fromRawValue(context, color->second, resolved); if (resolved) { @@ -114,6 +132,7 @@ static inline void fromRawValue(const PropsParserContext &context, const RawValu NativeDrawable::Ripple{ .color = parsedColor, .colorResourcePaths = parsedColorResourcePaths, + .colorFallback = parsedColorFallback, .rippleRadius = rippleRadius != map.end() && rippleRadius->second.hasType() ? (Float)rippleRadius->second : std::optional{}, diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/android/react/renderer/graphics/PlatformColorParser.h b/packages/react-native/ReactCommon/react/renderer/graphics/platform/android/react/renderer/graphics/PlatformColorParser.h index b27e586e7909..da72aa013175 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/android/react/renderer/graphics/PlatformColorParser.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/android/react/renderer/graphics/PlatformColorParser.h @@ -12,11 +12,14 @@ #include #include #include +#include +#include #include #include #include #include #include +#include #include #include #include @@ -36,39 +39,78 @@ inline SharedColor parsePlatformColor(const ContextContainer &contextContainer, int32_t surfaceId, const RawValue &value) { Color color = 0; - if (value.hasType>>()) { - auto map = (std::unordered_map>)value; - auto &resourcePaths = map["resource_paths"]; + if (value.hasType>()) { + // The color object mixes an array (`resource_paths`) with an optional string + // (`fallback`), so it must be read as a map of RawValue rather than a map of + // vector (which would assert on the string value). + auto map = (std::unordered_map)value; - // JNI calls are time consuming. Let's cache results here to avoid - // unnecessary calls. - static std::mutex getColorCacheMutex; - static folly::EvictingCacheMap getColorCache(64); + std::vector resourcePaths; + auto resourcePathsIt = map.find("resource_paths"); + if (resourcePathsIt != map.end() && resourcePathsIt->second.hasType>()) { + resourcePaths = (std::vector)resourcePathsIt->second; + } + + bool resolved = false; + if (!resourcePaths.empty()) { + // JNI calls are time consuming. Let's cache results here to avoid + // unnecessary calls. A cached std::nullopt records an explicit miss (no + // resource path resolved), which is distinct from a path that resolves to a + // transparent color (ARGB 0) — comparing the color value to 0 would conflate + // the two. + static std::mutex getColorCacheMutex; + static folly::EvictingCacheMap> getColorCache(64); - // Listen for appearance changes, which should invalidate the cache - static std::once_flag setupCacheInvalidation; - std::call_once(setupCacheInvalidation, configurePlatformColorCacheInvalidationHook, [&] { - std::scoped_lock lock(getColorCacheMutex); - getColorCache.clear(); - }); + // Listen for appearance changes, which should invalidate the cache + static std::once_flag setupCacheInvalidation; + std::call_once(setupCacheInvalidation, configurePlatformColorCacheInvalidationHook, [&] { + std::scoped_lock lock(getColorCacheMutex); + getColorCache.clear(); + }); - auto hash = hashGetColourArguments(surfaceId, resourcePaths); - { - std::scoped_lock lock(getColorCacheMutex); - auto iterator = getColorCache.find(hash); - if (iterator != getColorCache.end()) { - color = iterator->second; - } else { - const auto &fabricUIManager = contextContainer.at>("FabricUIManager"); - static auto getColorFromJava = - fabricUIManager->getClass()->getMethod)>("getColor"); - auto javaResourcePaths = jni::JArrayClass::newArray(resourcePaths.size()); + auto hash = hashGetColourArguments(surfaceId, resourcePaths); + std::optional resolvedColor; + { + std::scoped_lock lock(getColorCacheMutex); + auto iterator = getColorCache.find(hash); + if (iterator != getColorCache.end()) { + resolvedColor = iterator->second; + } else { + const auto &fabricUIManager = contextContainer.at>("FabricUIManager"); + // getColor returns a boxed Integer: null means no resource path resolved + // (an explicit miss). A non-null value is a resolved color, which may + // legitimately be 0 (transparent black). + static auto getColorFromJava = + fabricUIManager->getClass()->getMethod)>( + "getColor"); + auto javaResourcePaths = jni::JArrayClass::newArray(resourcePaths.size()); + + for (int i = 0; i < resourcePaths.size(); i++) { + javaResourcePaths->setElement(i, *jni::make_jstring(resourcePaths[i])); + } + auto boxedColor = getColorFromJava(fabricUIManager, surfaceId, *javaResourcePaths); + if (boxedColor) { + resolvedColor = static_cast(boxedColor->value()); + } + getColorCache.set(hash, resolvedColor); + } + } + if (resolvedColor.has_value()) { + color = *resolvedColor; + resolved = true; + } + } - for (int i = 0; i < resourcePaths.size(); i++) { - javaResourcePaths->setElement(i, *jni::make_jstring(resourcePaths[i])); + // Every resource path missed (Java returns 0): parse the raw fallback string + // with the shared CSS color parser (the same parser iOS Fabric uses). + if (!resolved) { + auto fallbackIt = map.find("fallback"); + if (fallbackIt != map.end() && fallbackIt->second.hasType()) { + auto cssColor = parseCSSProperty((std::string)fallbackIt->second); + if (std::holds_alternative(cssColor)) { + const auto &c = std::get(cssColor); + color = hostPlatformColorFromRGBA(c.r, c.g, c.b, c.a); } - color = getColorFromJava(fabricUIManager, surfaceId, *javaResourcePaths); - getColorCache.set(hash, color); } } } diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index de50393d9773..9068b2276438 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -7561,6 +7561,7 @@ struct facebook::react::NativeDrawable::Ripple { public std::optional alpha; public std::optional rippleRadius; public std::optional color; + public std::optional colorFallback; public std::optional> colorResourcePaths; } diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index 1199e9b6da16..b0aa33e46f33 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -7322,6 +7322,7 @@ struct facebook::react::NativeDrawable::Ripple { public std::optional alpha; public std::optional rippleRadius; public std::optional color; + public std::optional colorFallback; public std::optional> colorResourcePaths; } diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index bf9aabd23070..5e1e274fd4c1 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -7552,6 +7552,7 @@ struct facebook::react::NativeDrawable::Ripple { public std::optional alpha; public std::optional rippleRadius; public std::optional color; + public std::optional colorFallback; public std::optional> colorResourcePaths; }