Skip to content

Commit c43f71d

Browse files
Abbondanzofacebook-github-bot
authored andcommitted
PlatformColor lazy fallback: honor a raw-color fallback on Android (native)
Summary: An implementation for the RFC in react-native-community/discussions-and-proposals#1008 Wires up the Android native color resolvers (Fabric and Paper) to honor the lazy `{fallback}` carried by `PlatformColor(...)`. A later diff adds the JS argument that emits it, so on its own this change is a no-op for existing call sites. To tell a genuine miss apart from a token that resolves to transparent black (ARGB 0): - `FabricUIManager.getColor` now returns a boxed `Nullable Integer` — `null` means no resource path resolved. The Fabric C++ `PlatformColorParser.h` reads that boxed result over JNI, caches the explicit-miss signal, and on a miss parses the raw fallback string. The color object is now read as a `map<string, RawValue>` because it mixes an array (`resource_paths`) with an optional string (`fallback`). - On the Paper path, `ColorPropConverter` parses the raw fallback string when every resource path misses, and degrades to transparent (rather than crashing) when a fallback is present but unparseable. - `NativeDrawable` carries an optional `colorFallback` alongside `resource_paths` so ripple drawables degrade to the fallback too. The fallback string parser accepts `#RGB`/`#RGBA`/`#RRGGBB`/`#RRGGBBAA` (alpha last, as in CSS and iOS — not Android's native `#AARRGGBB`), `rgb()`/`rgba()`, and named colors. `rgb()`/`rgba()` require exactly 3 / 4 components respectively, matching the CSS parser used on the iOS Fabric path, so lenient inputs are rejected consistently across platforms. Unknown named colors return quietly rather than logging on every resolve. Generated files (`ReactAndroid.api` and the `ReactAndroid*Cxx.api` snapshots) are regenerated to match the new public signatures. Changelog: [Internal] - PlatformColor: Android native support for a lazy raw-color fallback Differential Revision: D113329136
1 parent 8627d42 commit c43f71d

9 files changed

Lines changed: 248 additions & 39 deletions

File tree

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

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ColorPropConverter.kt

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
package com.facebook.react.bridge
99

10+
import android.annotation.SuppressLint
1011
import android.content.Context
1112
import android.content.res.Resources
1213
import android.graphics.Color
@@ -15,14 +16,17 @@ import android.os.Build
1516
import android.util.TypedValue
1617
import androidx.annotation.ColorLong
1718
import androidx.core.content.res.ResourcesCompat
19+
import androidx.core.graphics.toColorInt
1820
import com.facebook.common.logging.FLog
1921
import com.facebook.react.common.ReactConstants
22+
import kotlin.math.roundToInt
2023

