Skip to content

Commit 750b40a

Browse files
Abbondanzometa-codesync[bot]
authored andcommitted
Support a lazy raw color fallback for PlatformColor (#57556)
Summary: Pull Request resolved: #57556 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 0fc76bb commit 750b40a

24 files changed

Lines changed: 791 additions & 56 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: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,38 @@ 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 lastArg = args[args.length - 1];
24+
if (lastArg == null || typeof lastArg === 'string') {
25+
// Fast path: the {fallback} object is only honored as the trailing argument,
26+
// so when it is absent the rest-param array is already the list of names and
27+
// can be handed to native without allocating and rebuilding a second array.
28+
/* $FlowExpectedError[incompatible-type]
29+
* LocalNativeColorValue is the actual type of the opaque NativeColorValue on Android platform */
30+
return {resource_paths: args} as LocalNativeColorValue;
31+
}
32+
const names: Array<string> = [];
33+
for (let i = 0; i < args.length - 1; i++) {
34+
const arg = args[i];
35+
if (typeof arg === 'string') {
36+
names.push(arg);
37+
}
38+
// A non-string before the final position is ignored (a lint error at authoring).
39+
}
40+
// The fallback is a raw color string, intentionally not processed here; it
41+
// crosses to native untouched and is only parsed on a token miss.
42+
const color: LocalNativeColorValue = {
43+
resource_paths: names,
44+
fallback: lastArg.fallback,
45+
};
2046
/* $FlowExpectedError[incompatible-type]
2147
* LocalNativeColorValue is the actual type of the opaque NativeColorValue on Android platform */
22-
return {resource_paths: names} as LocalNativeColorValue;
48+
return color as LocalNativeColorValue;
2349
};
2450

2551
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: 27 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,33 @@ 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 lastArg = args[args.length - 1];
30+
if (lastArg == null || typeof lastArg === 'string') {
31+
// Fast path: the {fallback} object is only honored as the trailing argument,
32+
// so when it is absent the rest-param array is already the list of names and
33+
// can be handed to native without allocating and rebuilding a second array.
34+
// $FlowExpectedError[incompatible-type] LocalNativeColorValue is the iOS LocalNativeColorValue type
35+
return {semantic: args} as LocalNativeColorValue;
36+
}
37+
const names: Array<string> = [];
38+
for (let i = 0; i < args.length - 1; i++) {
39+
const arg = args[i];
40+
if (typeof arg === 'string') {
41+
names.push(arg);
42+
}
43+
// A non-string before the final position is ignored (a lint error at authoring).
44+
}
45+
// The fallback is a raw color string, intentionally not processed here; it
46+
// crosses to native untouched and is only parsed on a token miss.
47+
const color: LocalNativeColorValue = {
48+
semantic: names,
49+
fallback: lastArg.fallback,
50+
};
2651
// $FlowExpectedError[incompatible-type] LocalNativeColorValue is the iOS LocalNativeColorValue type
27-
return {semantic: names} as LocalNativeColorValue;
52+
return color as LocalNativeColorValue;
2853
};
2954

3055
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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,71 @@ + (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 iOS New
950+
// Architecture the fallback is parsed by the shared CSS color parser, which also
951+
// accepts rgb()/rgba() and named colors; this legacy path handles the common hex
952+
// forms.)
953+
static UIColor *RCTFallbackUIColorFromHexString(NSString *colorString)
954+
{
955+
if (![colorString isKindOfClass:[NSString class]]) {
956+
return nil;
957+
}
958+
NSString *hex = [colorString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
959+
if (![hex hasPrefix:@"#"]) {
960+
return nil;
961+
}
962+
hex = [hex substringFromIndex:1];
963+
NSUInteger length = hex.length;
964+
// Expand shorthand #RGB / #RGBA to #RRGGBB / #RRGGBBAA.
965+
if (length == 3 || length == 4) {
966+
NSMutableString *expanded = [NSMutableString stringWithCapacity:length * 2];
967+
for (NSUInteger i = 0; i < length; i++) {
968+
unichar c = [hex characterAtIndex:i];
969+
[expanded appendFormat:@"%C%C", c, c];
970+
}
971+
hex = expanded;
972+
length = hex.length;
973+
}
974+
if (length != 6 && length != 8) {
975+
return nil;
976+
}
977+
NSCharacterSet *nonHex = [[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"] invertedSet];
978+
if ([hex rangeOfCharacterFromSet:nonHex].location != NSNotFound) {
979+
return nil;
980+
}
981+
unsigned int value = 0;
982+
if (![[NSScanner scannerWithString:hex] scanHexInt:&value]) {
983+
return nil;
984+
}
985+
CGFloat r;
986+
CGFloat g;
987+
CGFloat b;
988+
CGFloat a;
989+
if (length == 6) {
990+
r = ((value >> 16) & 0xFF) / 255.0;
991+
g = ((value >> 8) & 0xFF) / 255.0;
992+
b = (value & 0xFF) / 255.0;
993+
a = 1.0;
994+
} else {
995+
r = ((value >> 24) & 0xFF) / 255.0;
996+
g = ((value >> 16) & 0xFF) / 255.0;
997+
b = ((value >> 8) & 0xFF) / 255.0;
998+
a = (value & 0xFF) / 255.0;
999+
}
1000+
return [UIColor colorWithRed:r green:g blue:b alpha:a];
1001+
}
1002+
1003+
static UIColor *RCTFallbackUIColorFromColorDictionary(NSDictionary *dictionary)
1004+
{
1005+
id fallback = [dictionary objectForKey:@"fallback"];
1006+
if ([fallback isKindOfClass:[NSString class]]) {
1007+
return RCTFallbackUIColorFromHexString(fallback);
1008+
}
1009+
return nil;
1010+
}
1011+
9471012
+ (UIColor *)UIColor:(id)json
9481013
{
9491014
if (!json) {
@@ -983,6 +1048,10 @@ + (UIColor *)UIColor:(id)json
9831048
}
9841049
color = RCTColorFromSemanticColorName(semanticName);
9851050
if (color == nil) {
1051+
UIColor *fallbackColor = RCTFallbackUIColorFromColorDictionary(dictionary);
1052+
if (fallbackColor != nil) {
1053+
return fallbackColor;
1054+
}
9861055
RCTLogConvertError(
9871056
json,
9881057
[@"a UIColor. Expected one of the following values: " stringByAppendingString:RCTSemanticColorNames()]);
@@ -999,6 +1068,10 @@ + (UIColor *)UIColor:(id)json
9991068
return color;
10001069
}
10011070
}
1071+
UIColor *fallbackColor = RCTFallbackUIColorFromColorDictionary(dictionary);
1072+
if (fallbackColor != nil) {
1073+
return fallbackColor;
1074+
}
10021075
RCTLogConvertError(
10031076
json,
10041077
[@"a UIColor. None of the names in the array were one of the following values: "

packages/react-native/ReactAndroid/api/ReactAndroid.api

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2252,7 +2252,7 @@ public class com/facebook/react/fabric/FabricUIManager : com/facebook/react/brid
22522252
public fun dispatchCommand (IILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V
22532253
public fun dispatchCommand (ILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V
22542254
public fun findNextFocusableElement (III)Ljava/lang/Integer;
2255-
public fun getColor (I[Ljava/lang/String;)I
2255+
public fun getColor (I[Ljava/lang/String;)Ljava/lang/Integer;
22562256
public fun getEventDispatcher ()Lcom/facebook/react/uimanager/events/EventDispatcher;
22572257
public fun getPerformanceCounters ()Ljava/util/Map;
22582258
public fun getRelativeAncestorList (II)[I

0 commit comments

Comments
 (0)