Skip to content

Commit 48ee660

Browse files
Abbondanzofacebook-github-bot
authored andcommitted
Support a lazy raw color fallback for PlatformColor
Summary: An implementation for the RFC in react-native-community/discussions-and-proposals#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: '<raw color string>'}` argument to `PlatformColor(...)`. The fallback is carried lazily to the native layer as a raw, unprocessed string and is only parsed when every provided token fails to resolve. 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'}) The change spans the JS entry points (all platform `PlatformColorValueTypes.*` files plus the Flow and TypeScript type declarations), the iOS (Paper and Fabric) and Android (Paper and Fabric) native color resolvers, a new "Lazy Fallback Colors" section in the RNTester PlatformColor example, and the regenerated C++ API snapshots. Changelog: [General][Added] - Support a lazy raw color fallback for PlatformColor when native tokens fail to resolve Differential Revision: D111837102
1 parent ef54f74 commit 48ee660

18 files changed

Lines changed: 710 additions & 49 deletions

File tree

packages/eslint-plugin-react-native/__tests__/platform-colors-test.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ eslintTester.run('../platform-colors', rule, {
1919
valid: [
2020
"const color = PlatformColor('labelColor');",
2121
"const color = PlatformColor('controlAccentColor', 'controlColor');",
22+
"const color = PlatformColor('labelColor', {fallback: '#FF0000'});",
23+
"const color = PlatformColor('controlAccentColor', 'controlColor', {fallback: 'red'});",
2224
"const color = DynamicColorIOS({light: 'black', dark: 'white'});",
2325
"const color = DynamicColorIOS({light: PlatformColor('black'), dark: PlatformColor('white')});",
2426
"const color = DynamicColorIOS({light: PlatformColor('black'), dark: PlatformColor('white'), highContrastLight: PlatformColor('black'), highContrastDark: PlatformColor('white')});",
@@ -32,6 +34,14 @@ eslintTester.run('../platform-colors', rule, {
3234
code: "const labelColor = 'labelColor'; const color = PlatformColor(labelColor);",
3335
errors: [{message: rule.meta.messages.platformColorArgTypes}],
3436
},
37+
{
38+
code: "const raw = '#FF0000'; const color = PlatformColor('labelColor', {fallback: raw});",
39+
errors: [{message: rule.meta.messages.platformColorArgTypes}],
40+
},
41+
{
42+
code: "const color = PlatformColor({fallback: '#FF0000'}, 'labelColor');",
43+
errors: [{message: rule.meta.messages.platformColorArgTypes}],
44+
},
3545
{
3646
code: "const tuple = {light: 'black', dark: 'white'}; const color = DynamicColorIOS(tuple);",
3747
errors: [{message: rule.meta.messages.dynamicColorIOSArg}],

packages/eslint-plugin-react-native/platform-colors.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,34 @@ module.exports = {
3333
CallExpression: function (node) {
3434
if (node.callee.name === 'PlatformColor') {
3535
const args = node.arguments;
36+
// The optional trailing options object carries a lazy raw color
37+
// fallback: PlatformColor('token', {fallback: '#RRGGBB'}). Its
38+
// fallback value must itself be a literal so it stays statically
39+
// analyzable.
40+
const isFallbackObject = arg =>
41+
arg.type === 'ObjectExpression' &&
42+
arg.properties.length > 0 &&
43+
arg.properties.every(
44+
property =>
45+
property.type === 'Property' &&
46+
property.key.type === 'Identifier' &&
47+
property.key.name === 'fallback' &&
48+
property.value.type === 'Literal',
49+
);
3650
if (args.length === 0) {
3751
context.report({
3852
node,
3953
messageId: 'platformColorArgsLength',
4054
});
4155
return;
4256
}
43-
if (!args.every(arg => arg.type === 'Literal')) {
57+
if (
58+
!args.every(
59+
(arg, index) =>
60+
arg.type === 'Literal' ||
61+
(index === args.length - 1 && isFallbackObject(arg)),
62+
)
63+
) {
4464
context.report({
4565
node,
4666
messageId: 'platformColorArgTypes',

packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.android.js

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,33 @@ import type {NativeColorValue} from './StyleSheet';
1414
/** The actual type of the opaque NativeColorValue on Android platform */
1515
type LocalNativeColorValue = {
1616
resource_paths?: Array<string>,
17+
fallback?: string,
1718
};
1819

19-
export const PlatformColor = (...names: Array<string>): NativeColorValue => {
20+
export const PlatformColor = (
21+
...args: Array<string | {fallback: string}>
22+
): NativeColorValue => {
23+
const names: Array<string> = [];
24+
let fallback: ?string;
25+
for (let i = 0; i < args.length; i++) {
26+
const arg = args[i];
27+
if (typeof arg === 'string') {
28+
names.push(arg);
29+
} else if (i === args.length - 1) {
30+
// The {fallback} object is only honored as the trailing argument, matching
31+
// the ESLint rule. It is a raw color string, intentionally not processed
32+
// here; it crosses to native untouched and is only parsed on a token miss.
33+
fallback = arg.fallback;
34+
}
35+
// A non-string before the final position is ignored (a lint error at authoring).
36+
}
37+
const color: LocalNativeColorValue = {resource_paths: names};
38+
if (fallback != null) {
39+
color.fallback = fallback;
40+
}
2041
/* $FlowExpectedError[incompatible-type]
2142
* LocalNativeColorValue is the actual type of the opaque NativeColorValue on Android platform */
22-
return {resource_paths: names} as LocalNativeColorValue;
43+
return color as LocalNativeColorValue;
2344
};
2445

2546
export const normalizeColorObject = (

packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@ import {OpaqueColorValue} from './StyleSheet';
1515
*
1616
* @see https://reactnative.dev/docs/platformcolor#example
1717
*/
18-
export function PlatformColor(...colors: string[]): OpaqueColorValue;
18+
export function PlatformColor(
19+
...colors: Array<string | {fallback: string}>
20+
): OpaqueColorValue;

packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.ios.js

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {ColorValue, NativeColorValue} from './StyleSheet';
1414
/** The actual type of the opaque NativeColorValue on iOS platform */
1515
type LocalNativeColorValue = {
1616
semantic?: Array<string>,
17+
fallback?: string,
1718
dynamic?: {
1819
light: ?(ColorValue | ProcessedColorValue),
1920
dark: ?(ColorValue | ProcessedColorValue),
@@ -22,9 +23,29 @@ type LocalNativeColorValue = {
2223
},
2324
};
2425

25-
export const PlatformColor = (...names: Array<string>): NativeColorValue => {
26+
export const PlatformColor = (
27+
...args: Array<string | {fallback: string}>
28+
): NativeColorValue => {
29+
const names: Array<string> = [];
30+
let fallback: ?string;
31+
for (let i = 0; i < args.length; i++) {
32+
const arg = args[i];
33+
if (typeof arg === 'string') {
34+
names.push(arg);
35+
} else if (i === args.length - 1) {
36+
// The {fallback} object is only honored as the trailing argument, matching
37+
// the ESLint rule. It is a raw color string, intentionally not processed
38+
// here; it crosses to native untouched and is only parsed on a token miss.
39+
fallback = arg.fallback;
40+
}
41+
// A non-string before the final position is ignored (a lint error at authoring).
42+
}
43+
const color: LocalNativeColorValue = {semantic: names};
44+
if (fallback != null) {
45+
color.fallback = fallback;
46+
}
2647
// $FlowExpectedError[incompatible-type] LocalNativeColorValue is the iOS LocalNativeColorValue type
27-
return {semantic: names} as LocalNativeColorValue;
48+
return color as LocalNativeColorValue;
2849
};
2950

3051
export type DynamicColorIOSTuplePrivate = {

packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.js.flow

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type {NativeColorValue} from './StyleSheet';
1818
* @see https://reactnative.dev/docs/platformcolor#example
1919
*/
2020
declare export function PlatformColor(
21-
...names: Array<string>
21+
...names: Array<string | {fallback: string}>
2222
): NativeColorValue;
2323

2424
declare export function normalizeColorObject(

packages/react-native/Libraries/StyleSheet/__tests__/processColor-itest.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ describe('processColor', () => {
109109
const expectedColor = {dynamic: {light: 0xff000000, dark: 0xffffffff}};
110110
expect(processedColor).toEqual(expectedColor);
111111
});
112+
113+
it('should carry an unprocessed fallback on iOS PlatformColor colors', () => {
114+
const color = PlatformColorIOS('systemRedColor', {fallback: '#ff0000'});
115+
const processedColor = processColor(color);
116+
const expectedColor = {
117+
semantic: ['systemRedColor'],
118+
fallback: '#ff0000',
119+
};
120+
expect(processedColor).toEqual(expectedColor);
121+
});
112122
}
113123
});
114124

@@ -120,6 +130,18 @@ describe('processColor', () => {
120130
const expectedColor = {resource_paths: ['?attr/colorPrimary']};
121131
expect(processedColor).toEqual(expectedColor);
122132
});
133+
134+
it('should carry an unprocessed fallback on Android PlatformColor colors', () => {
135+
const color = PlatformColorAndroid('?attr/colorPrimary', {
136+
fallback: '#000000',
137+
});
138+
const processedColor = processColor(color);
139+
const expectedColor = {
140+
resource_paths: ['?attr/colorPrimary'],
141+
fallback: '#000000',
142+
};
143+
expect(processedColor).toEqual(expectedColor);
144+
});
123145
}
124146
});
125147
});

packages/react-native/React/Base/RCTConvert.mm

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,70 @@ + (RCTColorSpace)RCTColorSpaceFromString:(NSString *)colorSpace
944944
return RCTGetDefaultColorSpace();
945945
}
946946

947+
// Parses a raw hex color string (#RGB, #RGBA, #RRGGBB, #RRGGBBAA — alpha last, as
948+
// in CSS) into a UIColor, or nil if it is not a supported hex color. Used only as
949+
// a PlatformColor fallback when every semantic name misses. (On the New
950+
// Architecture the fallback additionally supports rgb()/rgba()/named colors via
951+
// the shared CSS color parser; this legacy path handles the common hex forms.)
952+
static UIColor *RCTFallbackUIColorFromHexString(NSString *colorString)
953+
{
954+
if (![colorString isKindOfClass:[NSString class]]) {
955+
return nil;
956+
}
957+
NSString *hex = [colorString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
958+
if (![hex hasPrefix:@"#"]) {
959+
return nil;
960+
}
961+
hex = [hex substringFromIndex:1];
962+
NSUInteger length = hex.length;
963+
// Expand shorthand #RGB / #RGBA to #RRGGBB / #RRGGBBAA.
964+
if (length == 3 || length == 4) {
965+
NSMutableString *expanded = [NSMutableString stringWithCapacity:length * 2];
966+
for (NSUInteger i = 0; i < length; i++) {
967+
unichar c = [hex characterAtIndex:i];
968+
[expanded appendFormat:@"%C%C", c, c];
969+
}
970+
hex = expanded;
971+
length = hex.length;
972+
}
973+
if (length != 6 && length != 8) {
974+
return nil;
975+
}
976+
NSCharacterSet *nonHex = [[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"] invertedSet];
977+
if ([hex rangeOfCharacterFromSet:nonHex].location != NSNotFound) {
978+
return nil;
979+
}
980+
unsigned int value = 0;
981+
if (![[NSScanner scannerWithString:hex] scanHexInt:&value]) {
982+
return nil;
983+
}
984+
CGFloat r;
985+
CGFloat g;
986+
CGFloat b;
987+
CGFloat a;
988+
if (length == 6) {
989+
r = ((value >> 16) & 0xFF) / 255.0;
990+
g = ((value >> 8) & 0xFF) / 255.0;
991+
b = (value & 0xFF) / 255.0;
992+
a = 1.0;
993+
} else {
994+
r = ((value >> 24) & 0xFF) / 255.0;
995+
g = ((value >> 16) & 0xFF) / 255.0;
996+
b = ((value >> 8) & 0xFF) / 255.0;
997+
a = (value & 0xFF) / 255.0;
998+
}
999+
return [UIColor colorWithRed:r green:g blue:b alpha:a];
1000+
}
1001+
1002+
static UIColor *RCTFallbackUIColorFromColorDictionary(NSDictionary *dictionary)
1003+
{
1004+
id fallback = [dictionary objectForKey:@"fallback"];
1005+
if ([fallback isKindOfClass:[NSString class]]) {
1006+
return RCTFallbackUIColorFromHexString(fallback);
1007+
}
1008+
return nil;
1009+
}
1010+
9471011
+ (UIColor *)UIColor:(id)json
9481012
{
9491013
if (!json) {
@@ -983,6 +1047,10 @@ + (UIColor *)UIColor:(id)json
9831047
}
9841048
color = RCTColorFromSemanticColorName(semanticName);
9851049
if (color == nil) {
1050+
UIColor *fallbackColor = RCTFallbackUIColorFromColorDictionary(dictionary);
1051+
if (fallbackColor != nil) {
1052+
return fallbackColor;
1053+
}
9861054
RCTLogConvertError(
9871055
json,
9881056
[@"a UIColor. Expected one of the following values: " stringByAppendingString:RCTSemanticColorNames()]);
@@ -999,6 +1067,10 @@ + (UIColor *)UIColor:(id)json
9991067
return color;
10001068
}
10011069
}
1070+
UIColor *fallbackColor = RCTFallbackUIColorFromColorDictionary(dictionary);
1071+
if (fallbackColor != nil) {
1072+
return fallbackColor;
1073+
}
10021074
RCTLogConvertError(
10031075
json,
10041076
[@"a UIColor. None of the names in the array were one of the following values: "

0 commit comments

Comments
 (0)