2124
public object ColorPropConverter {
2225

2326
private fun supportWideGamut(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
2427

2528
private const val JSON_KEY = "resource_paths"
29+
private const val FALLBACK_KEY = "fallback"
2630
private const val PREFIX_RESOURCE = "@"
2731
private const val PREFIX_ATTR = "?"
2832
private const val PACKAGE_DELIMITER = ":"
@@ -71,6 +75,17 @@ public object ColorPropConverter {
7175
"ColorValue: Failed to resolve resource paths: ${attemptedPaths.joinToString(", ")}",
7276
)
7377

78+
val fallback = parseFallbackColor(value)
79+
if (fallback != null) {
80+
return fallback
81+
}
82+
// A fallback was provided but could not be parsed: degrade to transparent
83+
// instead of crashing, matching iOS and the New Architecture path.
84+
if (value.hasKey(FALLBACK_KEY)) {
85+
FLog.w(ReactConstants.TAG, "ColorValue: Unparseable fallback color; using transparent.")
86+
return 0
87+
}
88+
7489
throw JSApplicationCausedNativeException(
7590
"ColorValue: None of the paths in the `$JSON_KEY` array resolved to a color resource."
7691
)
@@ -127,6 +142,22 @@ public object ColorPropConverter {
127142
"ColorValue: Failed to resolve resource paths: ${attemptedPaths.joinToString(", ")}",
128143
)
129144

145+
// Color.valueOf is API 26+, the same requirement as supportWideGamut(), so
146+
// gate the whole fallback block on it: parsed fallbacks return directly, and
147+
// a present-but-unparseable fallback degrades to transparent instead of
148+
// crashing (matching iOS and the New Architecture path). On older API levels
149+
// this method throws and the caller falls back to getColorInteger.
150+
if (supportWideGamut()) {
151+
val fallback = parseFallbackColor(value)
152+
if (fallback != null) {
153+
return Color.valueOf(fallback)
154+
}
155+
if (value.hasKey(FALLBACK_KEY)) {
156+
FLog.w(ReactConstants.TAG, "ColorValue: Unparseable fallback color; using transparent.")
157+
return Color.valueOf(0)
158+
}
159+
}
160+
130161
throw JSApplicationCausedNativeException(
131162
"ColorValue: None of the paths in the `$JSON_KEY` array resolved to a color resource."
132163
)
@@ -184,6 +215,9 @@ public object ColorPropConverter {
184215
}
185216
}
186217

218+
// getIdentifier is required here: the resource name arrives dynamically from JS
219+
// at runtime, so a compile-time R.id reference is not possible.
220+
@SuppressLint("DiscouragedApi")
187221
private fun resolveResource(context: Context, resourcePath: String): Int {
188222
val pathTokens = resourcePath.split(PACKAGE_DELIMITER)
189223
var packageName = context.packageName
@@ -203,6 +237,9 @@ public object ColorPropConverter {
203237
return ResourcesCompat.getColor(context.resources, resourceId, context.theme)
204238
}
205239

240+
// getIdentifier is required here: the theme attribute name arrives dynamically
241+
// from JS at runtime, so a compile-time R.attr reference is not possible.
242+
@SuppressLint("DiscouragedApi")
206243
@Throws(Resources.NotFoundException::class)
207244
private fun resolveThemeAttribute(context: Context, resourcePath: String): Int {
208245
val path = resourcePath.replace(ATTR_SEGMENT, "")
@@ -231,4 +268,105 @@ public object ColorPropConverter {
231268

232269
throw Resources.NotFoundException()
233270
}
271+
272+
// Lazy fallback: a raw color string carried alongside `resource_paths` that is
273+
// parsed only when every resource path fails to resolve, so an unresolved token
274+
// degrades to a developer-provided color instead of crashing.
275+
private fun parseFallbackColor(value: ReadableMap): Int? {
276+
if (!value.hasKey(FALLBACK_KEY)) {
277+
return null
278+
}
279+
val fallback = value.getString(FALLBACK_KEY)
280+
if (fallback.isNullOrEmpty()) {
281+
return null
282+
}
283+
return parseColorString(fallback)
284+
}
285+
286+
private fun parseColorString(raw: String): Int? {
287+
// CSS color syntax is case-insensitive, so normalize before dispatching:
288+
// otherwise `RGB(...)`, `#ABC`, or `RED` would fall through to null here while
289+
// the shared CSS parser on the Fabric path accepts them, diverging by
290+
// platform.
291+
val color = raw.trim().lowercase()
292+
if (color.isEmpty()) {
293+
return null
294+
}
295+
return when {
296+
color.startsWith("rgb(") || color.startsWith("rgba(") -> parseRgbaColor(color)
297+
// Hex is parsed manually so 8-digit hex is read as #RRGGBBAA (alpha last,
298+
// matching CSS and iOS). Color.parseColor would treat 8-digit hex as
299+
// #AARRGGBB (alpha first), disagreeing with the other platforms for the
300+
// same fallback string.
301+
color.startsWith("#") -> parseHexColor(color)
302+
// Named colors (e.g. "red") are resolved natively, but only for purely
303+
// alphabetic input. Gating on that keeps unsupported CSS forms (hsl() /
304+
// hsla()) and other arbitrary strings from reaching toColorInt. An unknown
305+
// name (e.g. a typo) still fails to parse; return null quietly rather than
306+
// logging a warning on every resolve.
307+
color.all { it.isLetter() } ->
308+
try {
309+
color.toColorInt()
310+
} catch (e: IllegalArgumentException) {
311+
null
312+
}
313+
else -> null
314+
}
315+
}
316+
317+
private fun parseHexColor(color: String): Int? {
318+
val hex = color.substring(1)
319+
if (hex.isEmpty() || hex.any { Character.digit(it, 16) < 0 }) {
320+
return null
321+
}
322+
// Parse via substring rather than per-index access: indexing a String
323+
// compiles to charAt, which lint flags as UTF-16-unsafe. It is harmless for
324+
// ASCII hex, but substring keeps the code lint-clean and just as clear.
325+
// nibble() expands one hex digit (0xN -> 0xNN); byte() reads two.
326+
fun nibble(i: Int): Int = hex.substring(i, i + 1).toInt(16) * 17
327+
fun byte(i: Int): Int = hex.substring(i, i + 2).toInt(16)
328+
return when (hex.length) {
329+
// #RGB — expand each nibble.
330+
3 -> Color.argb(255, nibble(0), nibble(1), nibble(2))
331+
// #RGBA — expand each nibble; alpha is the last nibble.
332+
4 -> Color.argb(nibble(3), nibble(0), nibble(1), nibble(2))
333+
6 -> Color.argb(255, byte(0), byte(2), byte(4))
334+
// #RRGGBBAA — alpha is the LAST byte.
335+
8 -> Color.argb(byte(6), byte(0), byte(2), byte(4))
336+
else -> null
337+
}
338+
}
339+
340+
private fun parseRgbaColor(raw: String): Int? {
341+
// `rgb(...)` takes exactly 3 components; `rgba(...)` takes exactly 4. Enforce
342+
// this so lenient inputs (e.g. `rgb(r,g,b,a)` or `rgba(r,g,b)`) are rejected,
343+
// matching the CSS parser used on the iOS Fabric path.
344+
val hasAlphaFn = raw.startsWith("rgba(")
345+
// Require the closing paren: substringBefore(')') would otherwise silently
346+
// accept an unterminated input like `rgb(1,2,3`, disagreeing with the shared
347+
// CSS parser used on the Fabric path.
348+
if (!raw.endsWith(")")) {
349+
return null
350+
}
351+
val inner = raw.substringAfter('(').substringBefore(')')
352+
val parts = inner.split(',').map { it.trim() }
353+
if (parts.size != (if (hasAlphaFn) 4 else 3)) {
354+
return null
355+
}
356+
val r = parts[0].toIntOrNull() ?: return null
357+
val g = parts[1].toIntOrNull() ?: return null
358+
val b = parts[2].toIntOrNull() ?: return null
359+
val a =
360+
if (hasAlphaFn) {
361+
(parts[3].toFloatOrNull() ?: return null).times(255).roundToInt()
362+
} else {
363+
255
364+
}
365+
return Color.argb(
366+
a.coerceIn(0, 255),
367+
r.coerceIn(0, 255),
368+
g.coerceIn(0, 255),
369+
b.coerceIn(0, 255),
370+
)
371+
}
234372
}

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -565,12 +565,12 @@ private NativeArray measureLines(
565565
mTextEffectRegistry);
566566
}
567567

568-
public int getColor(int surfaceId, String[] resourcePaths) {
568+
public @Nullable Integer getColor(int surfaceId, String[] resourcePaths) {
569569
ThemedReactContext context =
570570
mMountingManager.getSurfaceManagerEnforced(surfaceId, "getColor").getContext();
571571
// Surface may have been stopped
572572
if (context == null) {
573-
return 0;
573+
return null;
574574
}
575575

576576
for (String resourcePath : resourcePaths) {
@@ -579,7 +579,10 @@ public int getColor(int surfaceId, String[] resourcePaths) {
579579
return color;
580580
}
581581
}
582-
return 0;
582+
// No resource path resolved: return null (an explicit miss) rather than 0, so
583+
// the native caller can distinguish an unresolved color from one that resolves
584+
// to transparent black (ARGB 0).
585+
return null;
583586
}
584587

585588
/**

packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,10 @@ inline static void updateNativeDrawableProp(
379379
}
380380
folly::dynamic platformColorMap = folly::dynamic::object();
381381
platformColorMap["resource_paths"] = resourcePaths;
382+
if (nativeDrawableValue.ripple.colorFallback.has_value()) {
383+
platformColorMap["fallback"] =
384+
nativeDrawableValue.ripple.colorFallback.value();
385+
}
382386
nativeDrawableResult["color"] = platformColorMap;
383387
} else {
384388
nativeDrawableResult["color"] =

packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/NativeDrawable.h

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,21 @@ struct NativeDrawable {
2525
struct Ripple {
2626
std::optional<SharedColor> color{};
2727
std::optional<std::vector<std::string>> colorResourcePaths{};
28+
std::optional<std::string> colorFallback{};
2829
std::optional<Float> rippleRadius{};
2930
bool borderless{false};
3031
std::optional<Float> alpha{};
3132

3233
bool operator==(const Ripple &rhs) const
3334
{
34-
return std::tie(this->color, this->colorResourcePaths, this->borderless, this->rippleRadius, this->alpha) ==
35-
std::tie(rhs.color, rhs.colorResourcePaths, rhs.borderless, rhs.rippleRadius, rhs.alpha);
35+
return std::tie(
36+
this->color,
37+
this->colorResourcePaths,
38+
this->colorFallback,
39+
this->borderless,
40+
this->rippleRadius,
41+
this->alpha) ==
42+
std::tie(rhs.color, rhs.colorResourcePaths, rhs.colorFallback, rhs.borderless, rhs.rippleRadius, rhs.alpha);
3643
}
3744
};
3845

@@ -87,14 +94,25 @@ static inline void fromRawValue(const PropsParserContext &context, const RawValu
8794

8895
std::optional<SharedColor> parsedColor{};
8996
std::optional<std::vector<std::string>> parsedColorResourcePaths{};
97+
std::optional<std::string> parsedColorFallback{};
9098
if (color != map.end()) {
91-
if (color->second.hasType<std::unordered_map<std::string, std::vector<std::string>>>()) {
92-
auto colorMap = (std::unordered_map<std::string, std::vector<std::string>>)color->second;
99+
// The color object mixes an array (`resource_paths`) with an optional
100+
// string (`fallback`), so read it as a map of RawValue rather than a map
101+
// of vector<string> (which would assert on the string value).
102+
bool handledAsResourcePaths = false;
103+
if (color->second.hasType<std::unordered_map<std::string, RawValue>>()) {
104+
auto colorMap = (std::unordered_map<std::string, RawValue>)color->second;
93105
auto pathsIt = colorMap.find("resource_paths");
94-
if (pathsIt != colorMap.end()) {
95-
parsedColorResourcePaths = pathsIt->second;
106+
if (pathsIt != colorMap.end() && pathsIt->second.hasType<std::vector<std::string>>()) {
107+
parsedColorResourcePaths = (std::vector<std::string>)pathsIt->second;
108+
auto fallbackIt = colorMap.find("fallback");
109+
if (fallbackIt != colorMap.end() && fallbackIt->second.hasType<std::string>()) {
110+
parsedColorFallback = (std::string)fallbackIt->second;
111+
}
112+
handledAsResourcePaths = true;
96113
}
97-
} else {
114+
}
115+
if (!handledAsResourcePaths) {
98116
SharedColor resolved;
99117
fromRawValue(context, color->second, resolved);
100118
if (resolved) {
@@ -114,6 +132,7 @@ static inline void fromRawValue(const PropsParserContext &context, const RawValu
114132
NativeDrawable::Ripple{
115133
.color = parsedColor,
116134
.colorResourcePaths = parsedColorResourcePaths,
135+
.colorFallback = parsedColorFallback,
117136
.rippleRadius = rippleRadius != map.end() && rippleRadius->second.hasType<Float>()
118137
? (Float)rippleRadius->second
119138
: std::optional<Float>{},

0 commit comments

Comments
 (0)