Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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')});",
Expand All @@ -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}],
Expand Down
27 changes: 26 additions & 1 deletion packages/eslint-plugin-react-native/platform-colors.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,39 @@ module.exports = {
CallExpression: function (node) {
if (node.callee.name === 'PlatformColor') {
const args = node.arguments;
// Optional trailing {fallback: <literal>}: 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,
messageId: 'platformColorArgsLength',
});
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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
fallback?: string,
};

export const PlatformColor = (...names: Array<string>): NativeColorValue => {
export const PlatformColor = (
...args: Array<string | {fallback: string}>
): 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 = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | {fallback: string}>
): OpaqueColorValue;
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
fallback?: string,
dynamic?: {
light: ?(ColorValue | ProcessedColorValue),
dark: ?(ColorValue | ProcessedColorValue),
Expand All @@ -22,9 +25,16 @@ type LocalNativeColorValue = {
},
};

export const PlatformColor = (...names: Array<string>): NativeColorValue => {
export const PlatformColor = (
...args: Array<string | {fallback: string}>
): 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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {NativeColorValue} from './StyleSheet';
* @see https://reactnative.dev/docs/platformcolor#example
*/
declare export function PlatformColor(
...names: Array<string>
...names: Array<string | {fallback: string}>
): NativeColorValue;

declare export function normalizeColorObject(
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<View style={{backgroundColor: color}} />);
});
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(
<rn-view backgroundColor={expected} />,
);
});
}
});

// 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'],
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
});

Expand All @@ -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);
});
}
});
});
Original file line number Diff line number Diff line change
@@ -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<string | {fallback: string}>,
): {names: Array<string>, 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<string> = [];
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};
}
2 changes: 1 addition & 1 deletion packages/react-native/ReactAndroid/api/ReactAndroid.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading