PlatformColor lazy fallback: lint rule + RNTester example (#57705)

Summary:
Pull Request resolved: https://github.com/react/react-native/pull/57705

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: <literal>}` 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

fbshipit-source-id: 7d38a8d55b54615d1b131591e99d13e6299eb5cb
This commit is contained in:
Peter Abbondanzo
2026-08-06 07:37:07 -07:00
committed by meta-codesync[bot]
parent bf14b94352
commit 31e119a2a1
3 changed files with 205 additions and 1 deletions
@@ -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}],
@@ -33,6 +33,21 @@ 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,
@@ -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',
@@ -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 (
<View style={styles.column}>
<View style={styles.row}>
<RNTesterText style={styles.labelCell}>
Valid token '{validToken}' (shows the system color)
</RNTesterText>
<View
style={{
...styles.colorCell,
backgroundColor: PlatformColor(validToken),
}}
/>
</View>
<View style={styles.row}>
<RNTesterText style={styles.labelCell}>
Invalid token, NO fallback (miss → transparent, outlined below)
</RNTesterText>
<View
style={{
...styles.colorCell,
backgroundColor: PlatformColor(invalidToken),
borderColor: 'black',
borderWidth: 1,
}}
/>
</View>
<View style={styles.row}>
<RNTesterText style={styles.labelCell}>
Invalid token + fallback '#FF0000' → RED (backgroundColor)
</RNTesterText>
<View
style={{
...styles.colorCell,
backgroundColor: PlatformColor(invalidToken, {fallback: '#FF0000'}),
}}
/>
</View>
<View style={styles.row}>
<RNTesterText style={styles.labelCell}>
Invalid token + fallback '#FFFF00' → YELLOW (backgroundColor)
</RNTesterText>
<View
style={{
...styles.colorCell,
backgroundColor: PlatformColor(invalidToken, {fallback: '#FFFF00'}),
}}
/>
</View>
<View style={styles.row}>
<RNTesterText style={styles.labelCell}>
Invalid token + fallback '#00FF00' → GREEN (text color)
</RNTesterText>
<View style={styles.colorCell}>
<RNTesterText
style={{
color: PlatformColor(invalidToken, {fallback: '#00FF00'}),
fontWeight: 'bold',
}}>
GREEN
</RNTesterText>
</View>
</View>
<View style={styles.row}>
<RNTesterText style={styles.labelCell}>
Invalid token + fallback '#0000FF' → BLUE (borderColor)
</RNTesterText>
<View
style={{
...styles.colorCell,
borderColor: PlatformColor(invalidToken, {fallback: '#0000FF'}),
borderWidth: 3,
}}
/>
</View>
<RNTesterText style={styles.note}>
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.
</RNTesterText>
<View style={styles.row}>
<RNTesterText style={styles.labelCell}>
fallback 'rgb(255, 0, 128)' → PINK (backgroundColor)
</RNTesterText>
<View
style={{
...styles.colorCell,
backgroundColor: PlatformColor(invalidToken, {
fallback: 'rgb(255, 0, 128)',
}),
}}
/>
</View>
<View style={styles.row}>
<RNTesterText style={styles.labelCell}>
fallback 'rgba(0, 128, 255, 0.7)' → semi-transparent BLUE
</RNTesterText>
<View
style={{
...styles.colorCell,
backgroundColor: PlatformColor(invalidToken, {
fallback: 'rgba(0, 128, 255, 0.7)',
}),
borderColor: 'black',
borderWidth: 1,
}}
/>
</View>
{/*
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.
*/}
<View style={styles.row}>
<RNTesterText style={styles.labelCell}>
fallback '#FF000080' (#RRGGBBAA) → 50% transparent RED
</RNTesterText>
<View
style={{
...styles.colorCell,
backgroundColor: PlatformColor(invalidToken, {
fallback: '#FF000080',
}),
borderColor: 'black',
borderWidth: 1,
}}
/>
</View>
</View>
);
}
function DynamicColorsExample() {
return Platform.OS === 'ios' ? (
<View style={styles.column}>
@@ -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 <FallbackColorsExample />;
},
},
{
title: 'Lazy Fallback Colors',
render(): React.MixedElement {
return <LazyFallbackColorsExample />;
},
},
{
title: 'iOS Dynamic Colors',
render(): React.MixedElement {