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..cbdc666c936b 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,30 @@ 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 color = PlatformColor('labelColor', {fallback: '#FF0000', fallback: '#00FF00'});", + errors: [{message: rule.meta.messages.platformColorArgTypes}], + }, + { + code: "const color = PlatformColor('labelColor', {fallback: '#FF0000', extra: 'red'});", + errors: [{message: rule.meta.messages.platformColorArgTypes}], + }, + { + code: "const color = PlatformColor('labelColor', {['fallback']: '#FF0000'});", + errors: [{message: rule.meta.messages.platformColorArgTypes}], + }, + { + code: "const color = PlatformColor({fallback: '#FF0000'});", + 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..597d2045d199 100644 --- a/packages/eslint-plugin-react-native/platform-colors.js +++ b/packages/eslint-plugin-react-native/platform-colors.js @@ -33,6 +33,21 @@ module.exports = { CallExpression: function (node) { if (node.callee.name === 'PlatformColor') { const args = node.arguments; + // Optional trailing {fallback: }: exactly one `fallback` + // property with a literal value, so it stays statically analyzable. + const isFallbackObject = arg => + arg.type === 'ObjectExpression' && + arg.properties.length === 1 && + arg.properties.every( + property => + property.type === 'Property' && + // Reject computed keys (e.g. {['fallback']: ...}); only a plain + // identifier key keeps the object statically analyzable. + property.computed === false && + property.key.type === 'Identifier' && + property.key.name === 'fallback' && + property.value.type === 'Literal', + ); if (args.length === 0) { context.report({ node, @@ -40,7 +55,17 @@ module.exports = { }); return; } - if (!args.every(arg => arg.type === 'Literal')) { + if ( + !args.every( + (arg, index) => + arg.type === 'Literal' || + // The trailing {fallback: ...} object is only allowed after at + // least one literal color-token argument (index > 0). + (index > 0 && + 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..08a3b86bb089 100644 --- a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.android.js +++ b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.android.js @@ -11,15 +11,27 @@ import type {ProcessedColorValue} from './processColor'; import type {NativeColorValue} from './StyleSheet'; +import parsePlatformColorArgs from './parsePlatformColorArgs'; + /** 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, fallback} = parsePlatformColorArgs(args); + // Raw fallback (when present) is passed to native untouched and only parsed + // on a token miss. + const color: LocalNativeColorValue = + fallback == null + ? {resource_paths: names} + : {resource_paths: names, 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..c008435227ad 100644 --- a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.ios.js +++ b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.ios.js @@ -11,9 +11,12 @@ import type {ProcessedColorValue} from './processColor'; import type {ColorValue, NativeColorValue} from './StyleSheet'; +import parsePlatformColorArgs from './parsePlatformColorArgs'; + /** 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 +25,16 @@ type LocalNativeColorValue = { }, }; -export const PlatformColor = (...names: Array): NativeColorValue => { +export const PlatformColor = ( + ...args: Array +): NativeColorValue => { + const {names, fallback} = parsePlatformColorArgs(args); + // Raw fallback (when present) is passed to native untouched and only parsed + // on a token miss. + const color: LocalNativeColorValue = + fallback == null ? {semantic: names} : {semantic: names, 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__/PlatformColorFallback-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/PlatformColorFallback-itest.js new file mode 100644 index 000000000000..fbd60b8dcd43 --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/__tests__/PlatformColorFallback-itest.js @@ -0,0 +1,130 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {ColorValue} from 'react-native'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {PlatformColor, View} from 'react-native'; + +const processColor = require('../processColor').default; + +// Fantom runs as `Platform.OS === 'android'` with no host resource system, so +// PlatformColor tokens never resolve and the fallback is carried through, not +// applied (the real miss -> fallback visual is covered by RNTester screenshots). +// These tests verify each fallback format renders to the expected color and that +// the raw fallback string survives the color pipeline into the view props. + +function renderedBackgroundColor(color: ColorValue): unknown { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + return root.getRenderedOutput({props: ['backgroundColor']}).toJSX(); +} + +describe('PlatformColor lazy fallback', () => { + // These render the raw color strings directly (not through PlatformColor): + // Fantom carries the fallback through instead of applying it, so this + // documents that each format accepted as a fallback is a valid color. + describe('color-format strings accepted as fallbacks render to the expected color', () => { + // Opaque formats only: alpha serialization is exercised via the pipeline + // assertions below, keeping these rendered-output checks deterministic. + const cases: Array<[string, string, string]> = [ + ['#RRGGBB hex', '#FF0000', 'rgba(255, 0, 0, 1)'], + ['rgb()', 'rgb(255, 0, 128)', 'rgba(255, 0, 128, 1)'], + ['hsl()', 'hsl(120, 100%, 50%)', 'rgba(0, 255, 0, 1)'], + ['named color', 'cornflowerblue', 'rgba(100, 149, 237, 1)'], + ]; + for (const [name, input, expected] of cases) { + it(`renders ${name}`, () => { + expect(renderedBackgroundColor(input)).toEqual( + , + ); + }); + } + }); + + // The `PlatformColor()` arguments must be literals (enforced by the + // @react-native/platform-colors lint rule), so the calls cannot be built in a + // loop; only the shared assertion is factored out. + describe('PlatformColor carries the raw, unprocessed fallback', () => { + function expectFallbackCarried(color: ColorValue, fallback: string) { + expect(processColor(color)).toEqual({ + resource_paths: ['?attr/nonExistentColor'], + fallback, + }); + } + + it('carries a #RRGGBB fallback', () => { + expectFallbackCarried( + PlatformColor('?attr/nonExistentColor', {fallback: '#FF0000'}), + '#FF0000', + ); + }); + + it('carries a #RRGGBBAA fallback', () => { + expectFallbackCarried( + PlatformColor('?attr/nonExistentColor', {fallback: '#FF000080'}), + '#FF000080', + ); + }); + + it('carries an rgb() fallback', () => { + expectFallbackCarried( + PlatformColor('?attr/nonExistentColor', {fallback: 'rgb(255, 0, 128)'}), + 'rgb(255, 0, 128)', + ); + }); + + it('carries an rgba() fallback', () => { + expectFallbackCarried( + PlatformColor('?attr/nonExistentColor', { + fallback: 'rgba(0, 128, 255, 0.7)', + }), + 'rgba(0, 128, 255, 0.7)', + ); + }); + + it('carries an hsl() fallback', () => { + expectFallbackCarried( + PlatformColor('?attr/nonExistentColor', { + fallback: 'hsl(120, 100%, 50%)', + }), + 'hsl(120, 100%, 50%)', + ); + }); + + it('carries an hsla() fallback', () => { + expectFallbackCarried( + PlatformColor('?attr/nonExistentColor', { + fallback: 'hsla(280, 100%, 60%, 0.8)', + }), + 'hsla(280, 100%, 60%, 0.8)', + ); + }); + + it('carries a named-color fallback', () => { + expectFallbackCarried( + PlatformColor('?attr/nonExistentColor', {fallback: 'cornflowerblue'}), + 'cornflowerblue', + ); + }); + }); + + it('omits the fallback field when none is provided (miss stays transparent)', () => { + expect(processColor(PlatformColor('?attr/nonExistentColor'))).toEqual({ + resource_paths: ['?attr/nonExistentColor'], + }); + }); +}); diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processColor-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processColor-itest.js index 5084f8787785..2054ef112a21 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/processColor-itest.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processColor-itest.js @@ -109,6 +109,21 @@ describe('processColor', () => { const expectedColor = {dynamic: {light: 0xff000000, dark: 0xffffffff}}; expect(processedColor).toEqual(expectedColor); }); + + // The macOS and Windows PlatformColor entry points carry the fallback with + // the same trailing-{fallback} detection as iOS and Android. This + // integration test harness only executes as iOS and Android, so that + // shared behavior is exercised by the iOS and Android cases here rather + // than duplicated for platforms the harness cannot run. + 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 +135,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/Libraries/StyleSheet/parsePlatformColorArgs.js b/packages/react-native/Libraries/StyleSheet/parsePlatformColorArgs.js new file mode 100644 index 000000000000..5dcfb6d754d4 --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/parsePlatformColorArgs.js @@ -0,0 +1,55 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +/** + * Splits the variadic `PlatformColor(...)` arguments into the leading color + * token names and the optional trailing `{fallback}` options object. The + * per-platform `PlatformColor` implementations differ only in the native object + * they build from this result, so the argument parsing is shared here. + */ +export default function parsePlatformColorArgs( + args: Array, +): {names: Array, fallback: ?string} { + const lastArg = args[args.length - 1]; + if (__DEV__) { + args.forEach((arg, index) => { + if (typeof arg !== 'object' || arg == null) { + return; + } + if (index !== args.length - 1) { + console.error( + 'PlatformColor: an options object is only honored as the final argument; one in any other position is ignored.', + ); + } else if (typeof arg.fallback !== 'string') { + console.error( + 'PlatformColor: the trailing options object must be of the form {fallback: string}; it is ignored.', + ); + } + }); + } + // The {fallback} options object is only honored as the trailing argument. + const fallback = + lastArg != null && + typeof lastArg === 'object' && + typeof lastArg.fallback === 'string' + ? lastArg.fallback + : null; + // Collect the leading string tokens; a non-string non-trailing arg (a lint + // error) is dropped. + const names: Array = []; + const nameCount = fallback == null ? args.length : args.length - 1; + for (let i = 0; i < nameCount; i++) { + const arg = args[i]; + if (typeof arg === 'string') { + names.push(arg); + } + } + return {names, fallback}; +} 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/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index d96a0fa1e59c..b97540334904 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,9 @@ public int getColor(int surfaceId, String[] resourcePaths) { return color; } } - return 0; + // Null (explicit miss), not 0, so native can tell 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/css/tests/CSSColorTest.cpp b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSColorTest.cpp index c5d30aec120e..c6fa8befdf9f 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/tests/CSSColorTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSColorTest.cpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace facebook::react { @@ -492,4 +493,38 @@ TEST(CSSColor, constexpr_values) { parseCSSProperty("rgb(255, 255, 255)"); } +// The PlatformColor fallback is a raw CSS parsed by this same parser on +// a token miss. Pins the promised fallback formats to their RGBA, and checks +// that unparseable input yields std::monostate so native degrades to +// transparent. +TEST(CSSColor, platform_color_fallback_contract) { + auto expectColor = [](std::string_view input, int r, int g, int b, int a) { + auto value = parseCSSProperty(input); + ASSERT_TRUE(std::holds_alternative(value)) << input; + EXPECT_EQ(static_cast(std::get(value).r), r) << input; + EXPECT_EQ(static_cast(std::get(value).g), g) << input; + EXPECT_EQ(static_cast(std::get(value).b), b) << input; + EXPECT_EQ(static_cast(std::get(value).a), a) << input; + }; + + expectColor("#0f0", 0, 255, 0, 255); + expectColor("#ff0000", 255, 0, 0, 255); + expectColor("#ff000080", 255, 0, 0, 128); // alpha is the LAST byte + expectColor("rgb(0, 128, 255)", 0, 128, 255, 255); + expectColor("rgba(0, 128, 255, 0.6)", 0, 128, 255, 153); + expectColor("hsl(120, 100%, 50%)", 0, 255, 0, 255); + expectColor("hsla(120, 100%, 50%, 0.6)", 0, 255, 0, 153); + expectColor("cornflowerblue", 100, 149, 237, 255); + expectColor("transparent", 0, 0, 0, 0); + + EXPECT_TRUE( + std::holds_alternative(parseCSSProperty(""))); + EXPECT_TRUE( + std::holds_alternative( + parseCSSProperty("not-a-color"))); + EXPECT_TRUE( + std::holds_alternative( + parseCSSProperty("#GG0000"))); +} + } // namespace facebook::react 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..cf5a7c2ce1f1 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,73 @@ 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>()) { + // Mixed array + string values, so read as a map of RawValue (a map of + // vector would assert on the fallback string). + 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()) { + // Cache the (costly) JNI results. A cached nullopt is an explicit miss, + // distinct from a path that resolves to transparent (ARGB 0). + 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"); + // Boxed Integer: null is an explicit miss; a non-null value may 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])); + // No path resolved: parse the raw fallback with the shared CSS 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..a7dd98139991 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,9 @@ struct Color { int32_t getColor() const; std::size_t getUIColorHash() const; + // Returns the UndefinedColor sentinel (null underlying UIColor) on a miss, so + // callers can tell a miss from a name that resolves to transparent. Callers + // reaching into getUIColor() must null-check it. 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..e47deaa95cf6 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,12 @@ 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) { + // Undefined-color sentinel on a miss, distinct from a name that resolves to + // transparent (getColor() is still 0, preserving the old render). + 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..890db576eba9 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,39 @@ return SharedColor(color); } +// nullopt only on a parse failure, so a fallback that resolves to transparent is +// still honored. +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)); + // The sentinel (null UIColor) means a true miss; apply the fallback only + // then, not when a name resolves to transparent. + if (!semanticColor) { + if (items.find("fallback") != items.end() && items.at("fallback").hasType()) { + auto fallbackColor = fallbackColorFromString((std::string)items.at("fallback")); + // has_value(), not != 0, so a transparent fallback is kept. + if (fallbackColor.has_value()) { + return *fallbackColor; + } + } + // Miss with no usable fallback: clearColor, never leaking the sentinel. + 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..ad021c1e413f 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,15 @@ 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 on a miss, so callers +// can tell a miss from a name that resolves to transparent. +UIColor *_Nullable RCTPlatformColorFromSemanticItemsOrNil(std::vector &semanticItems); +// Precondition: `color` is a resolved color, never the miss sentinel, so the +// result stays _Nonnull. 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..882f38805672 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,15 @@ } } - return UIColor.clearColor; + return nil; +} + +UIColor *RCTPlatformColorFromSemanticItems(std::vector &semanticItems) +{ + // Non-null contract (e.g. for RCTPlatformColorComponentsFromSemanticItems); + // callers needing to detect a miss use the OrNil variant. + UIColor *uiColor = RCTPlatformColorFromSemanticItemsOrNil(semanticItems); + return uiColor != nil ? uiColor : UIColor.clearColor; } UIColor *RCTPlatformColorFromColor(const facebook::react::Color &color) diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 24f632825a84..556c8117198d 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<> + * @generated SignedSource<<53280bff7703ea0e9e912e22543fd568>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -3491,7 +3491,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" @@ -5866,7 +5873,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..1465983c994a 100644 --- a/packages/rn-tester/js/examples/PlatformColor/PlatformColorExample.js +++ b/packages/rn-tester/js/examples/PlatformColor/PlatformColorExample.js @@ -236,6 +236,154 @@ 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 shared native CSS color + parser, so hex (#RGB / #RRGGBB / #RRGGBBAA), rgb(), rgba(), hsl(), + hsla() and named colors all resolve consistently on every platform. Only + a representative subset is demoed below. + + + + 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. They resolve on every platform, since the + fallback is parsed by the shared CSS color parser; they are omitted only + to keep this example concise. + */} + + + fallback '#FF000080' (#RRGGBBAA) → 50% transparent RED + + + + + ); +} + function DynamicColorsExample() { return Platform.OS === 'ios' ? ( @@ -372,6 +520,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 +547,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 9686cd7e5d1b..2f1ede61e222 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -7562,6 +7562,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 9e4a3fa333a9..e2e2f1d000c3 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -7323,6 +7323,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 46adc5077283..d584b9cd4c2b 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -7553,6 +7553,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; }