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/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/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) 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; }