diff --git a/packages/eslint-plugin-react-native/__tests__/platform-colors-test.js b/packages/eslint-plugin-react-native/__tests__/platform-colors-test.js index ea27e59e0b49..ea5f66acdafd 100644 --- a/packages/eslint-plugin-react-native/__tests__/platform-colors-test.js +++ b/packages/eslint-plugin-react-native/__tests__/platform-colors-test.js @@ -19,6 +19,8 @@ eslintTester.run('../platform-colors', rule, { valid: [ "const color = PlatformColor('labelColor');", "const color = PlatformColor('controlAccentColor', 'controlColor');", + "const color = PlatformColor('labelColor', {fallback: '#FF0000'});", + "const color = PlatformColor('controlAccentColor', 'controlColor', {fallback: 'red'});", "const color = DynamicColorIOS({light: 'black', dark: 'white'});", "const color = DynamicColorIOS({light: PlatformColor('black'), dark: PlatformColor('white')});", "const color = DynamicColorIOS({light: PlatformColor('black'), dark: PlatformColor('white'), highContrastLight: PlatformColor('black'), highContrastDark: PlatformColor('white')});", @@ -32,6 +34,14 @@ eslintTester.run('../platform-colors', rule, { code: "const labelColor = 'labelColor'; const color = PlatformColor(labelColor);", errors: [{message: rule.meta.messages.platformColorArgTypes}], }, + { + code: "const raw = '#FF0000'; const color = PlatformColor('labelColor', {fallback: raw});", + errors: [{message: rule.meta.messages.platformColorArgTypes}], + }, + { + code: "const color = PlatformColor({fallback: '#FF0000'}, 'labelColor');", + errors: [{message: rule.meta.messages.platformColorArgTypes}], + }, { code: "const tuple = {light: 'black', dark: 'white'}; const color = DynamicColorIOS(tuple);", errors: [{message: rule.meta.messages.dynamicColorIOSArg}], diff --git a/packages/eslint-plugin-react-native/platform-colors.js b/packages/eslint-plugin-react-native/platform-colors.js index 2de452899cd6..daf4dac4b6c4 100644 --- a/packages/eslint-plugin-react-native/platform-colors.js +++ b/packages/eslint-plugin-react-native/platform-colors.js @@ -33,6 +33,20 @@ module.exports = { CallExpression: function (node) { if (node.callee.name === 'PlatformColor') { const args = node.arguments; + // The optional trailing options object carries a lazy raw color + // fallback: PlatformColor('token', {fallback: '#RRGGBB'}). Its + // fallback value must itself be a literal so it stays statically + // analyzable. + const isFallbackObject = arg => + arg.type === 'ObjectExpression' && + arg.properties.length > 0 && + arg.properties.every( + property => + property.type === 'Property' && + property.key.type === 'Identifier' && + property.key.name === 'fallback' && + property.value.type === 'Literal', + ); if (args.length === 0) { context.report({ node, @@ -40,7 +54,13 @@ module.exports = { }); return; } - if (!args.every(arg => arg.type === 'Literal')) { + if ( + !args.every( + (arg, index) => + arg.type === 'Literal' || + (index === args.length - 1 && isFallbackObject(arg)), + ) + ) { context.report({ node, messageId: 'platformColorArgTypes', diff --git a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.android.js b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.android.js index 2647776e3549..1b77aba25cbf 100644 --- a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.android.js +++ b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.android.js @@ -14,12 +14,33 @@ import type {NativeColorValue} from './StyleSheet'; /** The actual type of the opaque NativeColorValue on Android platform */ type LocalNativeColorValue = { resource_paths?: Array, + fallback?: string, }; -export const PlatformColor = (...names: Array): NativeColorValue => { +export const PlatformColor = ( + ...args: Array +): NativeColorValue => { + const names: Array = []; + let fallback: ?string; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (typeof arg === 'string') { + names.push(arg); + } else if (i === args.length - 1) { + // The {fallback} object is only honored as the trailing argument, matching + // the ESLint rule. It is a raw color string, intentionally not processed + // here; it crosses to native untouched and is only parsed on a token miss. + fallback = arg.fallback; + } + // A non-string before the final position is ignored (a lint error at authoring). + } + const color: LocalNativeColorValue = {resource_paths: names}; + if (fallback != null) { + color.fallback = fallback; + } /* $FlowExpectedError[incompatible-type] * LocalNativeColorValue is the actual type of the opaque NativeColorValue on Android platform */ - return {resource_paths: names} as LocalNativeColorValue; + return color as LocalNativeColorValue; }; export const normalizeColorObject = ( diff --git a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.d.ts b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.d.ts index 909f73d596e7..02be4f88770a 100644 --- a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.d.ts +++ b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.d.ts @@ -15,4 +15,6 @@ import {OpaqueColorValue} from './StyleSheet'; * * @see https://reactnative.dev/docs/platformcolor#example */ -export function PlatformColor(...colors: string[]): OpaqueColorValue; +export function PlatformColor( + ...colors: Array +): OpaqueColorValue; diff --git a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.ios.js b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.ios.js index 50af1b60828c..fef23b555ffd 100644 --- a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.ios.js +++ b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.ios.js @@ -14,6 +14,7 @@ import type {ColorValue, NativeColorValue} from './StyleSheet'; /** The actual type of the opaque NativeColorValue on iOS platform */ type LocalNativeColorValue = { semantic?: Array, + fallback?: string, dynamic?: { light: ?(ColorValue | ProcessedColorValue), dark: ?(ColorValue | ProcessedColorValue), @@ -22,9 +23,29 @@ type LocalNativeColorValue = { }, }; -export const PlatformColor = (...names: Array): NativeColorValue => { +export const PlatformColor = ( + ...args: Array +): NativeColorValue => { + const names: Array = []; + let fallback: ?string; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (typeof arg === 'string') { + names.push(arg); + } else if (i === args.length - 1) { + // The {fallback} object is only honored as the trailing argument, matching + // the ESLint rule. It is a raw color string, intentionally not processed + // here; it crosses to native untouched and is only parsed on a token miss. + fallback = arg.fallback; + } + // A non-string before the final position is ignored (a lint error at authoring). + } + const color: LocalNativeColorValue = {semantic: names}; + if (fallback != null) { + color.fallback = fallback; + } // $FlowExpectedError[incompatible-type] LocalNativeColorValue is the iOS LocalNativeColorValue type - return {semantic: names} as LocalNativeColorValue; + return color as LocalNativeColorValue; }; export type DynamicColorIOSTuplePrivate = { diff --git a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.js.flow b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.js.flow index e76df70da962..94c595e6a8b7 100644 --- a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.js.flow +++ b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.js.flow @@ -18,7 +18,7 @@ import type {NativeColorValue} from './StyleSheet'; * @see https://reactnative.dev/docs/platformcolor#example */ declare export function PlatformColor( - ...names: Array + ...names: Array ): NativeColorValue; declare export function normalizeColorObject( diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processColor-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processColor-itest.js index 5084f8787785..1b62de6b96e8 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/processColor-itest.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processColor-itest.js @@ -109,6 +109,16 @@ describe('processColor', () => { const expectedColor = {dynamic: {light: 0xff000000, dark: 0xffffffff}}; expect(processedColor).toEqual(expectedColor); }); + + it('should carry an unprocessed fallback on iOS PlatformColor colors', () => { + const color = PlatformColorIOS('systemRedColor', {fallback: '#ff0000'}); + const processedColor = processColor(color); + const expectedColor = { + semantic: ['systemRedColor'], + fallback: '#ff0000', + }; + expect(processedColor).toEqual(expectedColor); + }); } }); @@ -120,6 +130,18 @@ describe('processColor', () => { const expectedColor = {resource_paths: ['?attr/colorPrimary']}; expect(processedColor).toEqual(expectedColor); }); + + it('should carry an unprocessed fallback on Android PlatformColor colors', () => { + const color = PlatformColorAndroid('?attr/colorPrimary', { + fallback: '#000000', + }); + const processedColor = processColor(color); + const expectedColor = { + resource_paths: ['?attr/colorPrimary'], + fallback: '#000000', + }; + expect(processedColor).toEqual(expectedColor); + }); } }); }); diff --git a/packages/react-native/React/Base/RCTConvert.mm b/packages/react-native/React/Base/RCTConvert.mm index 9b7dd699ce6b..88d163f4a46d 100644 --- a/packages/react-native/React/Base/RCTConvert.mm +++ b/packages/react-native/React/Base/RCTConvert.mm @@ -944,6 +944,70 @@ + (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 New +// Architecture the fallback additionally supports rgb()/rgba()/named colors via +// the shared CSS color parser; 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; + } + NSCharacterSet *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; + CGFloat g; + CGFloat b; + CGFloat a; + 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 +1047,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 +1067,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/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..9fa6b37eab29 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 @@ -15,14 +15,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 +74,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 +141,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." ) @@ -231,4 +261,108 @@ 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? { + val color = raw.trim() + if (color.isEmpty()) { + return null + } + return try { + 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 handled natively. + else -> color.toColorInt() + } + } catch (e: IllegalArgumentException) { + FLog.w(ReactConstants.TAG, "ColorValue: Unable to parse fallback color \"$raw\".") + null + } + } + + private fun parseHexColor(color: String): Int? { + val hex = color.substring(1) + if (hex.isEmpty() || hex.any { Character.digit(it, 16) < 0 }) { + return null + } + return when (hex.length) { + // #RGB — expand each nibble (0xN -> 0xNN via * 17). + 3 -> + Color.argb( + 255, + Character.digit(hex[0], 16) * 17, + Character.digit(hex[1], 16) * 17, + Character.digit(hex[2], 16) * 17, + ) + // #RGBA — expand each nibble; alpha is the last nibble. + 4 -> + Color.argb( + Character.digit(hex[3], 16) * 17, + Character.digit(hex[0], 16) * 17, + Character.digit(hex[1], 16) * 17, + Character.digit(hex[2], 16) * 17, + ) + 6 -> + Color.argb( + 255, + hex.substring(0, 2).toInt(16), + hex.substring(2, 4).toInt(16), + hex.substring(4, 6).toInt(16), + ) + // #RRGGBBAA — alpha is the LAST byte. + 8 -> + Color.argb( + hex.substring(6, 8).toInt(16), + hex.substring(0, 2).toInt(16), + hex.substring(2, 4).toInt(16), + hex.substring(4, 6).toInt(16), + ) + 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(") + 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/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..c53bdc8ba352 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 @@ -15,8 +15,12 @@ #include #include #include +#include +#include +#include #include #include +#include #include #include #include @@ -32,43 +36,168 @@ inline size_t hashGetColourArguments(int32_t surfaceId, const std::vector parsePlatformColorFallbackString(const std::string &raw) +{ + auto isSpace = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }; + size_t start = 0; + size_t end = raw.size(); + while (start < end && isSpace(raw[start])) { + start++; + } + while (end > start && isSpace(raw[end - 1])) { + end--; + } + std::string s = raw.substr(start, end - start); + if (s.empty()) { + return std::nullopt; + } + + if (s[0] == '#') { + auto hexVal = [](char c) -> int { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return -1; + }; + std::string hex = s.substr(1); + for (char c : hex) { + if (hexVal(c) < 0) { + return std::nullopt; + } + } + uint8_t r = 0; + uint8_t g = 0; + uint8_t b = 0; + uint8_t a = 0xFF; + if (hex.size() == 3) { + r = static_cast(hexVal(hex[0]) * 17); + g = static_cast(hexVal(hex[1]) * 17); + b = static_cast(hexVal(hex[2]) * 17); + } else if (hex.size() == 6) { + r = static_cast(hexVal(hex[0]) * 16 + hexVal(hex[1])); + g = static_cast(hexVal(hex[2]) * 16 + hexVal(hex[3])); + b = static_cast(hexVal(hex[4]) * 16 + hexVal(hex[5])); + } else if (hex.size() == 8) { + // #RRGGBBAA — alpha is the LAST byte (CSS / iOS convention), NOT the first + // as in Android's native #AARRGGBB. + r = static_cast(hexVal(hex[0]) * 16 + hexVal(hex[1])); + g = static_cast(hexVal(hex[2]) * 16 + hexVal(hex[3])); + b = static_cast(hexVal(hex[4]) * 16 + hexVal(hex[5])); + a = static_cast(hexVal(hex[6]) * 16 + hexVal(hex[7])); + } else { + return std::nullopt; + } + return hostPlatformColorFromRGBA(r, g, b, a); + } + + if (s.rfind("rgb(", 0) == 0 || s.rfind("rgba(", 0) == 0) { + auto open = s.find('('); + auto close = s.find(')'); + if (open == std::string::npos || close == std::string::npos || close <= open) { + return std::nullopt; + } + std::string inner = s.substr(open + 1, close - open - 1); + std::vector parts; + std::string cur; + for (char c : inner) { + if (c == ',') { + parts.push_back(cur); + cur.clear(); + } else { + cur.push_back(c); + } + } + parts.push_back(cur); + if (parts.size() < 3) { + return std::nullopt; + } + auto clamp = [](int v) -> uint8_t { return static_cast(std::max(0, std::min(255, v))); }; + try { + int r = std::stoi(parts[0]); + int g = std::stoi(parts[1]); + int b = std::stoi(parts[2]); + int a = 255; + if (parts.size() >= 4) { + a = static_cast(std::lround(std::stof(parts[3]) * 255.0f)); + } + return hostPlatformColorFromRGBA(clamp(r), clamp(g), clamp(b), clamp(a)); + } catch (const std::exception &) { + return std::nullopt; + } + } + + return std::nullopt; +} + 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"]; - - // JNI calls are time consuming. Let's cache results here to avoid - // unnecessary calls. - 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(); - }); - - 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()); + 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; + + 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. + 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(); + }); + + 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()); + + for (int i = 0; i < resourcePaths.size(); i++) { + javaResourcePaths->setElement(i, *jni::make_jstring(resourcePaths[i])); + } + color = getColorFromJava(fabricUIManager, surfaceId, *javaResourcePaths); + getColorCache.set(hash, color); + } + } + resolved = (color != 0); + } - 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. + if (!resolved) { + auto fallbackIt = map.find("fallback"); + if (fallbackIt != map.end() && fallbackIt->second.hasType()) { + auto parsed = parsePlatformColorFallbackString((std::string)fallbackIt->second); + if (parsed.has_value()) { + color = *parsed; } - color = getColorFromJava(fabricUIManager, surfaceId, *javaResourcePaths); - getColorCache.set(hash, color); } } } 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..97156fad38c6 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,6 +8,9 @@ #import "PlatformColorParser.h" #import +#import +#import +#import #import #import #import @@ -51,13 +54,38 @@ return SharedColor(color); } +// Parses a raw color string (e.g. "#f00", "#ff0000", "#ff000080", "rgba(...)") +// using the shared CSS color parser. Returns clearColor() when the string is not +// a parseable color. +static SharedColor 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 clearColor(); +} + 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)); + // When every semantic name fails to resolve, createSemanticColor returns a + // transparent (clear) color. If the color object carries a raw-string + // fallback, parse it and use it instead so the miss renders the author's + // intended color rather than transparent. + if ((*semanticColor).getColor() == 0 && items.find("fallback") != items.end() && + items.at("fallback").hasType()) { + auto fallbackColor = fallbackColorFromString((std::string)items.at("fallback")); + if ((*fallbackColor).getColor() != 0) { + return fallbackColor; + } + } + return semanticColor; } else if ( items.find("dynamic") != items.end() && items.at("dynamic").hasType>()) { diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index fdf2d8b0e554..689d054e5b2a 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<3c6e653165b5e8314aba1038abcadea2>> + * @generated SignedSource<<51f051374fd2f4d333e0e14590e5b933>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -3496,7 +3496,14 @@ declare class PixelRatio { static startDetecting(): void } declare type Platform = typeof Platform -declare function PlatformColor(...names: Array): NativeColorValue +declare function PlatformColor( + ...names: Array< + | string + | { + fallback: string + } + > +): NativeColorValue declare type PlatformConfig = {} declare type PlatformOSType = "android" | "ios" | "macos" | "native" | "web" | "windows" @@ -5871,7 +5878,7 @@ export { PermissionsAndroid, // 8a0bc8d8 PixelRatio, // 10d9e32d Platform, // b73caa89 - PlatformColor, // 8297ec62 + PlatformColor, // d083d341 PlatformOSType, // 0a17561e PlatformSelectSpec, // 09ed7758 PointValue, // 69db075f diff --git a/packages/rn-tester/js/examples/PlatformColor/PlatformColorExample.js b/packages/rn-tester/js/examples/PlatformColor/PlatformColorExample.js index bdebddb14c4f..6e6606fee575 100644 --- a/packages/rn-tester/js/examples/PlatformColor/PlatformColorExample.js +++ b/packages/rn-tester/js/examples/PlatformColor/PlatformColorExample.js @@ -236,6 +236,159 @@ function FallbackColorsExample() { ); } +function LazyFallbackColorsExample() { + // A token that resolves to a real system color on each platform. + const validToken = Platform.select({ + ios: 'systemBlue', + android: '?attr/colorAccent', + default: 'systemBlue', + }); + // A token that intentionally does not resolve on any platform, so the lazy + // raw-string fallback is what actually gets rendered. + const invalidToken = Platform.select({ + ios: 'nonExistentSystemColor', + android: '?attr/nonExistentColor', + default: 'nonExistentToken', + }); + + return ( + + + + Valid token '{validToken}' (shows the system color) + + + + + + Invalid token, NO fallback (miss → transparent, outlined below) + + + + + + Invalid token + fallback '#FF0000' → RED (backgroundColor) + + + + + + Invalid token + fallback '#FFFF00' → YELLOW (backgroundColor) + + + + + + Invalid token + fallback '#00FF00' → GREEN (text color) + + + + GREEN + + + + + + Invalid token + fallback '#0000FF' → BLUE (borderColor) + + + + + The fallback is parsed by each platform's native color parser, so + support varies. Hex (#RGB / #RRGGBB / #RRGGBBAA) resolves on every + platform. rgb() and rgba() resolve on the New Architecture and on legacy + Android, but not on legacy iOS (hex only). hsl() / hsla() and named + colors are not shown here because their support is not yet consistent + across all platforms. + + + + fallback 'rgb(255, 0, 128)' → PINK (backgroundColor) + + + + + + fallback 'rgba(0, 128, 255, 0.7)' → semi-transparent BLUE + + + + {/* + hsl()/hsla() and named-color fallbacks (e.g. 'cornflowerblue') are + intentionally not demoed here: today they resolve only on the iOS New + Architecture, which parses the fallback with the shared CSS color parser. + A follow-up change migrates the Android New Architecture parser to that + same shared parser, after which these forms resolve there too. They are + omitted to keep this example honest across all current platforms and + architectures. + */} + + + fallback '#FF000080' (#RRGGBBAA) → 50% transparent RED + + + + + ); +} + function DynamicColorsExample() { return Platform.OS === 'ios' ? ( @@ -372,6 +525,13 @@ const styles = StyleSheet.create({ }, colorCell: {flex: 0.25, alignItems: 'stretch'}, separator: {height: 8}, + note: { + fontStyle: 'italic', + paddingVertical: 8, + ...Platform.select({ + ios: {color: PlatformColor('secondaryLabel')}, + }), + }, }); exports.title = 'PlatformColor'; @@ -392,6 +552,12 @@ exports.examples = [ return ; }, }, + { + title: 'Lazy Fallback Colors', + render(): React.MixedElement { + return ; + }, + }, { title: 'iOS Dynamic Colors', render(): React.MixedElement { diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index 0aaf2efc5caa..69bfbc13ed31 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -942,6 +942,7 @@ std::function facebook::react::makeCallback(std::weak_ptr< std::optional facebook::react::blendModeFromString(std::string_view blendModeName); std::optional facebook::react::fromCSSShadow(const facebook::react::CSSShadow& cssShadow); std::optional facebook::react::parseBoxShadowRawValue(const facebook::react::PropsParserContext& context, const facebook::react::RawValue& value); +std::optional facebook::react::parsePlatformColorFallbackString(const std::string& raw); std::optional facebook::react::fromCSSFilter(const facebook::react::CSSFilterFunction& cssFilter); std::optional facebook::react::parseDropShadow(const facebook::react::PropsParserContext& context, const facebook::react::RawValue& value); std::optional facebook::react::parseFilterRawValue(const facebook::react::PropsParserContext& context, const facebook::react::RawValue& value); @@ -7555,6 +7556,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 be2958293dad..0120cf8beb09 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -940,6 +940,7 @@ size_t facebook::react::textAttributesHashLayoutWise(const facebook::react::Text std::optional facebook::react::blendModeFromString(std::string_view blendModeName); std::optional facebook::react::fromCSSShadow(const facebook::react::CSSShadow& cssShadow); std::optional facebook::react::parseBoxShadowRawValue(const facebook::react::PropsParserContext& context, const facebook::react::RawValue& value); +std::optional facebook::react::parsePlatformColorFallbackString(const std::string& raw); std::optional facebook::react::fromCSSFilter(const facebook::react::CSSFilterFunction& cssFilter); std::optional facebook::react::parseDropShadow(const facebook::react::PropsParserContext& context, const facebook::react::RawValue& value); std::optional facebook::react::parseFilterRawValue(const facebook::react::PropsParserContext& context, const facebook::react::RawValue& value); @@ -7316,6 +7317,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 faf7b0b6db37..608bbe488abb 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -942,6 +942,7 @@ std::function facebook::react::makeCallback(std::weak_ptr< std::optional facebook::react::blendModeFromString(std::string_view blendModeName); std::optional facebook::react::fromCSSShadow(const facebook::react::CSSShadow& cssShadow); std::optional facebook::react::parseBoxShadowRawValue(const facebook::react::PropsParserContext& context, const facebook::react::RawValue& value); +std::optional facebook::react::parsePlatformColorFallbackString(const std::string& raw); std::optional facebook::react::fromCSSFilter(const facebook::react::CSSFilterFunction& cssFilter); std::optional facebook::react::parseDropShadow(const facebook::react::PropsParserContext& context, const facebook::react::RawValue& value); std::optional facebook::react::parseFilterRawValue(const facebook::react::PropsParserContext& context, const facebook::react::RawValue& value); @@ -7546,6 +7547,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; }