Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions src/__tests__/native/root-color-seed-android.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("react-native")>("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(<View testID={testID} className="c" />);

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(<View testID={testID} className="c" />);

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(
<View testID="parent" className="parent">
<View testID={testID} className="child" />
</View>,
);

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(<View testID={testID} className="c" />);
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(<View testID={testID} className="ring" />);

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(<View testID={testID} className="ir" />);

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(<View testID={testID} className="c" />);

expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "#000000",
});
});
});
36 changes: 36 additions & 0 deletions src/__tests__/native/root-color-seed-ios.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<View testID={testID} className="c" />);

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(
<View testID="parent" className="parent">
<View testID={testID} className="child" />
</View>,
);

expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "#f00",
});
});
});
42 changes: 42 additions & 0 deletions src/__tests__/vendor/tailwind/ring-color-seed.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("react-native")>("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");
});
});
41 changes: 32 additions & 9 deletions src/native-internal/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
]);
}