From 2dc7f5acdbaa0f9197c3e5fe93c0a8323657c2d8 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 23 Jul 2026 19:50:03 +0300 Subject: [PATCH 1/2] fix(native): seed a concrete scheme-aware Android __rn-css-color 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. --- .../native/root-color-seed-android.test.tsx | 121 ++++++++++++++++++ .../native/root-color-seed-ios.test.tsx | 36 ++++++ .../vendor/tailwind/ring-color-seed.test.tsx | 42 ++++++ src/native-internal/root.ts | 41 ++++-- 4 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/native/root-color-seed-android.test.tsx create mode 100644 src/__tests__/native/root-color-seed-ios.test.tsx create mode 100644 src/__tests__/vendor/tailwind/ring-color-seed.test.tsx diff --git a/src/__tests__/native/root-color-seed-android.test.tsx b/src/__tests__/native/root-color-seed-android.test.tsx new file mode 100644 index 00000000..83f4f3ba --- /dev/null +++ b/src/__tests__/native/root-color-seed-android.test.tsx @@ -0,0 +1,121 @@ +import { act, render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { colorScheme } from "react-native-css/runtime"; + +// The root `__rn-css-color` seed is a module-load side effect gated on +// Platform.OS, and jest-expo defaults to ios. Mock android so the runtime +// (react-native-css/jest -> native-internal/root) takes the Android branch; +// babel-jest hoists this jest.mock above the imports above. +jest.mock("react-native", () => { + const ReactNative = + jest.requireActual("react-native"); + ReactNative.Platform.OS = "android"; + return ReactNative; +}); + +describe("android root __rn-css-color seed", () => { + test("currentcolor with no ancestor resolves to a concrete color", () => { + // The bug: the Android seed was PlatformColor('?attr/textColorPrimary'), + // which resolves to a ColorStateList reference that never paints — so + // currentcolor / default rings / text-current rendered nothing. It is now a + // concrete color that always paints. + colorScheme.set("light"); + registerCSS(`.c { color: currentcolor; }`); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#000000", + }); + }); + + test("the seed is scheme-aware — white in dark mode — via prefers-color-scheme", () => { + // Reactivity comes from the root observable's existing media-query + // evaluation reading the `colorScheme` observable — no extra Appearance + // listener is added. + colorScheme.set("dark"); + registerCSS(`.c { color: currentcolor; }`); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#FFFFFF", + }); + }); + + test("an ancestor-published color still overrides the root seed", () => { + // The seed is only the *ultimate* fallback; a nearer published color wins, + // so the fix changes nothing for content that already has a color context. + colorScheme.set("light"); + registerCSS(` + .parent { color: red; } + .child { color: currentcolor; } + `); + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#f00", + }); + }); + + test("responds live to a colorScheme change — reactive, not seeded once", () => { + // Proves the reactivity claim: the same element re-resolves on a scheme + // flip, through the root observable's media-query evaluation alone. + colorScheme.set("light"); + registerCSS(`.c { color: currentcolor; }`); + render(); + const element = screen.getByTestId(testID); + + expect(element.props.style).toStrictEqual({ color: "#000000" }); + + act(() => { + colorScheme.set("dark"); + }); + + expect(element.props.style).toStrictEqual({ color: "#FFFFFF" }); + }); + + test("a default ring (box-shadow currentcolor) paints with the seed color", () => { + // The reported symptom: Tailwind's default ring color is currentcolor, so + // with the old ColorStateList seed every ring was invisible on Android. + colorScheme.set("light"); + registerCSS( + `.ring { --my-ring: 0 0 0 2px currentcolor; box-shadow: var(--my-ring); }`, + ); + render(); + + const boxShadow = screen.getByTestId(testID).props.style.boxShadow as [ + { color: string }, + ]; + expect(boxShadow[0].color).toBe("#000000"); + }); + + test("a default inset-ring paints with the seed color", () => { + colorScheme.set("light"); + registerCSS( + `.ir { --my-ring: inset 0 0 0 2px currentcolor; box-shadow: var(--my-ring); }`, + ); + render(); + + const boxShadow = screen.getByTestId(testID).props.style.boxShadow as [ + { inset: boolean; color: string }, + ]; + expect(boxShadow[0].inset).toBe(true); + expect(boxShadow[0].color).toBe("#000000"); + }); + + test("no color-scheme preference (null) falls back to the light default", () => { + // Appearance.getColorScheme() can be null; the dark media query then fails, + // so the seed resolves to its unconditioned light value rather than nothing. + colorScheme.set(null); + registerCSS(`.c { color: currentcolor; }`); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#000000", + }); + }); +}); diff --git a/src/__tests__/native/root-color-seed-ios.test.tsx b/src/__tests__/native/root-color-seed-ios.test.tsx new file mode 100644 index 00000000..cac45c23 --- /dev/null +++ b/src/__tests__/native/root-color-seed-ios.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; + +// jest-expo runs with Platform.OS === "ios" by default (no mock needed), so +// native-internal/root takes the iOS branch when the runtime loads here. +describe("ios root __rn-css-color seed", () => { + test("keeps PlatformColor('label') — the first-class dynamic system color", () => { + // iOS is unchanged: PlatformColor('label') already tracks the system + // appearance, so only Android needed the concrete scheme-aware seed. + registerCSS(`.c { color: currentcolor; }`); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: { semantic: ["label", "labelColor"] }, + }); + }); + + test("an ancestor-published color still overrides the PlatformColor seed", () => { + // The resolution machinery is platform-agnostic; a nearer published color + // wins on iOS too, so the seed remains only the ultimate fallback. + registerCSS(` + .parent { color: red; } + .child { color: currentcolor; } + `); + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#f00", + }); + }); +}); diff --git a/src/__tests__/vendor/tailwind/ring-color-seed.test.tsx b/src/__tests__/vendor/tailwind/ring-color-seed.test.tsx new file mode 100644 index 00000000..4b06bdc5 --- /dev/null +++ b/src/__tests__/vendor/tailwind/ring-color-seed.test.tsx @@ -0,0 +1,42 @@ +import { colorScheme } from "react-native-css/runtime"; + +import { renderSimple } from "./_tailwind"; + +// The root `__rn-css-color` seed is a module-load side effect gated on +// Platform.OS, and jest-expo defaults to ios. Mock android so the runtime takes +// the Android branch, then drive it with *real* Tailwind output — the default +// `ring-*` color is `currentcolor`, which resolves through the root seed. +jest.mock("react-native", () => { + const ReactNative = + jest.requireActual("react-native"); + ReactNative.Platform.OS = "android"; + return ReactNative; +}); + +const ringColor = (props: { style?: unknown }): string => { + const style = props.style as { boxShadow: [{ color: string }] }; + return style.boxShadow[0].color; +}; + +describe("android default ring paints (real Tailwind → root color seed)", () => { + test("ring-2 with no explicit color resolves to the seed, not an invisible ColorStateList", async () => { + // The reported bug: Tailwind's default ring color is currentcolor, and with + // the old PlatformColor('?attr/textColorPrimary') seed the ring never + // painted on Android. It now resolves to the concrete seed. + colorScheme.set("light"); + const { props } = await renderSimple({ className: "ring-2" }); + expect(ringColor(props)).toBe("#000000"); + }); + + test("the default ring is scheme-aware (white in dark mode)", async () => { + colorScheme.set("dark"); + const { props } = await renderSimple({ className: "ring" }); + expect(ringColor(props)).toBe("#FFFFFF"); + }); + + test("an explicit ring color still wins over the currentcolor default", async () => { + colorScheme.set("light"); + const { props } = await renderSimple({ className: "ring-2 ring-red-500" }); + expect(ringColor(props)).toBe("#fb2c36"); + }); +}); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index e45a7d11..332fb43d 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -33,12 +33,35 @@ export const rootVariables = rootVariableFamily(); export const universalVariables = rootVariableFamily(); rootVariables("__rn-css-rem").set([[14]]); -// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -rootVariables("__rn-css-color").set([ - [ - Platform.OS === "ios" - ? PlatformColor("label", "labelColor") - : PlatformColor("?attr/textColorPrimary", "SystemBaseHighColor"), - ], - // eslint-disable-next-line @typescript-eslint/no-explicit-any -] as any); + +/** + * The ultimate fallback for every `currentcolor` (and `color: inherit`) + * resolution that reaches the root with no ancestor- or theme-published + * `--__rn-css-color`. + * + * iOS keeps `PlatformColor('label')` — a first-class dynamic color that already + * tracks the system appearance. + * + * Android's `PlatformColor('?attr/textColorPrimary')` resolves to a + * ColorStateList, and RN's `ColorPropConverter` returns the resource + * *reference* rather than an ARGB int, so it silently never paints — default + * `ring-*` / `inset-ring-*` / `text-current` render nothing. Seed a concrete + * color instead, made scheme-aware through this same root observable's + * `prefers-color-scheme` evaluation (no extra `Appearance` listener). A binary + * black/white default is spec-faithful: the root value is only the ultimate + * fallback, so any ancestor-published or themed `--__rn-css-color` overrides it. + */ +if (Platform.OS === "ios") { + // PlatformColor returns an OpaqueColorValue that isn't in the StyleDescriptor + // union, but the native runtime consumes it as a color. + const iosLabelColor = PlatformColor( + "label", + "labelColor", + ) as unknown as StyleDescriptor; + rootVariables("__rn-css-color").set([[iosLabelColor]]); +} else { + rootVariables("__rn-css-color").set([ + ["#FFFFFF", [["=", "prefers-color-scheme", "dark"]]], + ["#000000"], + ]); +} From 96f11f3aec5a55b2553d44007d9451c23fe16006 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 21:24:30 +0300 Subject: [PATCH 2/2] test(native): pin that a :root color overrides the seed, and follow the 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. --- .../native/root-color-seed-override.test.tsx | 47 +++++++++++++++++++ ....test.tsx => root-color-seed.test.ios.tsx} | 0 2 files changed, 47 insertions(+) create mode 100644 src/__tests__/native/root-color-seed-override.test.tsx rename src/__tests__/native/{root-color-seed-ios.test.tsx => root-color-seed.test.ios.tsx} (100%) diff --git a/src/__tests__/native/root-color-seed-override.test.tsx b/src/__tests__/native/root-color-seed-override.test.tsx new file mode 100644 index 00000000..ef9205e7 --- /dev/null +++ b/src/__tests__/native/root-color-seed-override.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { colorScheme } from "react-native-css/runtime"; + +// The seed is only defensible if an app can override it, so these pin that it can. +// They live in their own file because `inject` replaces a root variable outright and +// nothing puts it back — `react-native-css/jest`'s beforeEach clears +// StyleCollection.styles, not the root registry — so a `:root { color }` here would +// otherwise leak into every later test in the same file. +jest.mock("react-native", () => { + const ReactNative = + jest.requireActual("react-native"); + ReactNative.Platform.OS = "android"; + return ReactNative; +}); + +test("a stylesheet :root color replaces the seed outright", () => { + // An app that themes its text colour has to win, and a :root rule is how it does + // that — not via an ancestor element, which is all the sibling suite covers + colorScheme.set("light"); + registerCSS(` + :root { color: #123456; } + .c { color: currentcolor; } + `); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#123456", + }); +}); + +test("a scheme-conditioned :root color overrides the seed per scheme", () => { + colorScheme.set("dark"); + registerCSS(` + :root { color: #123456; } + @media (prefers-color-scheme: dark) { + :root { color: #eeeeee; } + } + .c { color: currentcolor; } + `); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#eee", + }); +}); diff --git a/src/__tests__/native/root-color-seed-ios.test.tsx b/src/__tests__/native/root-color-seed.test.ios.tsx similarity index 100% rename from src/__tests__/native/root-color-seed-ios.test.tsx rename to src/__tests__/native/root-color-seed.test.ios.tsx