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..aa6d3fa12683 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,26 @@ 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 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..228c7c3609c6 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,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..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..33fbe18af43b --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/__tests__/PlatformColorFallback-itest.js @@ -0,0 +1,149 @@ +/** + * 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', () => { + describe('fallback color-format strings 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 each case is spelled out. + describe('PlatformColor carries the raw, unprocessed fallback', () => { + it('carries a #RRGGBB fallback', () => { + expect( + processColor( + PlatformColor('?attr/nonExistentColor', {fallback: '#FF0000'}), + ), + ).toEqual({ + resource_paths: ['?attr/nonExistentColor'], + fallback: '#FF0000', + }); + }); + + it('carries a #RRGGBBAA fallback', () => { + expect( + processColor( + PlatformColor('?attr/nonExistentColor', {fallback: '#FF000080'}), + ), + ).toEqual({ + resource_paths: ['?attr/nonExistentColor'], + fallback: '#FF000080', + }); + }); + + it('carries an rgb() fallback', () => { + expect( + processColor( + PlatformColor('?attr/nonExistentColor', { + fallback: 'rgb(255, 0, 128)', + }), + ), + ).toEqual({ + resource_paths: ['?attr/nonExistentColor'], + fallback: 'rgb(255, 0, 128)', + }); + }); + + it('carries an rgba() fallback', () => { + expect( + processColor( + PlatformColor('?attr/nonExistentColor', { + fallback: 'rgba(0, 128, 255, 0.7)', + }), + ), + ).toEqual({ + resource_paths: ['?attr/nonExistentColor'], + fallback: 'rgba(0, 128, 255, 0.7)', + }); + }); + + it('carries an hsl() fallback', () => { + expect( + processColor( + PlatformColor('?attr/nonExistentColor', { + fallback: 'hsl(120, 100%, 50%)', + }), + ), + ).toEqual({ + resource_paths: ['?attr/nonExistentColor'], + fallback: 'hsl(120, 100%, 50%)', + }); + }); + + it('carries an hsla() fallback', () => { + expect( + processColor( + PlatformColor('?attr/nonExistentColor', { + fallback: 'hsla(280, 100%, 60%, 0.8)', + }), + ), + ).toEqual({ + resource_paths: ['?attr/nonExistentColor'], + fallback: 'hsla(280, 100%, 60%, 0.8)', + }); + }); + + it('carries a named-color fallback', () => { + expect( + processColor( + PlatformColor('?attr/nonExistentColor', {fallback: 'cornflowerblue'}), + ), + ).toEqual({ + resource_paths: ['?attr/nonExistentColor'], + fallback: '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/ReactCommon/react/renderer/css/tests/CSSColorTest.cpp b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSColorTest.cpp index c5d30aec120e..748dd6e1d298 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,39 @@ 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); // #RGB + expectColor("#ff0000", 255, 0, 0, 255); // #RRGGBB + expectColor( + "#ff000080", 255, 0, 0, 128); // #RRGGBBAA — alpha is the LAST byte + expectColor("rgb(0, 128, 255)", 0, 128, 255, 255); + expectColor("rgba(0, 128, 255, 0.5)", 0, 128, 255, 128); + expectColor("hsl(120, 100%, 50%)", 0, 255, 0, 255); + expectColor("hsla(120, 100%, 50%, 0.5)", 0, 255, 0, 128); + 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/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 {