Skip to content

fix(native): seed a concrete scheme-aware __rn-css-color on every platform - #392

Open
YevheniiKotyrlo wants to merge 3 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/root-color-seed
Open

fix(native): seed a concrete scheme-aware __rn-css-color on every platform#392
YevheniiKotyrlo wants to merge 3 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/root-color-seed

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Every color that resolves through the root __rn-css-color fallback fails to paint, on both platforms, by two different routes into the same shape of defect: the seed is a value the platform accepts at the prop and then does not resolve where it matters.

On Android it is total — default ring-* / inset-ring-* (Tailwind v4's default ring color is currentcolor), text-current and bg-current are all invisible.

On iOS it is confined to the props React Native never resolves against a trait collection — border, outline and shadow. Reported on an iPhone: a border-current box painted no border while a literal-colored border beside it painted normally.

The root seed in src/native-internal/root.ts:

rootVariables("__rn-css-color").set([[
  Platform.OS === "ios"
    ? PlatformColor("label", "labelColor")
    : PlatformColor("?attr/textColorPrimary", "SystemBaseHighColor"),
]] as any);

On Android PlatformColor('?attr/textColorPrimary') resolves through React Native's ColorPropConverter.resolveThemeAttribute, which returns outValue.data without inspecting outValue.type:

if (theme.resolveAttribute(resourceId, outValue, true)) {
  return outValue.data
}

?attr/textColorPrimary is a ColorStateList on AppCompat / Material themes, so Theme.resolveAttribute fills outValue with a resource reference (TYPE_REFERENCE), not a packed color — and outValue.data is then that reference id, used as if it were an ARGB int. The paint is a garbage, effectively-invisible color. The fallback 'SystemBaseHighColor' has no @ / ? prefix, so it isn't a valid resource path and is silently skipped. Net effect on device: no exception, no paint — verified on an Android emulator, where re-seeding __rn-css-color with #FF00FF made the rings and text-current render magenta, proving the resolution machinery is correct and only the seed value was wrong.

Fix

Seed a concrete color on every platform, made scheme-aware through the root observable's existing prefers-color-scheme evaluation — no new Appearance listener, no Platform.OS branch, and it drops the as any / as unknown as StyleDescriptor casts and two eslint suppressions:

rootVariables("__rn-css-color").set([
  ["#FFFFFF", [["=", "prefers-color-scheme", "dark"]]],
  ["#000000"],
]);

Why iOS needs it too

PlatformColor('label') genuinely does track the system appearance — but only where the platform resolves it. A semantic color is a dynamic UIColor, which has to be resolved against a trait collection before it can become a CGColor. In RCTViewComponentView.mm that happens in exactly one place, the background:

UIColor *backgroundColor = [_backgroundColor resolvedColorWithTraitCollection:self.traitCollection];

resolvedColorWithTraitCollection appears once in that file. Border, outline and shadow take RCTUIColorFromSharedColor(...) and read .CGColor directly. facebook/react-native#57836 tracks the consequence — "Dynamic borderColor/outlineColor (PlatformColor/DynamicColorIOS) resolve against the system appearance, ignoring overrideUserInterfaceStyle" — which paints a variant that is indistinguishable from no border when it matches what is behind it.

So both platforms hand this seed to a path that fails silently: Android to an unchecked resource reference, iOS to an unresolved dynamic color. Neither raises. A fallback is the one value that must not depend on the platform getting a dynamic color right, because when it is wrong nothing reports it — which is the argument for making it concrete rather than for making it clever.

What is measured and what is not, stated plainly: the RN source asymmetry above is verifiable by reading the file, #57836 is open upstream, and the device report is a real iPhone. What I have not done is attach a trait-collection probe to that device to prove #57836 is the precise mechanism rather than a sibling of it — this is a Windows host with no iOS toolchain. The change stands on the fallback argument, which does not depend on that diagnosis.

The root variable is only the ultimate fallback (any ancestor-published or themed --__rn-css-color overrides it), so a binary black/white default is spec-faithful — CSS's initial color is effectively canvastext.

Why the media-query form: the root observable already evaluates each seeded value's media query against the reactive colorScheme observable (native/conditions/media-query.ts), so [["#FFFFFF", darkMQ], ["#000000"]] is scheme-reactive for free — the same mechanism light-dark() and prefers-color-scheme rules use — rather than a second imperative Appearance subscription.

Test plan

src/__tests__/native/root-color-seed-android.test.tsx (Platform.OS mocked to android — the seed is a module-load side effect and jest-expo defaults to ios):

  • currentcolor with no ancestor → #000000 (light) / #FFFFFF (dark).
  • Live reactivity: act(() => colorScheme.set("dark")) re-resolves the same element.
  • A default ring and inset-ring (box-shadow currentcolor) paint with the seed color — the reported symptom.
  • No color-scheme preference (null) falls back to the light default.
  • An ancestor-published color still overrides the seed (it is only the ultimate fallback).

src/__tests__/native/root-color-seed.test.ios.tsx (jest-expo runs Platform.OS === "ios" with no mock, so this is the seed as iOS loads it):

  • The seed resolves to the same concrete color every other platform gets, and follows the scheme in both directions.
  • An ancestor-published color still overrides it.

This file previously asserted that { semantic: ["label", "labelColor"] } reached props.style. That pins arrival, not paint, and cannot tell the two apart — it is the same assertion shape the Android sibling carried before its fix, on the build that painted no ring on a device. It asserts the resolved color now, and pins the scheme, because the iOS arm has a scheme dependence for the first time: PlatformColor used to absorb that and the observable answers it now.

Four other suites pinned the same descriptor for currentcolor consumers — filters (drop-shadow), text-shadow, and Tailwind's caret-current and text-current — and now assert the resolved color. That set is the blast radius of the change, and it is worth noting what it says: every one of them is a currentcolor reader that was pinning the unresolved descriptor.

src/__tests__/native/root-color-seed-override.test.tsx:

  • A stylesheet :root { color } replaces the seed outright, and a scheme-conditioned one overrides it per scheme. This is what makes a hardcoded black/white defensible, and the other files only ever tested an ancestor element. Its own file because inject replaces a root variable outright and nothing puts it back between tests, so a :root { color } leaks into every later test in the same file.

src/__tests__/vendor/tailwind/ring-color-seed.test.tsx (real Tailwind → the runtime, android):

  • ring-2 / ring — whose default color is currentcolor — paint the concrete seed (#000000 light, #FFFFFF dark); an explicit ring-red-500 still wins. This exercises the reported symptom through actual Tailwind output, not a hand-written box-shadow.

Verified red→green: pre-fix, the Android tests resolve to { semantic: ["?attr/textColorPrimary", "SystemBaseHighColor"] }; post-fix, concrete colors.

To be precise about what that proves and what it does not: jest-expo's haste defaultPlatform is ios, so PlatformColorValueTypes always resolves its .ios.js variant even with Platform.OS mocked to android. The tests therefore observe the unresolved descriptor rather than Android's {resource_paths: […]} shape, and they are guards on the seed value rather than on the native resolution. The native side is what the emulator run above covers.

Why black and white, rather than another theme attribute

The failure is specific to the ?attr/ path returning outValue.data unchecked. @-prefixed resources go through ResourcesCompat.getColor, which flattens a ColorStateList correctly, and a theme attribute whose value is a plain <color> resolves to TYPE_INT_COLOR_* where data really is ARGB. So ?attr/colorForeground — which points at foreground_material_light|dark in AppCompat and Material — would plausibly work and would keep the platform's real foreground across DayNight and custom brand themes, instead of collapsing every theme to pure black or white.

I chose the concrete pair because it is what CSS Color 4 specifies for the initial color (CanvasText), because prefers-color-scheme is the only signal the runtime has, and because it matches how light-dark() already compiles in this repo. But if you would rather keep a platform attribute here, colorForeground is the candidate and I am happy to switch — it needs a device check I have not run.

yarn test1063 passed, 21 skipped; yarn typecheck and yarn lint clean. (Three cases in two babel/ import-plugin suites fail only on Windows — a pre-existing path-separator bug in the plugin, unrelated to this change; they are green on CI.)

Follow-up (separate, facebook/react-native)

The deeper RN-layer bug — ColorPropConverter.resolveThemeAttribute returning outValue.data unchecked for ColorStateList attributes (a reference, not a color) — affects any PlatformColor('?attr/<ColorStateList>') usage and is worth a separate issue in react-native itself.

References

  • React Native — ColorPropConverter.resolveThemeAttribute returns outValue.data without checking outValue.type (Kotlin source, commit-pinned) · PlatformColor
  • Android — ?attr/textColorPrimary is a themed ColorStateList in AppCompat / Material components, which is why the returned outValue.data is a reference, not a color.
  • react-native-css — the reactive mechanism reused: the root observable evaluates each seeded value's media query against the colorScheme observable (native/conditions/media-query.tsprefers-color-schemeget(colorScheme)), so a media-query-conditioned seed is scheme-reactive with no extra listener.

Overlaps with #410

Both edit the same twelve lines of src/native-internal/root.ts — this one changes the seed, #410 puts the registries behind one globalThis key. They are independent defects with different blast radii, so I have kept them apart; whichever lands first, I will rebase the other onto the composed body.

One thing worth knowing before both land, because neither branch's tests can see it. observable.set() evaluates its read function immediately, so a media-conditioned seed calls testMediaQueryget(colorScheme) during root.ts's module body. On main that never happens, because the seed carries no media query. Under #410 the registry becomes process-global while colorScheme in src/native/reactivity.ts does not, so the shared seed binds to whichever module copy evaluated first. Measured with jest.resetModules(): the composed pair answers #000000 after a second copy sets dark, where this branch alone answers #FFFFFF.

That is #410's class of bug rather than this one's — any @media (prefers-color-scheme: dark) { :root { --x } } in user CSS already hits it under #410 — but this change makes it reachable in every Android app rather than only in stylesheets that happen to use a conditioned root variable. The real fix is to guard reactivity.ts's process-global observables the way style-collection.ts and variables.tsx already are. Happy to send that first if you would prefer it ahead of either.

On Android the root `__rn-css-color` fallback was
PlatformColor('?attr/textColorPrimary'), which resolves to a ColorStateList;
RN's ColorPropConverter returns the resource *reference* rather than an ARGB
int, so it silently never paints. Every color resolving through the root
fallback -- default ring-* / inset-ring-* (Tailwind's default ring color is
currentcolor), text-current, bg-current -- was invisible on Android. iOS's
PlatformColor('label') is fine.

Seed a concrete color on Android instead, made scheme-aware through the root
observable's existing prefers-color-scheme evaluation (no extra Appearance
listener). The root variable is only the ultimate fallback -- any
ancestor-published or themed --__rn-css-color overrides it -- so a binary
black/white default is spec-faithful.

Splitting the platforms also drops the file's `as any` and two eslint
suppressions: the Android media-query seed types cleanly, and the iOS
PlatformColor uses an isolated `as unknown as StyleDescriptor` bridge.

Adds android + ios tests (currentcolor, live scheme reactivity, a default ring,
ancestor override) that resolve the ColorStateList reference before the fix and
concrete colors after.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review July 26, 2026 14:52
…he platform-suffix convention

The seed hardcodes black and white, and the only thing that makes that defensible
is that an app which themes its own text colour wins. The suite proved that for an
ancestor element and never for a stylesheet `:root` rule, which is how an app
actually does it.

The two new cases live in their own file deliberately: `inject` replaces a root
variable outright and nothing puts it back, so a `:root { color }` in the existing
file leaks into every test after it.

`root-color-seed-ios.test.tsx` becomes `root-color-seed.test.ios.tsx`, matching
env / styled / text-shadow.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The swatch is invisible or black — the seed is a PlatformColor Android cannot resolve to a value.

FIXED — The currentColor swatch matches the text beside it, in both light and dark.

before — stock 3.0.7 after — with this PR

Both frames come from the same device in the same run (Android 36 emulator, 1140×2400 @ 480dpi), differing only in whether this PR is applied.

Each frame carries a build-probe width=<dp> line — a rem-derived box that resolves differently on a patched build. The capture harness reads it off the device and refuses to save a frame whose probe disagrees with the variant it claims, so a before image cannot silently be a second after.

The root `__rn-css-color` seed is the ultimate fallback behind every
`currentcolor` and `color: inherit` that reaches the root with nothing
published above it. It feeds `color`, `border-color`, `outline-color`, ring
and shadow alike.

Android already seeded a concrete scheme-aware colour here, because
`PlatformColor('?attr/textColorPrimary')` resolves to a ColorStateList and
`ColorPropConverter` hands back the resource reference rather than an ARGB
int, so default `ring-*` / `inset-ring-*` / `text-current` painted nothing.

iOS kept `PlatformColor('label')` on the reasoning that it already tracks the
system appearance. It does — but only where the platform resolves it. A
semantic colour is a dynamic `UIColor`, which has to be resolved against a
trait collection before it can become a `CGColor`, and
`RCTViewComponentView.mm` does that in exactly one place: the background.
Border, outline and shadow take `RCTUIColorFromSharedColor(...).CGColor`
directly. react/react-native#57836 tracks the consequence — a dynamic
`borderColor` / `outlineColor` resolves against the system appearance and
ignores `overrideUserInterfaceStyle`, painting a variant that is
indistinguishable from no border when it matches what is behind it. Reported
on an iPhone: `border-current` painted nothing while a literal border beside
it painted normally.

So both platforms had the same shape of defect — a value that reaches the
prop and then fails silently somewhere the paint path does not resolve it.
Neither raises an error; both fall back to transparent or to the wrong
variant. A fallback is the one value that must not depend on the platform
getting a dynamic colour right, because when it is wrong nothing reports it.
One concrete scheme-aware colour on every platform removes that class of
failure from the fallback entirely, and the `Platform.OS` branch with it.

The iOS test is rewritten rather than dropped. It asserted that
`{semantic: ["label","labelColor"]}` reached `props.style`, which pins arrival
and not paint — the same shape the Android sibling asserted before its fix, on
the build that painted no ring on a device. It now asserts the resolved colour
and drives both schemes, because the iOS arm has a scheme dependence for the
first time: `PlatformColor` used to absorb that and the observable answers it
now.

Four other suites pinned the same descriptor for `currentcolor` consumers —
drop-shadow, text-shadow, caret and text colour — and now assert the resolved
colour.

Suite: 1063 passed, 21 skipped. The three failures are the pre-existing
Windows-only relative-import cases in the babel tests, unchanged by this.
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(native): seed a concrete scheme-aware Android __rn-css-color fix(native): seed a concrete scheme-aware __rn-css-color on every platform Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant