From 5ecf6a2ae6d16adabea65e68df5ac05d98619a98 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Wed, 5 Aug 2026 08:14:53 -0700 Subject: [PATCH 1/2] Support a lazy raw color fallback for PlatformColor (#57701) 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(...)` and updates the JS entry points to emit it. The fallback is carried lazily to native as a raw, unprocessed string and is only parsed when every provided token fails to resolve (the native side of that behavior landed earlier in this stack). 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'}) Each platform entry point (`PlatformColorValueTypes.{android,ios,macos,windows}.js`, plus the vendored macOS/Windows copies) collects the leading string tokens and, when a trailing `{fallback}` object is present, attaches its raw string to the native color object. Non-string arguments in a non-trailing position are filtered out consistently rather than being forwarded to native. The Flow (`.js.flow`), TypeScript (`.d.ts`), and generated (`ReactNativeApi.d.ts`) declarations widen the signature to `Array`, which is backward compatible. Changelog: [General][Added] - Support a lazy raw color fallback for PlatformColor when native tokens fail to resolve Reviewed By: javache, huntie Differential Revision: D113329139 --- .../PlatformColorValueTypes.android.js | 16 +++++- .../StyleSheet/PlatformColorValueTypes.d.ts | 4 +- .../StyleSheet/PlatformColorValueTypes.ios.js | 14 ++++- .../PlatformColorValueTypes.js.flow | 2 +- .../__tests__/processColor-itest.js | 27 +++++++++ .../StyleSheet/parsePlatformColorArgs.js | 55 +++++++++++++++++++ packages/react-native/ReactNativeApi.d.ts | 13 ++++- 7 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 packages/react-native/Libraries/StyleSheet/parsePlatformColorArgs.js 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__/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/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 From cb0f50be4d2851a6f1fe760f3b8e99ce9277fc44 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Wed, 5 Aug 2026 08:14:53 -0700 Subject: [PATCH 2/2] PlatformColor lazy fallback: lint rule + RNTester example (#57705) Summary: An implementation for the RFC in https://github.com/react-native-community/discussions-and-proposals/pull/1008 Rounds out the lazy `PlatformColor` fallback with tooling and a demo. - The `react-native/platform-colors` ESLint rule now permits an optional trailing `{fallback: }` options object so `PlatformColor('token', {fallback: '#RRGGBB'})` is lint-clean, while still requiring every other argument to be a literal. The options object must have exactly one `fallback` property whose value is a literal, so it stays statically analyzable. - A new "Lazy Fallback Colors" section in the RNTester `PlatformColor` example demonstrates valid tokens, misses with no fallback (transparent), and misses with hex / `rgb()` / `rgba()` / `#RRGGBBAA` fallbacks across `backgroundColor`, text `color`, and `borderColor`. Changelog: [Internal] - PlatformColor: ESLint support and RNTester example for the lazy raw-color fallback Reviewed By: christophpurrer Differential Revision: D113329138 --- .../__tests__/platform-colors-test.js | 22 +++ .../platform-colors.js | 23 ++- .../PlatformColor/PlatformColorExample.js | 161 ++++++++++++++++++ 3 files changed, 205 insertions(+), 1 deletion(-) 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/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 {