Skip to content
Closed
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,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}],
Expand Down
23 changes: 22 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,35 @@ 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' ||
(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,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(<View style={{backgroundColor: color}} />);
});
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(
<rn-view backgroundColor={expected} />,
);
});
}
});

// 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'],
});
});
});
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};
}
Loading
Loading