From e8254982fc7f463fc9d8fc81997b360817881cf4 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 23 Jul 2026 19:48:25 +0300 Subject: [PATCH 1/7] fix(compiler): map `color: inherit` to the inherited-color variable Tailwind's `text-inherit` (`color: inherit`) was dropped by the compiler and rendered React Native's default text color (black on Android) instead of the parent's color; web inherited correctly via real CSS. lightningcss emits `color: inherit` as an UnparsedProperty (the keyword is not a CssColor), and parseUnparsed's token/ident branch discarded it with a warning. Per CSS Color 4, `currentcolor` used as the value of `color` is defined as `inherit`, so both resolve to the --__rn-css-color variable every color rule already publishes to its subtree -- no new machinery. - parseUnparsed: resolve the inherited-color keywords -- `inherit`, `unset` (CSS Cascade: `unset` computes to `inherit` on the inherited `color` property), and `currentcolor` -- to the variable, matched case-insensitively (`currentColor`, `INHERIT`), instead of dropping them. - parseUnparsedDeclaration: fix the self-reference guard, which compared against a stale "-css-color" literal that never matched the emitted "__rn-css-color". Without it an inheriting rule publishes a circular --__rn-css-color: var(--__rn-css-color), breaking resolution for its subtree. `inherit` on non-color properties, and `initial`, remain dropped with a warning (no per-property inheritance context exists). Adds compiler + native-render tests, including the parent -> child -> grandchild chain that proves the self-reference fix, and corrects the vendor text-inherit test that had encoded the dropped-declaration behaviour. --- src/__tests__/compiler/compiler.test.tsx | 118 ++++++++++++++++++ src/__tests__/native/colors.test.tsx | 103 +++++++++++++++ .../vendor/tailwind/typography.test.tsx | 12 +- src/compiler/declarations.ts | 32 ++++- 4 files changed, 259 insertions(+), 6 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 7a9fbea8..d7561ab3 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -448,3 +448,121 @@ 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), which parseUnparsed used to drop. Per CSS Color, + // `currentcolor` used as the value of `color` is defined as `inherit`, so it + // resolves to the same inherited-color variable. The ABSENCE of a `v` entry + // is the no-self-reference guarantee — publishing this value as its own + // --__rn-css-color would seed a circular var(--__rn-css-color). + 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)", () => { + expect(stylesheetFor("inherit")).toStrictEqual( + stylesheetFor("currentcolor"), + ); + }); + + test("currentcolor still resolves to the inherited-color variable (unchanged)", () => { + // The token/ident branch that hunk 1 restructured also carries currentcolor; + // this pins that currentcolor keeps compiling to the same lookup, and — like + // inherit — never self-publishes a `v`. + expect(stylesheetFor("currentcolor")).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [[[{}, "var", "__rn-css-color"], "color", 1]], + dv: 1, + }, + ], + ], + ], + }); + }); + + test("a normal color still publishes --__rn-css-color to descendants", () => { + 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("color: initial is still dropped (different semantics, out of scope)", () => { + expect(stylesheetFor("initial")).toStrictEqual({}); + }); + + 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("keyword matching is case-insensitive (INHERIT)", () => { + // CSS-wide keywords are case-insensitive; lightningcss does not fold case. + expect(stylesheetFor("INHERIT")).toStrictEqual(stylesheetFor("inherit")); + }); + + test("currentColor (camelCase) resolves like currentcolor", () => { + // The spelling React/JS authors reach for; it is valid, case-insensitive CSS. + expect(stylesheetFor("currentColor")).toStrictEqual( + stylesheetFor("currentcolor"), + ); + }); + + test("currentcolor resolves on a non-color property too (border-color)", () => { + expect( + compile(`.child { border-color: currentcolor; }`).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [[[{}, "var", "__rn-css-color"], "borderColor", 1]], + dv: 1, + }, + ], + ], + ], + }); + }); +}); diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx index 6a8c7255..b67a971b 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -1,4 +1,5 @@ import { 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"; @@ -165,3 +166,105 @@ 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", + }); + }); +}); 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..7591a6ff 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -950,10 +950,15 @@ export function parseUnparsedDeclaration( } if (property === "color") { + // Publish the resolved color to descendants as --__rn-css-color — the + // variable `currentcolor` and `color: inherit` both resolve against. Skip + // when the value IS that same lookup (e.g. `color: inherit`) so an + // inheriting rule never seeds a circular `--__rn-css-color: + // var(--__rn-css-color)` that would break resolution for its own subtree. if ( !isStyleFunction(value) || value[1] !== "var" || - value[2] !== "-css-color" + value[2] !== "__rn-css-color" ) { builder.addDescriptor("--__rn-css-color", value); } @@ -1263,11 +1268,30 @@ export function parseUnparsed( return; } - if (value === "inherit" || value === "initial") { + // CSS-wide keywords and `currentcolor` are case-insensitive. + 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 parseUnparsedDeclaration). + if ( + keyword === "currentcolor" || + ((keyword === "inherit" || keyword === "unset") && + property === "color") + ) { + return [{}, "var", "__rn-css-color"] as const; + } + + // `inherit` and `initial` on any other property have no per-property + // resolution context here — drop with a warning. `unset` on a + // non-color property (= `initial` there) and `revert` / + // `revert-layer` keep their existing fall-through handling below. + if (keyword === "inherit" || keyword === "initial") { builder.addWarning("value", value); return; - } else if (value === "currentcolor") { - return [{}, "var", "__rn-css-color"] as const; } if (value === "true") { From c7ab2cbecfb63355af4b0e4bf0f436c4fd56214f Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:13:26 +0300 Subject: [PATCH 2/7] fix(compiler): never publish a self-referential inherited color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `color: inherit` is a READER of --__rn-css-color, the variable every colour rule publishes to its subtree. A rule that reads that variable must not also publish itself as it: the descriptor is handed to descendants unresolved, so a descendant resolving it walks straight back into the same name and recursion runs until the stack is exhausted — `RangeError: Maximum call stack size exceeded` from a render. The guard compared the top level of the value only, which misses every value that buries the read one level down: color: var(--brand, inherit) color: color-mix(in srgb, currentcolor, blue) color: rgb(from currentcolor r g b) color: light-dark(currentcolor, blue) so a parent with any of those plus a descendant `text-inherit` took the app down. `readsInheritedColor` walks the whole descriptor tree instead, and the publish is one `publishInheritedColor` used by both sites that publish — `parseUnparsedDeclaration` for the keyword path and `parseFontColorDeclaration` for the parsed-CssColor path, which had its own narrower `currentcolor` check and let `light-dark(currentcolor, …)` through. Withholding the publish leaves the nearest ancestor naming a colour of its own as the one descendants inherit. That is exactly right for `inherit`, `unset` and `currentcolor`, and an approximation for a value DERIVED from the inherited colour, where descendants see the ancestor's colour rather than the derived one — publishing the derived value needs resolution in the publisher's own scope, which the compiler cannot do. Also here, because they sit on the same two lines: - `revert` / `revert-layer` reached the style as the literal string "revert", and were published under --__rn-css-color, so a descendant reading the inherited colour got `color: "revert"`. React Native has no cascade origins to roll back to, so they drop with a warning like `initial`. - `parseFontColorDeclaration` parsed the colour twice, once for the declaration and once for the variable. `light-dark()` pushes an extra `prefers-color-scheme: dark` rule as a side effect of parsing, so `color: light-dark(a, b)` emitted that rule twice. It parses once now. The two compiler tests asserting `currentcolor` behaviour are relabelled as pins: `color: currentcolor` never reaches the restructured ident branch, because lightningcss parses it into a CssColor that `parseColor` handles. That branch's `currentcolor` clause is live for the UNPARSED properties — box-shadow, filter: drop-shadow(), custom properties — and removing it fails `src/__tests__/native/{box-shadow,filters}.test.tsx`. --- src/__tests__/compiler/compiler.test.tsx | 235 ++++++++++++++++++++-- src/__tests__/native/colors.test.tsx | 237 ++++++++++++++++++++++- src/compiler/declarations.ts | 128 +++++++++--- 3 files changed, 551 insertions(+), 49 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index d7561ab3..426c0fc5 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(` @@ -454,12 +458,14 @@ describe("CSS-wide color keywords", () => { 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), which parseUnparsed used to drop. Per CSS Color, - // `currentcolor` used as the value of `color` is defined as `inherit`, so it - // resolves to the same inherited-color variable. The ABSENCE of a `v` entry - // is the no-self-reference guarantee — publishing this value as its own - // --__rn-css-color would seed a circular var(--__rn-css-color). + // 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: [ [ @@ -477,15 +483,20 @@ describe("CSS-wide color keywords", () => { }); 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("currentcolor still resolves to the inherited-color variable (unchanged)", () => { - // The token/ident branch that hunk 1 restructured also carries currentcolor; - // this pins that currentcolor keeps compiling to the same lookup, and — like - // inherit — never self-publishes a `v`. + 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: [ [ @@ -502,7 +513,10 @@ describe("CSS-wide color keywords", () => { }); }); - test("a normal color still publishes --__rn-css-color to descendants", () => { + 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: [ [ @@ -525,6 +539,16 @@ describe("CSS-wide color keywords", () => { ).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({}); }); @@ -535,19 +559,33 @@ describe("CSS-wide color keywords", () => { expect(stylesheetFor("unset")).toStrictEqual(stylesheetFor("inherit")); }); - test("keyword matching is case-insensitive (INHERIT)", () => { - // CSS-wide keywords are case-insensitive; lightningcss does not fold case. - expect(stylesheetFor("INHERIT")).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("currentColor (camelCase) resolves like currentcolor", () => { - // The spelling React/JS authors reach for; it is valid, case-insensitive CSS. + test("PIN: currentColor (camelCase) resolves like currentcolor", () => { + // A pin of behaviour that predates this change. Case folding here is + // lightningcss's, not ours — it parses either spelling into the same + // CssColor before this package sees it. expect(stylesheetFor("currentColor")).toStrictEqual( stylesheetFor("currentcolor"), ); }); - test("currentcolor resolves on a non-color property too (border-color)", () => { + 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({ @@ -565,4 +603,163 @@ describe("CSS-wide color keywords", () => { ], }); }); + + 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 `--__rn-css-color`. + * + * Derived from the compiled output rather than restated, so a new rule shape + * that publishes the variable is covered without editing the reader. + */ +function publishedInheritedColors(css: string): StyleDescriptor[] { + return (compile(css).stylesheet().s ?? []).flatMap(([, ruleSet]) => + ruleSet.flatMap((rule) => + (rule.v ?? []) + .filter(([name]) => name === "__rn-css-color") + .map(([, value]) => value), + ), + ); +} + +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, + ]); + }); + + 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 b67a971b..badc4ca5 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react-native"; +import { 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"; @@ -267,4 +267,239 @@ describe("inherit", () => { 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("border-color: inherit is dropped, it does not read the color variable", () => { + // Only `color` seeds --__rn-css-color, so only `color` can read it back. + registerCSS(` + .parent { color: red; } + .child { border-color: inherit; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toBeUndefined(); + }); + + test("color: revert publishes nothing to descendants", () => { + // React Native has no cascade origins, so `revert` has no computed value. + // Emitting the literal handed every descendant `color: "revert"`. + registerCSS(` + .parent { color: red; } + .mid { color: revert; } + .child { color: inherit; } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId("mid").props.style).toBeUndefined(); + 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/compiler/declarations.ts b/src/compiler/declarations.ts index 7591a6ff..5e734000 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -950,20 +950,78 @@ export function parseUnparsedDeclaration( } if (property === "color") { - // Publish the resolved color to descendants as --__rn-css-color — the - // variable `currentcolor` and `color: inherit` both resolve against. Skip - // when the value IS that same lookup (e.g. `color: inherit`) so an - // inheriting rule never seeds a circular `--__rn-css-color: - // var(--__rn-css-color)` that would break resolution for its own subtree. - if ( - !isStyleFunction(value) || - value[1] !== "var" || - value[2] !== "__rn-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 `color: inherit` compiles to it. */ +const inheritedColorLookup = [ + {}, + "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( @@ -1268,7 +1326,8 @@ export function parseUnparsed( return; } - // CSS-wide keywords and `currentcolor` are case-insensitive. + // 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 @@ -1276,20 +1335,35 @@ export function parseUnparsed( // (`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 parseUnparsedDeclaration). + // 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 [{}, "var", "__rn-css-color"] as const; + return inheritedColorLookup; } - // `inherit` and `initial` on any other property have no per-property - // resolution context here — drop with a warning. `unset` on a - // non-color property (= `initial` there) and `revert` / - // `revert-layer` keep their existing fall-through handling below. - if (keyword === "inherit" || keyword === "initial") { + // `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; } @@ -1613,17 +1687,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( From 3e56a8ad86aca1a0d85e484ea8ed92d2ca5ac762 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:14:08 +0300 Subject: [PATCH 3/7] fix(native): scope a declaration's style target to its own iteration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `applyDeclarations` walks the target path into a fresh binding per declaration, but that binding was the function's `target` PARAMETER, reassigned each time around the loop. A delayed or transform closure captures it, and those closures run after every declaration has been walked — so they saw whatever nested object the LAST declaration ended on. A rule with a delayed `color` and a `box-shadow` is the shape that shows it: the shadow declaration walks into `["&", "boxShadow", "[0]", "color"]`, so the colour's closure reads `getDeepPath(shadowObject, "color")`, never matches the placeholder it minted against the style root, and leaves the internal `{ color: true }` in the style. `text-shadow` does the same. .parent { color: red } .child { color: inherit; box-shadow: 1px 1px blue } before: { color: { color: true }, boxShadow: [...] } after: { color: "#f00", boxShadow: [...] } The binding is now declared inside the loop, so each declaration's closures keep the target that declaration resolved. `color: currentcolor` in place of `color: inherit` reaches the same bug, so this is not specific to the keyword. --- src/__tests__/native/colors.test.tsx | 49 ++++++++++++++++++++++++++++ src/native/styles/calculate-props.ts | 36 +++++++++++--------- 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx index badc4ca5..96519285 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -423,6 +423,55 @@ describe("inherit", () => { expect(screen.getByTestId("child").props.style).toBeUndefined(); }); + 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: 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("color: revert publishes nothing to descendants", () => { // React Native has no cascade origins, so `revert` has no computed value. // Emitting the literal handed every descendant `color: "revert"`. 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); } } } From 033f76ab47fc70b2e1ee837ace7ef3f6bd6a1e6f Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:25:17 +0300 Subject: [PATCH 4/7] refactor(compiler): single-source the inherited-color variable name The name was spelled as a literal at the publish site and at each of the three places that compile a read of it, so the publish and the reads could drift apart silently. One constant, and one `inheritedColorLookup()` the three read sites call. It returns a fresh tuple per call because a descriptor is owned by the rule it lands in. --- src/compiler/declarations.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 5e734000..1a2d1a9a 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -958,12 +958,14 @@ export function parseUnparsedDeclaration( /** 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 `color: inherit` compiles to it. */ -const inheritedColorLookup = [ - {}, - "var", - INHERITED_COLOR_VARIABLE, -] as const satisfies StyleFunction; +/** + * 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 @@ -1190,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; } @@ -1346,7 +1348,7 @@ export function parseUnparsed( ((keyword === "inherit" || keyword === "unset") && property === "color") ) { - return inheritedColorLookup; + return inheritedColorLookup(); } // `inherit` on any other property has no per-property inheritance @@ -1732,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: [], From 4801492f785acf74af73eab8ce77b5cd1d94d839 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 21:52:49 +0300 Subject: [PATCH 5/7] test: cover every fix on both the compiler and the native plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each defect on this branch should be observable from the compiler output AND from a render. Auditing what was here found four places where only one plane watched, plus one guard direction nothing watched at all. Every test below was mutation-proved: the thing it guards was broken, the test was watched go red for the right reason, and the break was reverted. Compiler plane - `revert` / `revert-layer` drop on EVERY property, not just `color`. The drop arm is keyword-first and only the resolving arm is gated on `color`, but the whole census sat on `color`. A property axis pins the arm that actually exists. Reverting the widened drop reddens 6 tests, 4 of them new. - `unset` on a non-color property is deliberately NOT dropped — it means `initial` there and the literal is what clears a colour. That exception was documented in a comment and asserted nowhere, so adding `unset` to the drop list was a silent change. - A value that CONTAINS a `var()` which is not the inherited colour still publishes. The publish census held only values resolving to a plain string, so a walk answering "reads the inherited colour" for ANY `var()` passed it untouched — no crash, just every descendant of a `color: var(--brand)` rule quietly ceasing to inherit. That mutation now reddens 4 tests; before it reddened none. Native plane - `color: inherit` with no coloured ancestor at all, which resolves against the root seed rather than the nearest publisher. - An ancestor whose colour is itself a variable, inlined and uninlined. The uninlined case is asserted as an equality against the ancestor rather than a literal, because the class is that the two agree. - `revert-layer` beside `revert`, and a `light-dark()` ancestor across a colour scheme change. - `color: currentcolor` beside a box-shadow — the stranded-target defect with no `inherit` in the input, so the runtime fix stays pinned if it is split out. - A transform key before a nested declaration. The two existing tests covered the delayed closure; the transform closure had nothing, and it fails worse: the translate values land INSIDE the shadow object and the transform array keeps its boolean placeholders. Two planes measured, not assumed - The double-parse of `light-dark()` is compiler-only. Restoring it puts three rules on the element instead of two and changes no rendered style; across all 1160 tests only the two compiler assertions redden. - The `calculate-props` target scoping is native-only. Reverting it reddens five render tests and not one compiler test. A divergence found while covering `light-dark()` is pinned rather than fixed: under dark, a descendant inherits the ancestor's LIGHT colour, because `light-dark()` publishes --__rn-css-color from its light branch while the extra dark rule carries the dark declaration. It reproduces with the double-parse restored, so it predates this branch. The expectation records it so a fix has to come back and change it. --- src/__tests__/compiler/compiler.test.tsx | 68 ++++++++ src/__tests__/native/colors.test.tsx | 204 +++++++++++++++++++++-- src/__tests__/native/transform.test.tsx | 52 ++++++ 3 files changed, 307 insertions(+), 17 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 426c0fc5..00319074 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -553,6 +553,38 @@ describe("CSS-wide color keywords", () => { 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"]] }]]], + }); + }); + 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. @@ -750,6 +782,42 @@ describe("the inherited-color variable is never self-referential", () => { ]); }); + /** + * 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 diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx index 96519285..a37f497d 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -1,7 +1,8 @@ -import { fireEvent, 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", () => { @@ -407,20 +408,116 @@ describe("inherit", () => { }); }); - test("border-color: inherit is dropped, it does not read the color variable", () => { - // Only `color` seeds --__rn-css-color, so only `color` can read it back. + 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(` - .parent { color: red; } - .child { border-color: inherit; } + .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("child").props.style).toBeUndefined(); + 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, + }); }); test("color: inherit alongside a box-shadow leaves no placeholder in the style", () => { @@ -452,6 +549,36 @@ describe("inherit", () => { }); }); + 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; } @@ -472,24 +599,67 @@ describe("inherit", () => { }); }); - test("color: revert publishes nothing to descendants", () => { - // React Native has no cascade origins, so `revert` has no computed value. - // Emitting the literal handed every descendant `color: "revert"`. + 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: red; } - .mid { color: revert; } + .parent { color: light-dark(red, blue); } .child { color: inherit; } `); render( - - - - + + , ); - expect(screen.getByTestId("mid").props.style).toBeUndefined(); + 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 — so it is recorded, not fixed here, and a + // fix has to come back and update this expectation. + expect(screen.getByTestId("parent").props.style).toStrictEqual({ + color: "#00f", + }); 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", + }, + ], + }); + }); +}); From 23b493c33c86c29237ea8a9c3aa0e7c83d078252 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 21:59:02 +0300 Subject: [PATCH 6/7] docs(test): attribute the light-dark divergence to #420 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned divergence is that PR's defect 2, not a new finding, and the double parse this branch fixes is its defect 6 — so the comment names the owner and the merge order rather than implying either is unclaimed. --- src/__tests__/native/colors.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx index a37f497d..40c809c1 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -654,9 +654,11 @@ describe("inherit", () => { // 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 — so it is recorded, not fixed here, and a - // fix has to come back and update this expectation. + // 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", }); From e3049ba52f781dd07a0de093d503cb84ec229f0f Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 16 Aug 2026 10:25:41 +0300 Subject: [PATCH 7/7] test: make the camelCase pin failable and record the custom-property split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PIN: currentColor (camelCase) resolves like currentcolor` compared `stylesheetFor("currentColor")` against `stylesheetFor("currentcolor")`, and lightningcss folds both spellings into the same CssColor before this package sees either — so both sides of the equality moved together and the assertion could not fail. Returning "#000" from `parseColor`'s currentcolor case turns its sibling pin red and leaves that one green. It now asserts the compiled output, so the same mutation fails it. The spelling this package folds itself is the one that reaches the ident branch, and a custom property is where that happens. That surface splits by whether `inlineVariables` folded the name, which it does only for a name declared once: - declared once, `--brand: inherit; color: var(--brand)` compiles as `color: inherit` and resolves to the inherited-color variable; - declared twice, the fold is defeated and the keyword is met on a custom property, which carries no property context, so it drops and the lookup resolves to nothing. Both routes dropped before `color: inherit` mapped to the variable, so the divergence between them is new even though the unfolded route's defect is not. Both are pinned, along with the keyword rows for a custom property: `inherit` / `initial` / `revert` / `revert-layer` drop there while `currentcolor` resolves, because that arm is keyword-only rather than gated on the property. The `currentcolor` rows double as the vacuity guard for the drop rows — same construction, so an empty result there would mean the inliner had eaten the declaration. --- src/__tests__/compiler/compiler.test.tsx | 130 +++++++++++++++++++++-- src/__tests__/native/colors.test.tsx | 93 ++++++++++++++++ 2 files changed, 213 insertions(+), 10 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 00319074..84ac48a5 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -585,6 +585,90 @@ describe("CSS-wide color keywords", () => { }); }); + /** + * 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. @@ -603,13 +687,33 @@ describe("CSS-wide color keywords", () => { }, ); - test("PIN: currentColor (camelCase) resolves like currentcolor", () => { - // A pin of behaviour that predates this change. Case folding here is - // lightningcss's, not ours — it parses either spelling into the same - // CssColor before this package sees it. - expect(stylesheetFor("currentColor")).toStrictEqual( - stylesheetFor("currentcolor"), - ); + 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)", () => { @@ -726,21 +830,27 @@ function declarationsFor(css: string): StyleDeclaration[] { } /** - * Every value any rule in `css` publishes as `--__rn-css-color`. + * 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 publishedInheritedColors(css: string): StyleDescriptor[] { +function publishedVariable(css: string, name: string): StyleDescriptor[] { return (compile(css).stylesheet().s ?? []).flatMap(([, ruleSet]) => ruleSet.flatMap((rule) => (rule.v ?? []) - .filter(([name]) => name === "__rn-css-color") + .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 diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx index 40c809c1..849f54e8 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -520,6 +520,99 @@ describe("inherit", () => { }); }); + /** + * `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)