Skip to content

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

Draft
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/root-color-seed
Draft

fix(native): seed a concrete scheme-aware Android __rn-css-color#392
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/root-color-seed

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

On Android, every color that resolves through the root __rn-css-color fallback renders nothing — default ring-* / inset-ring-* (Tailwind v4's default ring color is currentcolor), text-current, and bg-current are all invisible. iOS is fine.

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 Android, made scheme-aware through the root observable's existing prefers-color-scheme evaluation — no new Appearance listener, and it drops the as any + two eslint suppressions:

if (Platform.OS === "ios") {
  rootVariables("__rn-css-color").set([
    [PlatformColor("label", "labelColor") as unknown as StyleDescriptor],
  ]);
} else {
  rootVariables("__rn-css-color").set([
    ["#FFFFFF", [["=", "prefers-color-scheme", "dark"]]],
    ["#000000"],
  ]);
}

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. iOS keeps PlatformColor('label'), a first-class dynamic color that already tracks the system appearance.

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-ios.test.tsx:

  • iOS keeps PlatformColor('label'); ancestor override still works.

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"] } (the ColorStateList reference); post-fix, concrete colors.

yarn test, yarn typecheck, yarn eslint, and prettier --check all pass. (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.

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.
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