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"], + ]); +}