From 48ee66040606d3e20619ecfa11e79f5c6299a352 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Tue, 14 Jul 2026 20:34:27 -0700 Subject: [PATCH] Support a lazy raw color fallback for PlatformColor Summary: An implementation for the RFC in https://github.com/react-native-community/discussions-and-proposals/pull/1008 PlatformColor previously had no way to specify what color to use when none of the supplied native color tokens resolve on the device. On a miss the behavior was inconsistent across platforms and architectures (transparent, nil, or a thrown error), giving apps no control over the rendered result. This adds an optional trailing `{fallback: ''}` argument to `PlatformColor(...)`. The fallback is carried lazily to the native layer as a raw, unprocessed string and is only parsed when every provided token fails to resolve. When at least one token resolves the fallback is ignored, so existing call sites are completely unaffected and omitting the argument preserves today's exact behavior. Example: PlatformColor('someSystemToken', {fallback: '#FF0000'}) The change spans the JS entry points (all platform `PlatformColorValueTypes.*` files plus the Flow and TypeScript type declarations), the iOS (Paper and Fabric) and Android (Paper and Fabric) native color resolvers, a new "Lazy Fallback Colors" section in the RNTester PlatformColor example, and the regenerated C++ API snapshots. Changelog: [General][Added] - Support a lazy raw color fallback for PlatformColor when native tokens fail to resolve Differential Revision: D111837102 --- .../__tests__/platform-colors-test.js | 10 + .../platform-colors.js | 22 +- .../PlatformColorValueTypes.android.js | 25 ++- .../StyleSheet/PlatformColorValueTypes.d.ts | 4 +- .../StyleSheet/PlatformColorValueTypes.ios.js | 25 ++- .../PlatformColorValueTypes.js.flow | 2 +- .../__tests__/processColor-itest.js | 22 ++ .../react-native/React/Base/RCTConvert.mm | 72 +++++++ .../react/bridge/ColorPropConverter.kt | 134 ++++++++++++ .../components/view/HostPlatformViewProps.cpp | 4 + .../renderer/components/view/NativeDrawable.h | 33 ++- .../renderer/graphics/PlatformColorParser.h | 191 +++++++++++++++--- .../renderer/graphics/PlatformColorParser.mm | 30 ++- packages/react-native/ReactNativeApi.d.ts | 13 +- .../PlatformColor/PlatformColorExample.js | 166 +++++++++++++++ .../api-snapshots/ReactAndroidDebugCxx.api | 2 + .../api-snapshots/ReactAndroidNewarchCxx.api | 2 + .../api-snapshots/ReactAndroidReleaseCxx.api | 2 + 18 files changed, 710 insertions(+), 49 deletions(-) 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; }