diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx
index 7a9fbea8..84ac48a5 100644
--- a/src/__tests__/compiler/compiler.test.tsx
+++ b/src/__tests__/compiler/compiler.test.tsx
@@ -1,4 +1,8 @@
-import { compile } from "react-native-css/compiler";
+import {
+ compile,
+ type StyleDeclaration,
+ type StyleDescriptor,
+} from "react-native-css/compiler";
test("hello world", () => {
const compiled = compile(`
@@ -448,3 +452,492 @@ test("simplifies rem", () => {
],
});
});
+
+describe("CSS-wide color keywords", () => {
+ const stylesheetFor = (value: string) =>
+ compile(`.child { color: ${value}; }`).stylesheet();
+
+ test("compiles to the inherited-color variable instead of being dropped", () => {
+ // lightningcss emits `color: inherit` as an UnparsedProperty — the keyword
+ // is not a CssColor — so it lands in parseUnparsed's ident branch, which
+ // drops every keyword it has no resolution context for. Per CSS Color,
+ // `currentcolor` used as the value of `color` is defined as `inherit`, so
+ // both spell the same computed value and resolve to the same variable.
+ // The ABSENCE of a `v` entry is the no-self-reference guarantee: publishing
+ // this value as its own --__rn-css-color seeds a cycle a descendant then
+ // recurses into (see "never publishes a self-referential" below).
+ expect(stylesheetFor("inherit")).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 1],
+ d: [[[{}, "var", "__rn-css-color"], "color", 1]],
+ dv: 1,
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test("inherit and currentcolor compile identically (CSS Color spec identity)", () => {
+ // The two keywords travel different code paths — `inherit` through
+ // parseUnparsed's ident branch, `currentcolor` through parseColor — and
+ // must converge on the same output.
+ expect(stylesheetFor("inherit")).toStrictEqual(
+ stylesheetFor("currentcolor"),
+ );
+ });
+
+ test("PIN: currentcolor resolves to the inherited-color variable", () => {
+ // A pin of behaviour that predates this change, not a guard for it:
+ // `color: currentcolor` never reaches the ident branch below. lightningcss
+ // parses it as a CssColor, so it is `parseColor`'s `case "currentcolor"`
+ // that produces this lookup and `parseFontColorDeclaration`'s own
+ // `type !== "currentcolor"` check that withholds the `v`.
+ expect(stylesheetFor("currentcolor")).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 1],
+ d: [[[{}, "var", "__rn-css-color"], "color", 1]],
+ dv: 1,
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test("PIN: a normal color publishes --__rn-css-color to descendants", () => {
+ // A pin of behaviour that predates this change: `color: red` is a CssColor,
+ // so it is `parseFontColorDeclaration` that publishes the `v`. It is here
+ // because the guard added for the keywords must not swallow this case.
+ expect(stylesheetFor("red")).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 1],
+ d: [{ color: "#f00" }],
+ v: [["__rn-css-color", "#f00"]],
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test("inherit on a non-color property is still dropped (no inheritance context)", () => {
+ expect(
+ compile(`.child { font-size: inherit; }`).stylesheet(),
+ ).toStrictEqual({});
+ });
+
+ test("border-color: inherit is still dropped", () => {
+ // The near miss to the `property === "color"` gate: border-color IS a colour
+ // property, but it is not `color`, so it publishes nothing and inherits
+ // nothing. Only `color` seeds --__rn-css-color, so only `color` can read it
+ // back as `inherit`.
+ expect(
+ compile(`.child { border-color: inherit; }`).stylesheet(),
+ ).toStrictEqual({});
+ });
+
+ test("color: initial is still dropped (different semantics, out of scope)", () => {
+ expect(stylesheetFor("initial")).toStrictEqual({});
+ });
+
+ test.each([
+ ["background-color", "inherit"],
+ ["background-color", "initial"],
+ ["background-color", "revert"],
+ ["background-color", "revert-layer"],
+ ["border-color", "revert"],
+ ["font-size", "revert"],
+ ])("%s: %s is dropped on a non-color property too", (property, keyword) => {
+ // The drop arm is keyword-first, not property-first: only the RESOLVING
+ // arm is gated on `property === "color"`. `revert` and `revert-layer`
+ // previously fell through to the style as their literal string on every
+ // property, so this pins the widened drop across the property axis rather
+ // than on `color` alone.
+ expect(
+ compile(`.child { ${property}: ${keyword}; }`).stylesheet(),
+ ).toStrictEqual({});
+ });
+
+ test("unset on a non-color property is NOT dropped", () => {
+ // The exception to the arm above, and the reason `unset` is absent from it.
+ // On a non-inherited property `unset` means `initial`, and the literal left
+ // here is what the runtime clears the declared colour with — see
+ // `background-color: unset still clears the color` in
+ // src/__tests__/native/colors.test.tsx. Dropping it would take that away,
+ // and nothing else in the compiler would notice.
+ expect(
+ compile(`.child { background-color: unset; }`).stylesheet(),
+ ).toStrictEqual({
+ s: [["child", [{ s: [1, 1], d: [["unset", "backgroundColor"]] }]]],
+ });
+ });
+
+ /**
+ * The custom-property rows of the keyword table above, which cannot share its
+ * one-declaration template — see `uninlinedCustomProperty`.
+ *
+ * A custom property's value is a raw token stream with no property to inherit
+ * FROM and no per-property initial value to fall back to, so
+ * `property === "color"` is false and the resolving arm never fires for one.
+ * `currentcolor` is the exception, and it is not an exception to the gate:
+ * that arm is keyword-only, because `currentcolor` is valid on every
+ * property, custom ones included.
+ */
+ const uninlinedCustomProperty = (value: string) =>
+ // Two definitions, deliberately. react-native-css's own `inlineVariables`
+ // pass keys on a custom property's DECLARATION COUNT: a name declared once
+ // is folded into its consumer at compile time and the declaration is
+ // deleted, so a single-definition case never reaches the keyword arm as a
+ // custom property at all. The second definition is what puts it there —
+ // and it is why the `currentcolor` rows below expect TWO published values,
+ // one per declaring rule.
+ `.child { --brand: ${value}; color: var(--brand); }
+ .other { --brand: ${value}; }`;
+
+ test.each(["inherit", "initial", "revert", "revert-layer", "INHERIT"])(
+ "--brand: %s is dropped — a custom property has no property context",
+ (keyword) => {
+ expect(
+ publishedVariable(uninlinedCustomProperty(keyword), "brand"),
+ ).toEqual([]);
+ },
+ );
+
+ test.each(["currentcolor", "currentColor"])(
+ "--brand: %s resolves — the currentcolor arm is keyword-only, not property-gated",
+ (spelling) => {
+ // Also the vacuity guard for the drop rows above: same construction, so
+ // an empty result here would mean the inliner had eaten the declaration
+ // and the `[]` there was proving nothing.
+ //
+ // The camelCase spelling is the one THIS package folds — lightningcss
+ // hands a custom property's tokens through verbatim, so without the
+ // `.toLowerCase()` in parseUnparsed the literal string "currentColor" is
+ // published as the variable's value and every consumer renders that.
+ expect(
+ publishedVariable(uninlinedCustomProperty(spelling), "brand"),
+ ).toEqual([
+ [{}, "var", "__rn-css-color"],
+ [{}, "var", "__rn-css-color"],
+ ]);
+ },
+ );
+
+ test("--brand: unset keeps its literal, like unset on any non-color property", () => {
+ // `unset` is absent from the drop rows for the same reason it is absent
+ // from the keyword table: on anything that is not `color` it means
+ // `initial`, and the literal is what the runtime clears a value with.
+ expect(
+ publishedVariable(uninlinedCustomProperty("unset"), "brand"),
+ ).toEqual(["unset", "unset"]);
+ });
+
+ test("a custom property declared ONCE is folded into its consumer first", () => {
+ // Why the rows above declare `--brand` twice. With a single definition the
+ // inliner substitutes the value and deletes the declaration, so
+ // `color: var(--brand)` becomes `color: inherit` and takes the resolving
+ // arm — the opposite outcome from the identical CSS carrying one more
+ // definition of the same name.
+ expect(
+ compile(`.child { --brand: inherit; color: var(--brand); }`).stylesheet(),
+ ).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 1],
+ d: [[[{}, "var", "__rn-css-color"], "color", 1]],
+ dv: 1,
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test("color: unset resolves like inherit (unset on an inherited property is inherit)", () => {
+ // Per CSS Cascade, `unset` computes to `inherit` on inherited properties,
+ // and `color` is inherited — so it maps to the same inherited-color variable.
+ expect(stylesheetFor("unset")).toStrictEqual(stylesheetFor("inherit"));
+ });
+
+ test.each(["INHERIT", "Inherit", "UNSET", "INITIAL"])(
+ "keyword matching is case-insensitive (%s)",
+ (spelling) => {
+ // CSS-wide keywords are case-insensitive; lightningcss does not fold case,
+ // so the ident branch has to. INITIAL is in the census because the fold
+ // must reach the drop-with-a-warning arm too, not only the resolving one.
+ expect(stylesheetFor(spelling)).toStrictEqual(
+ stylesheetFor(spelling.toLowerCase()),
+ );
+ },
+ );
+
+ test("PIN: currentColor (camelCase) resolves to the inherited-color variable", () => {
+ // A pin of behaviour that predates this change. On the PARSED-color path
+ // the case fold is lightningcss's, not ours — it parses either spelling
+ // into the same CssColor before this package sees it.
+ //
+ // Asserted against the output rather than against
+ // `stylesheetFor("currentcolor")`. An equality between two spellings
+ // lightningcss has ALREADY folded holds whatever this package then does
+ // with the result, so it cannot fail: break `parseColor`'s currentcolor
+ // case and both sides move together while the sibling pin above goes red.
+ // The spelling this package folds itself is the one that reaches the ident
+ // branch — pinned by `--brand: currentColor` in the custom-property rows
+ // above, where the fold is ours and is new here.
+ expect(stylesheetFor("currentColor")).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 1],
+ d: [[[{}, "var", "__rn-css-color"], "color", 1]],
+ dv: 1,
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test("PIN: currentcolor resolves on a non-color property too (border-color)", () => {
+ // A pin of behaviour that predates this change: border-color is a parsed
+ // CssColor, so this is parseColor's `case "currentcolor"` again. The ident
+ // branch's own currentcolor clause is what serves the UNPARSED properties —
+ // box-shadow, filter: drop-shadow(), and custom properties — and those are
+ // covered by src/__tests__/native/{box-shadow,filters}.test.tsx.
+ expect(
+ compile(`.child { border-color: currentcolor; }`).stylesheet(),
+ ).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 1],
+ d: [[[{}, "var", "__rn-css-color"], "borderColor", 1]],
+ dv: 1,
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test("color: inherit !important keeps the important specificity", () => {
+ expect(stylesheetFor("inherit !important")).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 1, 1],
+ d: [[[{}, "var", "__rn-css-color"], "color", 1]],
+ dv: 1,
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test("color: inherit inside a media query keeps the condition", () => {
+ expect(
+ compile(
+ `@media (min-width: 100px) { .child { color: inherit; } }`,
+ ).stylesheet(),
+ ).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [2, 1],
+ m: [[">=", "width", 100]],
+ d: [[[{}, "var", "__rn-css-color"], "color", 1]],
+ dv: 1,
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test("color: inherit inside :hover keeps the pseudo-class condition", () => {
+ expect(
+ compile(`.child:hover { color: inherit; }`).stylesheet(),
+ ).toStrictEqual({
+ s: [
+ [
+ "child",
+ [
+ {
+ s: [1, 2],
+ d: [[[{}, "var", "__rn-css-color"], "color", 1]],
+ dv: 1,
+ p: { h: 1 },
+ },
+ ],
+ ],
+ ],
+ });
+ });
+
+ test.each([
+ ["placeholder", "placeholderTextColor"],
+ ["selection", "selectionColor"],
+ ])("color: inherit on ::%s targets %s", (pseudoElement, targetProp) => {
+ // A pseudo-element rule retargets the declaration off `style`, so the
+ // inherited-color lookup has to survive the retarget.
+ expect(
+ declarationsFor(`.child::${pseudoElement} { color: inherit; }`),
+ ).toStrictEqual([[[{}, "var", "__rn-css-color"], [targetProp], 1]]);
+ });
+
+ test.each(["revert", "revert-layer"])(
+ "color: %s is dropped rather than published as a literal",
+ (keyword) => {
+ // React Native has no cascade origins to revert to, so the keyword has no
+ // computed value here. Emitting the literal string put `color: "revert"`
+ // in the style AND published it as --__rn-css-color, handing every
+ // descendant that reads the inherited color an unusable value.
+ expect(stylesheetFor(keyword)).toStrictEqual({});
+ },
+ );
+});
+
+/** Every declaration every rule in `css` produces, in compile order. */
+function declarationsFor(css: string): StyleDeclaration[] {
+ return (compile(css).stylesheet().s ?? []).flatMap(([, ruleSet]) =>
+ ruleSet.flatMap((rule) => rule.d ?? []),
+ );
+}
+
+/**
+ * Every value any rule in `css` publishes as the custom property `name`, in
+ * compile order and once per publishing rule.
+ *
+ * Derived from the compiled output rather than restated, so a new rule shape
+ * that publishes the variable is covered without editing the reader.
+ */
+function publishedVariable(css: string, name: string): StyleDescriptor[] {
+ return (compile(css).stylesheet().s ?? []).flatMap(([, ruleSet]) =>
+ ruleSet.flatMap((rule) =>
+ (rule.v ?? [])
+ .filter(([varName]) => varName === name)
+ .map(([, value]) => value),
+ ),
+ );
+}
+
+/** Every value any rule in `css` publishes as `--__rn-css-color`. */
+function publishedInheritedColors(css: string): StyleDescriptor[] {
+ return publishedVariable(css, "__rn-css-color");
+}
+
+describe("the inherited-color variable is never self-referential", () => {
+ /**
+ * Each of these makes `color` READ --__rn-css-color from somewhere below the
+ * top level of the descriptor, which is what a guard comparing only the top
+ * level misses. Publishing any of them as --__rn-css-color hands a descendant
+ * a value that resolves back into the same variable, and resolution recurses
+ * until the stack is exhausted.
+ */
+ const selfReferentialColors = [
+ "inherit",
+ "unset",
+ "currentcolor",
+ "var(--missing, inherit)",
+ "var(--missing, unset)",
+ "var(--missing, currentcolor)",
+ "color-mix(in srgb, currentcolor, blue)",
+ "color-mix(in srgb, inherit, blue)",
+ "rgb(from currentcolor r g b)",
+ "light-dark(currentcolor, blue)",
+ ];
+
+ test("the census is not empty", () => {
+ expect(selfReferentialColors.length).toBeGreaterThan(0);
+ });
+
+ test.each(selfReferentialColors)("color: %s publishes no `v`", (value) => {
+ expect(publishedInheritedColors(`.child { color: ${value}; }`)).toEqual([]);
+ });
+
+ test.each([
+ ["red", "#f00"],
+ ["#00f", "#00f"],
+ ["rgb(1 2 3)", "#010203"],
+ ["color-mix(in srgb, red, blue)", "#800080"],
+ ["oklch(0.7 0.1 200)", "#40b1b7"],
+ ])("color: %s still publishes its own resolved value", (value, expected) => {
+ expect(publishedInheritedColors(`.child { color: ${value}; }`)).toEqual([
+ expected,
+ ]);
+ });
+
+ /**
+ * The discriminating half of the walk. Every one of these CONTAINS a `var()`
+ * — bare, with a fallback, and nested inside a function's argument list — but
+ * none of them names the inherited color, so every one must still publish.
+ *
+ * The census above cannot see this: each of its values resolves to a plain
+ * string, so a walk that answered "reads the inherited color" for ANY `var()`
+ * would leave it green. That mistake does not crash — it silently withholds
+ * the publish, and every descendant of a `color: var(--brand)` rule stops
+ * inheriting. These are the values that tell the two apart.
+ */
+ test.each<[value: string, published: StyleDescriptor[]]>([
+ ["var(--brand)", [[{}, "var", "brand", 1]]],
+ ["var(--brand, red)", [[{}, "var", ["brand", "red"], 1]]],
+ [
+ "color-mix(in srgb, var(--brand), blue)",
+ [
+ [
+ {},
+ "colorMix",
+ ["srgb", [{}, "var", "brand", 1], undefined, "blue", undefined],
+ ],
+ ],
+ ],
+ // light-dark() publishes from its own rule AND from the extra
+ // `prefers-color-scheme: dark` rule it pushes, so the census sees two.
+ ["light-dark(red, blue)", ["#f00", "#f00"]],
+ ])(
+ "color: %s names a variable that is not the inherited one, so it publishes",
+ (value, published) => {
+ expect(publishedInheritedColors(`.child { color: ${value}; }`)).toEqual(
+ published,
+ );
+ },
+ );
+
+ test("light-dark() on color emits one dark rule, not one per parse", () => {
+ // `light-dark()` pushes an extra `prefers-color-scheme: dark` rule as a
+ // SIDE EFFECT of parsing, so the colour must be parsed exactly once for the
+ // declaration and the published variable both.
+ const darkRules = (
+ compile(`.child { color: light-dark(red, blue); }`).stylesheet().s ?? []
+ )
+ .flatMap(([, ruleSet]) => ruleSet)
+ .filter((rule) => rule.m !== undefined);
+
+ expect(darkRules).toHaveLength(1);
+ });
+});
diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx
index 6a8c7255..849f54e8 100644
--- a/src/__tests__/native/colors.test.tsx
+++ b/src/__tests__/native/colors.test.tsx
@@ -1,6 +1,8 @@
-import { render, screen } from "@testing-library/react-native";
+import { act, fireEvent, render, screen } from "@testing-library/react-native";
+import { Text } from "react-native-css/components/Text";
import { View } from "react-native-css/components/View";
import { registerCSS, testID } from "react-native-css/jest";
+import { colorScheme } from "react-native-css/runtime";
describe("hsl", () => {
test("inline", () => {
@@ -165,3 +167,653 @@ describe("currentcolor", () => {
});
});
});
+
+describe("inherit", () => {
+ test("color: inherit resolves to the parent's color", () => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test("text-inherit: a child Text inherits its parent's color", () => {
+ // The shape that surfaced the bug: a labelled button whose label renders
+ // React Native's default color (black) on native instead of the button's
+ // foreground color, while web inherits correctly.
+ registerCSS(`
+ .button { color: white; }
+ .label { color: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("label").props.style).toStrictEqual({
+ color: "#fff",
+ });
+ });
+
+ test("inherit chains through an inheriting ancestor without breaking the chain", () => {
+ // The middle node inherits and must NOT republish a circular
+ // --__rn-css-color, or the grandchild would fail to resolve the color.
+ registerCSS(`
+ .parent { color: red; }
+ .mid { color: inherit; }
+ .child { color: inherit; }
+ `);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("mid").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test("inherit follows the nearest colored ancestor", () => {
+ registerCSS(`
+ .outer { color: red; }
+ .inner { color: blue; }
+ .child { color: inherit; }
+ `);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#00f",
+ });
+ });
+
+ test("color: unset inherits the parent's color, same as inherit", () => {
+ // `unset` computes to `inherit` on inherited properties, and color is one.
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: unset; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test.each(["UNSET", "INHERIT", "Inherit"])(
+ "color: %s is case-folded and inherits",
+ (spelling) => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: ${spelling}; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ },
+ );
+
+ test("color: INITIAL is case-folded into the drop, not into the lookup", () => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: INITIAL; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toBeUndefined();
+ });
+
+ test("a descendant override restarts the chain", () => {
+ registerCSS(`
+ .red { color: red; }
+ .blue { color: blue; }
+ .inherit { color: inherit; }
+ `);
+
+ render(
+
+
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("first").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ expect(screen.getByTestId("second").props.style).toStrictEqual({
+ color: "#00f",
+ });
+ });
+
+ test("color: inherit under a media query", () => {
+ registerCSS(`
+ .parent { color: red; }
+ @media (min-width: 1px) { .child { color: inherit; } }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test("color: inherit under :hover", () => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: blue; }
+ .child:hover { color: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ const child = screen.getByTestId("child");
+ expect(child.props.style).toStrictEqual({ color: "#00f" });
+
+ fireEvent(child, "hoverIn", {});
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test("color: inherit !important beats a normal color on the same element", () => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: inherit !important; }
+ .override { color: blue; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test("color: inherit on ::placeholder and ::selection", () => {
+ registerCSS(`
+ .parent { color: red; }
+ .child::placeholder { color: inherit; }
+ .child::selection { color: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props).toStrictEqual({
+ children: undefined,
+ placeholderTextColor: "#f00",
+ selectionColor: "#f00",
+ style: {},
+ testID: "child",
+ });
+ });
+
+ test.each(["border-color", "background-color"])(
+ "%s: inherit is dropped, it does not read the color variable",
+ (property) => {
+ // Only `color` seeds --__rn-css-color, so only `color` can read it back.
+ // Neither of these inherits in CSS either, so there is nothing for them
+ // to have inherited even if a per-property context existed.
+ registerCSS(`
+ .parent { color: red; }
+ .child { ${property}: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toBeUndefined();
+ },
+ );
+
+ test("background-color: unset still clears the color", () => {
+ // The counterpart to the drop above. `unset` on a non-inherited property
+ // means `initial`, and the literal the compiler leaves in place is what the
+ // runtime clears the declared colour with — so adding `unset` to the
+ // keyword drop would silently take away the only way to clear one. The
+ // cleared element keeps the KEY and loses the value, which is how a later
+ // rule overrides an earlier one here rather than merging with it.
+ registerCSS(`
+ .filled { background-color: red; }
+ .cleared { background-color: unset; }
+ `);
+
+ render(
+ <>
+
+
+ >,
+ );
+
+ expect(screen.getByTestId("filled").props.style).toStrictEqual({
+ backgroundColor: "#f00",
+ });
+ expect(screen.getByTestId("cleared").props.style).toStrictEqual({
+ backgroundColor: undefined,
+ });
+ });
+
+ test("color: inherit with no colored ancestor falls back to the root seed", () => {
+ // Nothing publishes --__rn-css-color above this element, so the read lands
+ // on the value the root seeds it with: the platform's label colour. The
+ // failure this guards is not a wrong colour but an UNRESOLVED one — the
+ // pre-fix drop left `style` undefined and React Native painted its own
+ // default, and a read that resolved to nothing would do the same.
+ registerCSS(`.child { color: inherit; }`);
+
+ render();
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: { semantic: ["label", "labelColor"] },
+ });
+ });
+
+ test("inherit resolves an ancestor color that is itself a variable", () => {
+ // `--brand` has a single definition, so the compiler inlines it and the
+ // published inherited colour is already a resolved string.
+ registerCSS(`
+ .parent { --brand: #ff0000; color: var(--brand); }
+ .child { color: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("parent").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test("inherit resolves an ancestor color from an UNINLINED variable", () => {
+ // A second definition of `--brand` stops the compiler inlining it, so the
+ // ancestor publishes the var() lookup itself rather than a resolved colour.
+ // The descendant must still end up with the ancestor's COMPUTED colour —
+ // which is what `readsInheritedColor` letting a non-inherited `var()`
+ // through is for. Asserted as an equality against the ancestor rather than
+ // a literal: the class is that the two agree, and the raw-token colour a
+ // named-colour custom property currently produces is not this fix's to pin.
+ registerCSS(`
+ .parent { --brand: #ff0000; color: var(--brand); }
+ .child { --brand: #0000ff; color: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ const parentColor = screen.getByTestId("parent").props.style.color;
+
+ expect(parentColor).toBeDefined();
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: parentColor,
+ });
+ });
+
+ /**
+ * `color: var(--brand)` where the KEYWORD is the custom property's value.
+ *
+ * The two tests below are the same CSS but for one extra declaration of
+ * `--brand`, and they end at opposite outcomes, because `inlineVariables`
+ * keys on a custom property's DECLARATION COUNT:
+ *
+ * - declared once, the value is folded into its consumer at compile time and
+ * the rule compiles as `color: ` — the property context exists and
+ * `inherit` resolves;
+ * - declared twice or more, the fold is defeated, `var(--brand)` survives as
+ * a runtime lookup, and the compiler meets the keyword on a CUSTOM property
+ * instead, where there is no property to inherit from — so it drops and the
+ * lookup resolves to nothing.
+ *
+ * Mapping `color: inherit` to the inherited-color variable reaches the folded
+ * route only: before it BOTH routes were broken, so pinning them together is
+ * what records that the split between them is new.
+ */
+ test("color: var(--brand) with --brand: inherit resolves when the variable is inlined", () => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { --brand: inherit; color: var(--brand); }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+
+ test("color: var(--brand) with --brand: inherit drops when the variable is NOT inlined", () => {
+ // The unfolded half of the pair, pinned at the current output rather than
+ // at the CSS-correct one. Per CSS the child computes to red here too. The
+ // keyword is not the only thing that would have to change to get there: a
+ // custom property would need to carry the property context of whatever
+ // consumes it, which is a resolver change, not a keyword-table one.
+ registerCSS(`
+ .parent { color: red; }
+ .child { --brand: inherit; color: var(--brand); }
+ .other { --brand: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({});
+ });
+
+ test.each([
+ ["currentcolor", "inlined"],
+ ["currentcolor", "uninlined"],
+ ["currentColor", "inlined"],
+ ["currentColor", "uninlined"],
+ ] as const)(
+ "color: var(--brand) with --brand: %s resolves on the %s route",
+ (spelling, route) => {
+ // The control for the pair above: `currentcolor` is resolved by a
+ // keyword-only arm, so it never needs a property context and is symmetric
+ // across the fold. The camelCase spelling is symmetric too only because
+ // parseUnparsed folds case: lightningcss hands a custom property's tokens
+ // through verbatim, so without that fold the uninlined route publishes
+ // the literal string "currentColor" as the variable's value and this
+ // element renders it as a colour.
+ const secondDefinition =
+ route === "uninlined" ? `.other { --brand: ${spelling}; }` : "";
+
+ registerCSS(`
+ .parent { color: red; }
+ .child { --brand: ${spelling}; color: var(--brand); }
+ ${secondDefinition}
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ },
+ );
+
+ test("color: inherit alongside a box-shadow leaves no placeholder in the style", () => {
+ // The delayed-value placeholder `{ color: true }` is internal bookkeeping.
+ // A rule whose LAST declaration walks into a nested target (a shadow object)
+ // must not strand the placeholder of an earlier delayed declaration.
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: inherit; box-shadow: 1px 1px blue; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ boxShadow: [
+ {
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ color: "#00f",
+ },
+ ],
+ });
+ });
+
+ test("color: currentcolor alongside a box-shadow leaves no placeholder either", () => {
+ // The same runtime defect with no `inherit` anywhere in the input. The
+ // stranded target is a property of how a rule's declarations are walked,
+ // not of the keyword that made the colour delayed — so this is the pin that
+ // survives if the calculate-props fix is split into its own change.
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: currentcolor; box-shadow: 1px 1px blue; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ boxShadow: [
+ {
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ color: "#00f",
+ },
+ ],
+ });
+ });
+
+ test("color: inherit alongside a text-shadow leaves no placeholder either", () => {
+ registerCSS(`
+ .parent { color: red; }
+ .child { color: inherit; text-shadow: 1px 1px 2px blue; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ textShadowColor: "#00f",
+ textShadowOffset: { width: 1, height: 1 },
+ textShadowRadius: 2,
+ });
+ });
+
+ test.each(["revert", "revert-layer"])(
+ "color: %s publishes nothing to descendants",
+ (keyword) => {
+ // React Native has no cascade origins, so neither keyword has a computed
+ // value. Emitting the literal handed every descendant `color: "revert"`.
+ registerCSS(`
+ .parent { color: red; }
+ .mid { color: ${keyword}; }
+ .child { color: inherit; }
+ `);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("mid").props.style).toBeUndefined();
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ },
+ );
+
+ test("a light-dark() ancestor is inherited by a descendant", () => {
+ registerCSS(`
+ .parent { color: light-dark(red, blue); }
+ .child { color: inherit; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("parent").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+
+ act(() => {
+ colorScheme.set("dark");
+ });
+
+ // KNOWN DIVERGENCE, pinned at the current output rather than at the
+ // CSS-correct one — the same treatment the `rgb(from …)` census entry below
+ // gets. Per CSS the descendant computes to the ancestor's used colour, so
+ // both should be `#00f` here. `light-dark()` instead publishes
+ // --__rn-css-color from its LIGHT branch only: the extra
+ // `prefers-color-scheme: dark` rule carries the dark `color` declaration
+ // beside the light published value.
+ //
+ // It predates this change — it reproduces with the double parse restored —
+ // and it is #420's defect 2, so it is pinned here rather than fixed. Which
+ // of the two lands first decides who updates this expectation.
+ expect(screen.getByTestId("parent").props.style).toStrictEqual({
+ color: "#00f",
+ });
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+});
+
+/**
+ * Each of these makes the middle element's `color` READ the inherited-color
+ * variable from below the top level of its descriptor. Publishing such a value
+ * as --__rn-css-color hands the child a value that resolves back into the same
+ * variable, and resolution recurses until the stack is exhausted.
+ *
+ * The middle element resolves against ITS parent, so the child sees the nearest
+ * ancestor that published a colour of its own — the red parent.
+ */
+const selfReferentialMiddleColors: [css: string, midColor: string][] = [
+ ["inherit", "#f00"],
+ ["unset", "#f00"],
+ ["currentcolor", "#f00"],
+ ["var(--missing, inherit)", "#f00"],
+ ["var(--missing, unset)", "#f00"],
+ ["var(--missing, currentcolor)", "#f00"],
+ ["color-mix(in srgb, currentcolor, blue)", "rgba(127.5, 0, 127.5, 1)"],
+ ["color-mix(in srgb, inherit, blue)", "rgba(127.5, 0, 127.5, 1)"],
+ ["light-dark(currentcolor, blue)", "#f00"],
+ // Relative colour syntax is not implemented, so the mid colour is the
+ // stringified function rather than a colour. It is here for the crash, and it
+ // pins the current output so that implementing `rgb(from …)` has to update it.
+ ["rgb(from currentcolor r g b)", "rgb(from, #f00, r, g, b)"],
+];
+
+describe("a color that reads the inherited color never publishes itself", () => {
+ test("the census is not empty", () => {
+ expect(selfReferentialMiddleColors.length).toBeGreaterThan(0);
+ });
+
+ test.each(selfReferentialMiddleColors)(
+ "mid { color: %s } renders, and its child inherits the grandparent's color",
+ (midColorValue, expectedMidColor) => {
+ registerCSS(`
+ .parent { color: red; }
+ .mid { color: ${midColorValue}; }
+ .child { color: inherit; }
+ `);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("mid").props.style).toStrictEqual({
+ color: expectedMidColor,
+ });
+ expect(screen.getByTestId("child").props.style).toStrictEqual({
+ color: "#f00",
+ });
+ },
+ );
+});
diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx
index b7a08fca..cf85f25f 100644
--- a/src/__tests__/native/transform.test.tsx
+++ b/src/__tests__/native/transform.test.tsx
@@ -154,3 +154,55 @@ describe("transform", () => {
});
});
});
+
+describe("a transform's target survives a later nested declaration", () => {
+ // A transform key resolves through a closure that runs after every
+ // declaration in the rule has been walked. A later declaration that walks
+ // into a NESTED target — a box-shadow entry is the one that reaches here —
+ // must not move the object those closures write into.
+ test("translate before a box-shadow still lands in transform", () => {
+ registerCSS(
+ `.my-class { translate: 10px 20px; box-shadow: 1px 1px blue; }`,
+ );
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({
+ transform: [{ translateX: 10 }, { translateY: 20 }],
+ boxShadow: [
+ {
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ color: "#00f",
+ },
+ ],
+ });
+ });
+
+ test("declaration order does not matter", () => {
+ registerCSS(
+ `.my-class { box-shadow: 1px 1px blue; translate: 10px 20px; }`,
+ );
+
+ const component = render(
+ ,
+ ).getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({
+ transform: [{ translateX: 10 }, { translateY: 20 }],
+ boxShadow: [
+ {
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ color: "#00f",
+ },
+ ],
+ });
+ });
+});
diff --git a/src/__tests__/vendor/tailwind/typography.test.tsx b/src/__tests__/vendor/tailwind/typography.test.tsx
index 00d184b7..c286a3ca 100644
--- a/src/__tests__/vendor/tailwind/typography.test.tsx
+++ b/src/__tests__/vendor/tailwind/typography.test.tsx
@@ -322,9 +322,17 @@ describe("Typography - Text Color", () => {
});
});
test("text-inherit", async () => {
+ // Per CSS Color, `inherit` on the `color` property is defined as
+ // `currentcolor`, so text-inherit resolves to the platform label color —
+ // identical to text-current above — instead of being dropped with a warning.
expect(await renderCurrentTest()).toStrictEqual({
- props: {},
- warnings: { values: { color: "inherit" } },
+ props: {
+ style: {
+ color: {
+ semantic: ["label", "labelColor"],
+ },
+ },
+ },
});
});
});
diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts
index 13013642..1a2d1a9a 100644
--- a/src/compiler/declarations.ts
+++ b/src/compiler/declarations.ts
@@ -950,15 +950,80 @@ export function parseUnparsedDeclaration(
}
if (property === "color") {
- if (
- !isStyleFunction(value) ||
- value[1] !== "var" ||
- value[2] !== "-css-color"
- ) {
- builder.addDescriptor("--__rn-css-color", value);
+ publishInheritedColor(value, builder);
+ }
+ }
+}
+
+/** The variable a color rule publishes and `color: inherit` reads back. */
+const INHERITED_COLOR_VARIABLE = "__rn-css-color";
+
+/**
+ * A read of the inherited color, as `currentcolor` and `color: inherit` both
+ * compile to it. A fresh tuple per call, because a descriptor is owned by the
+ * rule it lands in.
+ */
+function inheritedColorLookup() {
+ return [{}, "var", INHERITED_COLOR_VARIABLE] as const satisfies StyleFunction;
+}
+
+/**
+ * Publish `value` to descendants as the inherited color, unless it reads the
+ * inherited color itself.
+ *
+ * A rule is handed to descendants as an UNRESOLVED descriptor, so a value that
+ * reads `--__rn-css-color` and is published under that same name resolves back
+ * into itself: the descendant recurses until the stack is exhausted. Withholding
+ * the publish leaves the nearest ancestor that names a color of its own as the
+ * one descendants inherit — which is exactly right for `inherit`, `unset` and
+ * `currentcolor`, and an approximation for a value that DERIVES from the
+ * inherited color (`color-mix(in srgb, currentcolor, blue)`), where descendants
+ * see the ancestor's color rather than the derived one. Publishing the derived
+ * value is only possible once resolution happens in the publisher's own scope.
+ */
+function publishInheritedColor(
+ value: StyleDescriptor,
+ builder: StylesheetBuilder,
+) {
+ if (readsInheritedColor(value)) {
+ return;
+ }
+
+ builder.addDescriptor(`--${INHERITED_COLOR_VARIABLE}`, value);
+}
+
+/**
+ * Whether `value` reads `var(--__rn-css-color)` anywhere inside it.
+ *
+ * The read is not always at the top level. `var(--brand, inherit)` buries it in
+ * a fallback, `color-mix(in srgb, currentcolor, blue)` and
+ * `rgb(from currentcolor r g b)` bury it in an argument list, and
+ * `light-dark(currentcolor, blue)` returns it from a branch — so the whole
+ * descriptor tree is walked rather than its first level.
+ */
+function readsInheritedColor(value: StyleDescriptor): boolean {
+ if (!Array.isArray(value)) {
+ return false;
+ }
+
+ if (isStyleFunction(value)) {
+ const args = value[2];
+
+ if (value[1] === "var") {
+ // `var()`'s arguments are the name alone, or `[name, fallback]`.
+ const name = Array.isArray(args) ? args[0] : args;
+
+ if (name === INHERITED_COLOR_VARIABLE) {
+ return true;
}
}
+
+ // A style function's other slots are its marker object, its name and the
+ // delayed-resolution flag; only the arguments can nest a descriptor.
+ return readsInheritedColor(args);
}
+
+ return value.some((entry) => readsInheritedColor(entry));
}
export function parseCustomDeclaration(
@@ -1127,7 +1192,7 @@ export function parseUnparsed(
} else if (tokenOrValue === "false") {
return false;
} else if (tokenOrValue === "currentcolor") {
- return [{}, "var", "__rn-css-color"] as const;
+ return inheritedColorLookup();
} else {
return tokenOrValue;
}
@@ -1263,11 +1328,46 @@ export function parseUnparsed(
return;
}
- if (value === "inherit" || value === "initial") {
+ // CSS-wide keywords and `currentcolor` are case-insensitive, and
+ // lightningcss hands them through unfolded.
+ const keyword = value.toLowerCase();
+
+ // Per CSS Color, `currentcolor` as the value of `color` is defined as
+ // `inherit`; and per CSS Cascade, `unset` on an inherited property
+ // (`color` is inherited) computes to `inherit` too. So `currentcolor`
+ // (valid on any property) and `inherit` / `unset` on `color` all
+ // resolve to the inherited-color variable every color rule publishes
+ // to its subtree (see publishInheritedColor).
+ //
+ // `color: currentcolor` does not arrive here — lightningcss parses it
+ // into a CssColor, so parseColor handles it. This clause serves the
+ // UNPARSED properties: box-shadow, filter: drop-shadow(), and custom
+ // properties, whose values reach the compiler as raw tokens.
+ if (
+ keyword === "currentcolor" ||
+ ((keyword === "inherit" || keyword === "unset") &&
+ property === "color")
+ ) {
+ return inheritedColorLookup();
+ }
+
+ // `inherit` on any other property has no per-property inheritance
+ // context here, `initial` has no per-property initial value, and
+ // React Native has no cascade origins for `revert` / `revert-layer`
+ // to roll back to. None of them has a value to compile to, so they
+ // drop with a warning rather than reaching the style as a literal.
+ //
+ // `unset` on a non-color property is the exception: it means
+ // `initial` there, and the runtime already turns the literal into
+ // `null`, which is how `background-color: unset` clears a color.
+ if (
+ keyword === "inherit" ||
+ keyword === "initial" ||
+ keyword === "revert" ||
+ keyword === "revert-layer"
+ ) {
builder.addWarning("value", value);
return;
- } else if (value === "currentcolor") {
- return [{}, "var", "__rn-css-color"] as const;
}
if (value === "true") {
@@ -1589,17 +1689,13 @@ export function parseFontColorDeclaration(
declaration: Extract,
builder: StylesheetBuilder,
) {
- parseColorDeclaration(declaration, builder);
+ // Parsed once, for the declaration and the published variable both:
+ // `light-dark()` pushes an extra `prefers-color-scheme: dark` rule as a side
+ // effect, so a second parse emits a second copy of that rule.
+ const value = parseColor(declaration.value, builder);
- if (
- typeof declaration.value !== "object" ||
- declaration.value.type !== "currentcolor"
- ) {
- builder.addDescriptor(
- "--__rn-css-color",
- parseColor(declaration.value, builder),
- );
- }
+ builder.addDescriptor(declaration.property, value);
+ publishInheritedColor(value, builder);
}
export function parseColorDeclaration(
@@ -1638,7 +1734,7 @@ export function parseColor(cssColor: CssColor, builder: StylesheetBuilder) {
switch (cssColor.type) {
case "currentcolor":
- return [{}, "var", "__rn-css-color"] as const;
+ return inheritedColorLookup();
case "light-dark": {
const extraRule: StyleRule = {
s: [],
diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts
index 28b9ed36..e48f4075 100644
--- a/src/native/styles/calculate-props.ts
+++ b/src/native/styles/calculate-props.ts
@@ -105,14 +105,20 @@ export function applyDeclarations(
target: Record = {},
topLevelTarget = target,
) {
- const originalTarget = target;
-
for (const declaration of declarations) {
- target = originalTarget;
+ /**
+ * Scoped to THIS declaration. The delayed and transform closures below
+ * capture it, and they run after every declaration has been walked — so a
+ * binding shared across iterations hands them whatever nested object the
+ * LAST declaration ended on (a shadow, a transform entry) instead of the
+ * target this declaration resolved. The placeholder then never matches, and
+ * `{ [prop]: true }` is left in the style.
+ */
+ let declarationTarget = target;
if (!Array.isArray(declaration)) {
// Static styles
- Object.assign(target, declaration);
+ Object.assign(declarationTarget, declaration);
} else {
// Dynamic styles
let value: any = declaration[0];
@@ -131,7 +137,7 @@ export function applyDeclarations(
if (final) {
if (first !== "&") {
topLevelTarget[first] ??= {};
- target = topLevelTarget[first];
+ declarationTarget = topLevelTarget[first];
}
let previousProp: string | number = first;
@@ -143,19 +149,19 @@ export function applyDeclarations(
if (!Array.isArray(previousTarget[previousProp])) {
previousTarget[previousProp] = [];
- target = previousTarget[previousProp];
+ declarationTarget = previousTarget[previousProp];
}
}
- previousTarget = target;
+ previousTarget = declarationTarget;
previousProp = prop;
- target[prop] ??= {};
- target = target[prop];
+ declarationTarget[prop] ??= {};
+ declarationTarget = declarationTarget[prop];
}
prop = final;
} else {
- target = topLevelTarget;
+ declarationTarget = topLevelTarget;
prop = first;
}
} else {
@@ -186,19 +192,19 @@ export function applyDeclarations(
renderGuards: guards,
calculateProps,
});
- applyValue(target, prop, value);
+ applyValue(declarationTarget, prop, value);
});
} else {
delayedStyles.push(() => {
- if (getDeepPath(target, prop) === value) {
- delete target[prop];
+ if (getDeepPath(declarationTarget, prop) === value) {
+ delete declarationTarget[prop];
value = resolveValue(originalValue, get, {
inlineVariables,
inheritedVariables,
renderGuards: guards,
calculateProps,
});
- applyValue(target, prop, value);
+ applyValue(declarationTarget, prop, value);
}
});
}
@@ -211,7 +217,7 @@ export function applyDeclarations(
});
}
- applyValue(target, prop, value);
+ applyValue(declarationTarget, prop, value);
}
}
}