From 23a47b4eac6e2f573178be86ca64c322afed88ca Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Mon, 27 Jul 2026 13:13:36 -0700 Subject: [PATCH 1/4] PlatformColor lazy fallback: honor a raw-color fallback on iOS and macOS (native) (#57556) Summary: An implementation for the RFC in https://github.com/react-native-community/discussions-and-proposals/pull/1008 This is the first diff in a stack that adds an optional trailing `{fallback: ''}` argument to `PlatformColor(...)`. The fallback is carried lazily to native and parsed only when every supplied color token fails to resolve. This diff wires up the iOS and macOS native color resolvers to honor that fallback; a later diff adds the JS argument that emits it, so on its own this change is a no-op for existing call sites. The fallback is supported on the new architecture (Fabric) only. To distinguish a genuine miss (no token resolves) from a token that legitimately resolves to a transparent color, the semantic-color resolver gains an `OrNil` variant: - `RCTPlatformColorFromSemanticItemsOrNil` returns `nil` on a miss instead of collapsing to `clearColor`. `RCTPlatformColorFromSemanticItems` keeps its existing non-null contract by wrapping the `OrNil` variant. - `Color::createSemanticColor` returns the undefined-color sentinel (a null underlying `UIColor`) on a miss, so `PlatformColorParser.mm` applies the fallback only on a true miss and otherwise renders transparent exactly as before. - The fallback string is parsed with the shared CSS color parser (`parseCSSProperty`), so `transparent`/`rgba(0,0,0,0)` are honored rather than mistaken for a parse failure. This spans the `xplat` iOS and macOS graphics resolvers. Changelog: [Internal] - PlatformColor: iOS/macOS native support for a lazy raw-color fallback Reviewed By: javache Differential Revision: D111837102 --- .../renderer/graphics/HostPlatformColor.h | 3 ++ .../renderer/graphics/HostPlatformColor.mm | 7 +++- .../renderer/graphics/PlatformColorParser.mm | 32 ++++++++++++++++++- .../renderer/graphics/RCTPlatformColorUtils.h | 9 ++++++ .../graphics/RCTPlatformColorUtils.mm | 12 +++++-- 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.h b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.h index b9535f14e363..a7dd98139991 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.h @@ -28,6 +28,9 @@ struct Color { int32_t getColor() const; std::size_t getUIColorHash() const; + // Returns the UndefinedColor sentinel (null underlying UIColor) on a miss, so + // callers can tell a miss from a name that resolves to transparent. Callers + // reaching into getUIColor() must null-check it. static Color createSemanticColor(std::vector &semanticItems); std::shared_ptr getUIColor() const diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm index 93ecb7e33190..e47deaa95cf6 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm @@ -237,7 +237,12 @@ int32_t ColorFromUIColor(const std::shared_ptr &uiColor) Color Color::createSemanticColor(std::vector &semanticItems) { - auto semanticColor = RCTPlatformColorFromSemanticItems(semanticItems); + UIColor *semanticColor = RCTPlatformColorFromSemanticItemsOrNil(semanticItems); + if (semanticColor == nil) { + // Undefined-color sentinel on a miss, distinct from a name that resolves to + // transparent (getColor() is still 0, preserving the old render). + return HostPlatformColor::UndefinedColor; + } return Color(wrapManagedObject(semanticColor)); } diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/PlatformColorParser.mm b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/PlatformColorParser.mm index 377783f0f195..890db576eba9 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/PlatformColorParser.mm +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/PlatformColorParser.mm @@ -8,9 +8,13 @@ #import "PlatformColorParser.h" #import +#import +#import +#import #import #import #import +#import #import #import @@ -51,13 +55,39 @@ return SharedColor(color); } +// nullopt only on a parse failure, so a fallback that resolves to transparent is +// still honored. +static std::optional fallbackColorFromString(const std::string &fallbackString) +{ + auto cssColor = parseCSSProperty(fallbackString); + if (std::holds_alternative(cssColor)) { + const auto &c = std::get(cssColor); + return colorFromRGBA(c.r, c.g, c.b, c.a); + } + return std::nullopt; +} + SharedColor parsePlatformColor(const ContextContainer &contextContainer, int32_t surfaceId, const RawValue &value) { if (value.hasType>()) { auto items = (std::unordered_map)value; if (items.find("semantic") != items.end() && items.at("semantic").hasType>()) { auto semanticItems = (std::vector)items.at("semantic"); - return SharedColor(Color::createSemanticColor(semanticItems)); + auto semanticColor = SharedColor(Color::createSemanticColor(semanticItems)); + // The sentinel (null UIColor) means a true miss; apply the fallback only + // then, not when a name resolves to transparent. + if (!semanticColor) { + if (items.find("fallback") != items.end() && items.at("fallback").hasType()) { + auto fallbackColor = fallbackColorFromString((std::string)items.at("fallback")); + // has_value(), not != 0, so a transparent fallback is kept. + if (fallbackColor.has_value()) { + return *fallbackColor; + } + } + // Miss with no usable fallback: clearColor, never leaking the sentinel. + return clearColor(); + } + return semanticColor; } else if ( items.find("dynamic") != items.end() && items.at("dynamic").hasType>()) { diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.h b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.h index f487564c043d..ad021c1e413f 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.h @@ -15,6 +15,15 @@ struct ColorComponents; struct Color; } // namespace facebook::react +NS_ASSUME_NONNULL_BEGIN + facebook::react::ColorComponents RCTPlatformColorComponentsFromSemanticItems(std::vector &semanticItems); UIColor *RCTPlatformColorFromSemanticItems(std::vector &semanticItems); +// Like RCTPlatformColorFromSemanticItems but returns nil on a miss, so callers +// can tell a miss from a name that resolves to transparent. +UIColor *_Nullable RCTPlatformColorFromSemanticItemsOrNil(std::vector &semanticItems); +// Precondition: `color` is a resolved color, never the miss sentinel, so the +// result stays _Nonnull. UIColor *RCTPlatformColorFromColor(const facebook::react::Color &color); + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.mm b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.mm index 4a0a53e2b504..882f38805672 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/RCTPlatformColorUtils.mm @@ -192,7 +192,7 @@ return _ColorComponentsFromUIColor(RCTPlatformColorFromSemanticItems(semanticItems)); } -UIColor *RCTPlatformColorFromSemanticItems(std::vector &semanticItems) +UIColor *_Nullable RCTPlatformColorFromSemanticItemsOrNil(std::vector &semanticItems) { for (const auto &semanticCString : semanticItems) { NSString *semanticNSString = _NSStringFromCString(semanticCString); @@ -206,7 +206,15 @@ } } - return UIColor.clearColor; + return nil; +} + +UIColor *RCTPlatformColorFromSemanticItems(std::vector &semanticItems) +{ + // Non-null contract (e.g. for RCTPlatformColorComponentsFromSemanticItems); + // callers needing to detect a miss use the OrNil variant. + UIColor *uiColor = RCTPlatformColorFromSemanticItemsOrNil(semanticItems); + return uiColor != nil ? uiColor : UIColor.clearColor; } UIColor *RCTPlatformColorFromColor(const facebook::react::Color &color) From 44ace625035c656db5c0b48189186fd9e826dbb6 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Mon, 27 Jul 2026 13:13:36 -0700 Subject: [PATCH 2/4] PlatformColor lazy fallback: honor a raw-color fallback on Android (native) (#57655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: An implementation for the RFC in https://github.com/react-native-community/discussions-and-proposals/pull/1008 Wires up the Android native color resolver (Fabric) 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 with the shared CSS color parser (`parseCSSProperty`), matching the iOS Fabric path. The color object is now read as a `map` because it mixes an array (`resource_paths`) with an optional string (`fallback`). - `NativeDrawable` carries an optional `colorFallback` alongside `resource_paths` so ripple drawables degrade to the fallback too. 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 Reviewed By: mdvacca, javache Differential Revision: D113329136 --- .../ReactAndroid/api/ReactAndroid.api | 2 +- .../react/fabric/FabricUIManager.java | 8 +- .../components/view/HostPlatformViewProps.cpp | 4 + .../renderer/components/view/NativeDrawable.h | 33 +++++-- .../renderer/graphics/PlatformColorParser.h | 93 +++++++++++++------ .../api-snapshots/ReactAndroidDebugCxx.api | 1 + .../api-snapshots/ReactAndroidNewarchCxx.api | 1 + .../api-snapshots/ReactAndroidReleaseCxx.api | 1 + 8 files changed, 104 insertions(+), 39 deletions(-) diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index cd91a0af37fc..f95aa27e7d05 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -2252,7 +2252,7 @@ public class com/facebook/react/fabric/FabricUIManager : com/facebook/react/brid public fun dispatchCommand (IILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V public fun dispatchCommand (ILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V public fun findNextFocusableElement (III)Ljava/lang/Integer; - public fun getColor (I[Ljava/lang/String;)I + public fun getColor (I[Ljava/lang/String;)Ljava/lang/Integer; public fun getEventDispatcher ()Lcom/facebook/react/uimanager/events/EventDispatcher; public fun getPerformanceCounters ()Ljava/util/Map; public fun getRelativeAncestorList (II)[I diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index d96a0fa1e59c..b97540334904 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -565,12 +565,12 @@ private NativeArray measureLines( mTextEffectRegistry); } - public int getColor(int surfaceId, String[] resourcePaths) { + public @Nullable Integer getColor(int surfaceId, String[] resourcePaths) { ThemedReactContext context = mMountingManager.getSurfaceManagerEnforced(surfaceId, "getColor").getContext(); // Surface may have been stopped if (context == null) { - return 0; + return null; } for (String resourcePath : resourcePaths) { @@ -579,7 +579,9 @@ public int getColor(int surfaceId, String[] resourcePaths) { return color; } } - return 0; + // Null (explicit miss), not 0, so native can tell an unresolved color from + // one that resolves to transparent black (ARGB 0). + return null; } /** diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp index 19cde3251e84..32289a2c43f1 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp @@ -379,6 +379,10 @@ inline static void updateNativeDrawableProp( } folly::dynamic platformColorMap = folly::dynamic::object(); platformColorMap["resource_paths"] = resourcePaths; + if (nativeDrawableValue.ripple.colorFallback.has_value()) { + platformColorMap["fallback"] = + nativeDrawableValue.ripple.colorFallback.value(); + } nativeDrawableResult["color"] = platformColorMap; } else { nativeDrawableResult["color"] = diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/NativeDrawable.h b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/NativeDrawable.h index 67c5a2bb27b5..4703f5fee5aa 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/NativeDrawable.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/NativeDrawable.h @@ -25,14 +25,21 @@ struct NativeDrawable { struct Ripple { std::optional color{}; std::optional> colorResourcePaths{}; + std::optional colorFallback{}; std::optional rippleRadius{}; bool borderless{false}; std::optional alpha{}; bool operator==(const Ripple &rhs) const { - return std::tie(this->color, this->colorResourcePaths, this->borderless, this->rippleRadius, this->alpha) == - std::tie(rhs.color, rhs.colorResourcePaths, rhs.borderless, rhs.rippleRadius, rhs.alpha); + return std::tie( + this->color, + this->colorResourcePaths, + this->colorFallback, + this->borderless, + this->rippleRadius, + this->alpha) == + std::tie(rhs.color, rhs.colorResourcePaths, rhs.colorFallback, rhs.borderless, rhs.rippleRadius, rhs.alpha); } }; @@ -87,14 +94,25 @@ static inline void fromRawValue(const PropsParserContext &context, const RawValu std::optional parsedColor{}; std::optional> parsedColorResourcePaths{}; + std::optional parsedColorFallback{}; if (color != map.end()) { - if (color->second.hasType>>()) { - auto colorMap = (std::unordered_map>)color->second; + // The color object mixes an array (`resource_paths`) with an optional + // string (`fallback`), so read it as a map of RawValue rather than a map + // of vector (which would assert on the string value). + bool handledAsResourcePaths = false; + if (color->second.hasType>()) { + auto colorMap = (std::unordered_map)color->second; auto pathsIt = colorMap.find("resource_paths"); - if (pathsIt != colorMap.end()) { - parsedColorResourcePaths = pathsIt->second; + if (pathsIt != colorMap.end() && pathsIt->second.hasType>()) { + parsedColorResourcePaths = (std::vector)pathsIt->second; + auto fallbackIt = colorMap.find("fallback"); + if (fallbackIt != colorMap.end() && fallbackIt->second.hasType()) { + parsedColorFallback = (std::string)fallbackIt->second; + } + handledAsResourcePaths = true; } - } else { + } + if (!handledAsResourcePaths) { SharedColor resolved; fromRawValue(context, color->second, resolved); if (resolved) { @@ -114,6 +132,7 @@ static inline void fromRawValue(const PropsParserContext &context, const RawValu NativeDrawable::Ripple{ .color = parsedColor, .colorResourcePaths = parsedColorResourcePaths, + .colorFallback = parsedColorFallback, .rippleRadius = rippleRadius != map.end() && rippleRadius->second.hasType() ? (Float)rippleRadius->second : std::optional{}, diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/android/react/renderer/graphics/PlatformColorParser.h b/packages/react-native/ReactCommon/react/renderer/graphics/platform/android/react/renderer/graphics/PlatformColorParser.h index b27e586e7909..cf5a7c2ce1f1 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/android/react/renderer/graphics/PlatformColorParser.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/android/react/renderer/graphics/PlatformColorParser.h @@ -12,11 +12,14 @@ #include #include #include +#include +#include #include #include #include #include #include +#include #include #include #include @@ -36,39 +39,73 @@ inline SharedColor parsePlatformColor(const ContextContainer &contextContainer, int32_t surfaceId, const RawValue &value) { Color color = 0; - if (value.hasType>>()) { - auto map = (std::unordered_map>)value; - auto &resourcePaths = map["resource_paths"]; + if (value.hasType>()) { + // Mixed array + string values, so read as a map of RawValue (a map of + // vector would assert on the fallback string). + auto map = (std::unordered_map)value; - // JNI calls are time consuming. Let's cache results here to avoid - // unnecessary calls. - static std::mutex getColorCacheMutex; - static folly::EvictingCacheMap getColorCache(64); + std::vector resourcePaths; + auto resourcePathsIt = map.find("resource_paths"); + if (resourcePathsIt != map.end() && resourcePathsIt->second.hasType>()) { + resourcePaths = (std::vector)resourcePathsIt->second; + } + + bool resolved = false; + if (!resourcePaths.empty()) { + // Cache the (costly) JNI results. A cached nullopt is an explicit miss, + // distinct from a path that resolves to transparent (ARGB 0). + static std::mutex getColorCacheMutex; + static folly::EvictingCacheMap> getColorCache(64); - // Listen for appearance changes, which should invalidate the cache - static std::once_flag setupCacheInvalidation; - std::call_once(setupCacheInvalidation, configurePlatformColorCacheInvalidationHook, [&] { - std::scoped_lock lock(getColorCacheMutex); - getColorCache.clear(); - }); + // Listen for appearance changes, which should invalidate the cache + static std::once_flag setupCacheInvalidation; + std::call_once(setupCacheInvalidation, configurePlatformColorCacheInvalidationHook, [&] { + std::scoped_lock lock(getColorCacheMutex); + getColorCache.clear(); + }); - auto hash = hashGetColourArguments(surfaceId, resourcePaths); - { - std::scoped_lock lock(getColorCacheMutex); - auto iterator = getColorCache.find(hash); - if (iterator != getColorCache.end()) { - color = iterator->second; - } else { - const auto &fabricUIManager = contextContainer.at>("FabricUIManager"); - static auto getColorFromJava = - fabricUIManager->getClass()->getMethod)>("getColor"); - auto javaResourcePaths = jni::JArrayClass::newArray(resourcePaths.size()); + auto hash = hashGetColourArguments(surfaceId, resourcePaths); + std::optional resolvedColor; + { + std::scoped_lock lock(getColorCacheMutex); + auto iterator = getColorCache.find(hash); + if (iterator != getColorCache.end()) { + resolvedColor = iterator->second; + } else { + const auto &fabricUIManager = contextContainer.at>("FabricUIManager"); + // Boxed Integer: null is an explicit miss; a non-null value may be 0 + // (transparent black). + static auto getColorFromJava = + fabricUIManager->getClass()->getMethod)>( + "getColor"); + auto javaResourcePaths = jni::JArrayClass::newArray(resourcePaths.size()); + + for (int i = 0; i < resourcePaths.size(); i++) { + javaResourcePaths->setElement(i, *jni::make_jstring(resourcePaths[i])); + } + auto boxedColor = getColorFromJava(fabricUIManager, surfaceId, *javaResourcePaths); + if (boxedColor) { + resolvedColor = static_cast(boxedColor->value()); + } + getColorCache.set(hash, resolvedColor); + } + } + if (resolvedColor.has_value()) { + color = *resolvedColor; + resolved = true; + } + } - for (int i = 0; i < resourcePaths.size(); i++) { - javaResourcePaths->setElement(i, *jni::make_jstring(resourcePaths[i])); + // No path resolved: parse the raw fallback with the shared CSS parser (the + // same parser iOS Fabric uses). + if (!resolved) { + auto fallbackIt = map.find("fallback"); + if (fallbackIt != map.end() && fallbackIt->second.hasType()) { + auto cssColor = parseCSSProperty((std::string)fallbackIt->second); + if (std::holds_alternative(cssColor)) { + const auto &c = std::get(cssColor); + color = hostPlatformColorFromRGBA(c.r, c.g, c.b, c.a); } - color = getColorFromJava(fabricUIManager, surfaceId, *javaResourcePaths); - getColorCache.set(hash, color); } } } diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index 9686cd7e5d1b..2f1ede61e222 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -7562,6 +7562,7 @@ struct facebook::react::NativeDrawable::Ripple { public std::optional alpha; public std::optional rippleRadius; public std::optional color; + public std::optional colorFallback; public std::optional> colorResourcePaths; } diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index 9e4a3fa333a9..e2e2f1d000c3 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -7323,6 +7323,7 @@ struct facebook::react::NativeDrawable::Ripple { public std::optional alpha; public std::optional rippleRadius; public std::optional color; + public std::optional colorFallback; public std::optional> colorResourcePaths; } diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index 46adc5077283..d584b9cd4c2b 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -7553,6 +7553,7 @@ struct facebook::react::NativeDrawable::Ripple { public std::optional alpha; public std::optional rippleRadius; public std::optional color; + public std::optional colorFallback; public std::optional> colorResourcePaths; } From 1bf93e883965ca557a84fe5bea6e233acfbb63ca Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Mon, 27 Jul 2026 13:13:36 -0700 Subject: [PATCH 3/4] Support a lazy raw color fallback for PlatformColor (#57701) Summary: An implementation for the RFC in https://github.com/react-native-community/discussions-and-proposals/pull/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: ''}` argument to `PlatformColor(...)` and updates the JS entry points to emit it. The fallback is carried lazily to native as a raw, unprocessed string and is only parsed when every provided token fails to resolve (the native side of that behavior landed earlier in this stack). 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'}) Each platform entry point (`PlatformColorValueTypes.{android,ios,macos,windows}.js`, plus the vendored macOS/Windows copies) collects the leading string tokens and, when a trailing `{fallback}` object is present, attaches its raw string to the native color object. Non-string arguments in a non-trailing position are filtered out consistently rather than being forwarded to native. The Flow (`.js.flow`), TypeScript (`.d.ts`), and generated (`ReactNativeApi.d.ts`) declarations widen the signature to `Array`, which is backward compatible. Changelog: [General][Added] - Support a lazy raw color fallback for PlatformColor when native tokens fail to resolve Differential Revision: D113329139 --- .../PlatformColorValueTypes.android.js | 16 +++++- .../StyleSheet/PlatformColorValueTypes.d.ts | 4 +- .../StyleSheet/PlatformColorValueTypes.ios.js | 14 ++++- .../PlatformColorValueTypes.js.flow | 2 +- .../__tests__/processColor-itest.js | 27 +++++++++ .../StyleSheet/parsePlatformColorArgs.js | 55 +++++++++++++++++++ packages/react-native/ReactNativeApi.d.ts | 13 ++++- 7 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 packages/react-native/Libraries/StyleSheet/parsePlatformColorArgs.js diff --git a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.android.js b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.android.js index 2647776e3549..08a3b86bb089 100644 --- a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.android.js +++ b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.android.js @@ -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, + fallback?: string, }; -export const PlatformColor = (...names: Array): NativeColorValue => { +export const PlatformColor = ( + ...args: Array +): 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 = ( diff --git a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.d.ts b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.d.ts index 909f73d596e7..02be4f88770a 100644 --- a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.d.ts +++ b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.d.ts @@ -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 +): OpaqueColorValue; diff --git a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.ios.js b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.ios.js index 50af1b60828c..c008435227ad 100644 --- a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.ios.js +++ b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.ios.js @@ -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, + fallback?: string, dynamic?: { light: ?(ColorValue | ProcessedColorValue), dark: ?(ColorValue | ProcessedColorValue), @@ -22,9 +25,16 @@ type LocalNativeColorValue = { }, }; -export const PlatformColor = (...names: Array): NativeColorValue => { +export const PlatformColor = ( + ...args: Array +): 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 = { diff --git a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.js.flow b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.js.flow index e76df70da962..94c595e6a8b7 100644 --- a/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.js.flow +++ b/packages/react-native/Libraries/StyleSheet/PlatformColorValueTypes.js.flow @@ -18,7 +18,7 @@ import type {NativeColorValue} from './StyleSheet'; * @see https://reactnative.dev/docs/platformcolor#example */ declare export function PlatformColor( - ...names: Array + ...names: Array ): NativeColorValue; declare export function normalizeColorObject( diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processColor-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processColor-itest.js index 5084f8787785..2054ef112a21 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/processColor-itest.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processColor-itest.js @@ -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); + }); } }); @@ -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); + }); } }); }); diff --git a/packages/react-native/Libraries/StyleSheet/parsePlatformColorArgs.js b/packages/react-native/Libraries/StyleSheet/parsePlatformColorArgs.js new file mode 100644 index 000000000000..5dcfb6d754d4 --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/parsePlatformColorArgs.js @@ -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, +): {names: Array, 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 = []; + 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}; +} diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 24f632825a84..556c8117198d 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<53280bff7703ea0e9e912e22543fd568>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -3491,7 +3491,14 @@ declare class PixelRatio { static startDetecting(): void } declare type Platform = typeof Platform -declare function PlatformColor(...names: Array): NativeColorValue +declare function PlatformColor( + ...names: Array< + | string + | { + fallback: string + } + > +): NativeColorValue declare type PlatformConfig = {} declare type PlatformOSType = "android" | "ios" | "macos" | "native" | "web" | "windows" @@ -5866,7 +5873,7 @@ export { PermissionsAndroid, // 8a0bc8d8 PixelRatio, // 10d9e32d Platform, // b73caa89 - PlatformColor, // 8297ec62 + PlatformColor, // d083d341 PlatformOSType, // 0a17561e PlatformSelectSpec, // 09ed7758 PointValue, // 69db075f From a86a895808220369c40529c46172aceb3286a9b0 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Mon, 27 Jul 2026 13:13:36 -0700 Subject: [PATCH 4/4] PlatformColor lazy fallback: lint rule + RNTester example (#57705) Summary: 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: }` 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 Differential Revision: D113329138 --- .../__tests__/platform-colors-test.js | 26 +++ .../platform-colors.js | 27 ++- .../PlatformColor/PlatformColorExample.js | 161 ++++++++++++++++++ 3 files changed, 213 insertions(+), 1 deletion(-) diff --git a/packages/eslint-plugin-react-native/__tests__/platform-colors-test.js b/packages/eslint-plugin-react-native/__tests__/platform-colors-test.js index ea27e59e0b49..cbdc666c936b 100644 --- a/packages/eslint-plugin-react-native/__tests__/platform-colors-test.js +++ b/packages/eslint-plugin-react-native/__tests__/platform-colors-test.js @@ -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,30 @@ 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 color = PlatformColor({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}], diff --git a/packages/eslint-plugin-react-native/platform-colors.js b/packages/eslint-plugin-react-native/platform-colors.js index 2de452899cd6..597d2045d199 100644 --- a/packages/eslint-plugin-react-native/platform-colors.js +++ b/packages/eslint-plugin-react-native/platform-colors.js @@ -33,6 +33,21 @@ module.exports = { CallExpression: function (node) { if (node.callee.name === 'PlatformColor') { const args = node.arguments; + // Optional trailing {fallback: }: 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,17 @@ module.exports = { }); return; } - if (!args.every(arg => arg.type === 'Literal')) { + if ( + !args.every( + (arg, index) => + arg.type === 'Literal' || + // The trailing {fallback: ...} object is only allowed after at + // least one literal color-token argument (index > 0). + (index > 0 && + index === args.length - 1 && + isFallbackObject(arg)), + ) + ) { context.report({ node, messageId: 'platformColorArgTypes', diff --git a/packages/rn-tester/js/examples/PlatformColor/PlatformColorExample.js b/packages/rn-tester/js/examples/PlatformColor/PlatformColorExample.js index bdebddb14c4f..1465983c994a 100644 --- a/packages/rn-tester/js/examples/PlatformColor/PlatformColorExample.js +++ b/packages/rn-tester/js/examples/PlatformColor/PlatformColorExample.js @@ -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 ( + + + + Valid token '{validToken}' (shows the system color) + + + + + + Invalid token, NO fallback (miss → transparent, outlined below) + + + + + + Invalid token + fallback '#FF0000' → RED (backgroundColor) + + + + + + Invalid token + fallback '#FFFF00' → YELLOW (backgroundColor) + + + + + + Invalid token + fallback '#00FF00' → GREEN (text color) + + + + GREEN + + + + + + Invalid token + fallback '#0000FF' → BLUE (borderColor) + + + + + 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. + + + + fallback 'rgb(255, 0, 128)' → PINK (backgroundColor) + + + + + + fallback 'rgba(0, 128, 255, 0.7)' → semi-transparent BLUE + + + + {/* + 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. + */} + + + fallback '#FF000080' (#RRGGBBAA) → 50% transparent RED + + + + + ); +} + function DynamicColorsExample() { return Platform.OS === 'ios' ? ( @@ -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 ; }, }, + { + title: 'Lazy Fallback Colors', + render(): React.MixedElement { + return ; + }, + }, { title: 'iOS Dynamic Colors', render(): React.MixedElement {