From 6b4e992b1b5035d86418f390a07dabacd0dac521 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 16:05:23 +0300 Subject: [PATCH 01/17] fix(compiler): map `color: inherit` to the inherited-color variable `color: inherit`, `unset` and `currentcolor` compile to a read of the colour every `color` rule publishes, and a colour that reads that channel is not published back into it. While an element computes its own `color`, the read resolves in the scope it inherited, because css-color-4 makes `currentcolor` the inherited colour there; on every other property it stays the element's own colour. #451 ordered the element's own variables before inherited ones, so the inherited read is now explicit rather than a consequence of lookup order. --- src/__tests__/compiler/compiler.test.tsx | 495 ++++++++++++- src/__tests__/native/colors.test.tsx | 701 +++++++++++++++++- src/__tests__/native/transform.test.tsx | 52 ++ .../vendor/tailwind/typography.test.tsx | 12 +- src/compiler/declarations.ts | 109 ++- src/native/styles/calculate-props.ts | 42 +- src/native/styles/resolve.ts | 29 +- src/utilities/index.ts | 1 + src/utilities/inherited-color.ts | 2 + 9 files changed, 1401 insertions(+), 42 deletions(-) create mode 100644 src/utilities/inherited-color.ts diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b08e24c3..06a58fc1 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(` @@ -356,3 +360,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..647e3eeb 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,700 @@ 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", + }); + }, + ); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 agree on each case: currentcolor is the inherited colour only inside `color` itself +describe("currentcolor inside color reads the inherited colour, and nowhere else", () => { + test.each([ + ["box-shadow", "box-shadow: 0 0 1px currentcolor;", "boxShadow"], + [ + "text-shadow", + "text-shadow: 1px 1px 1px currentcolor;", + "textShadowColor", + ], + ] as const)( + "%s reads the element's own colour", + (_property, declaration, key) => { + registerCSS(` + .parent { color: #ff0000; } + .child { color: #0000ff; ${declaration} } + `); + + render( + + + , + ); + + expect( + JSON.stringify(screen.getByTestId("child").props.style[key]), + ).toContain("#00f"); + }, + ); + + test("color-mix() of currentcolor on color mixes the inherited colour", () => { + registerCSS(` + .parent { color: #ff0000; } + .child { color: color-mix(in srgb, currentcolor, #0000ff); } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "rgba(127.5, 0, 127.5, 1)", + }); + }); +}); diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index 7a59b816..5bbf4a7e 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 9ca2aab8..f4330fee 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -36,7 +36,7 @@ import type { UnresolvedColor, } from "lightningcss"; -import { isStyleFunction } from "../utilities"; +import { INHERITED_COLOR_VARIABLE, isStyleFunction } from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, @@ -1001,15 +1001,49 @@ 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); + } + } +} + +// 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; +} + +// A descendant resolves a published colour lazily, so one that reads the channel it is published under would resolve into itself +function publishInheritedColor( + value: StyleDescriptor, + builder: StylesheetBuilder, +) { + if (readsInheritedColor(value)) { + return; + } + + builder.addDescriptor(`--${INHERITED_COLOR_VARIABLE}`, value); +} + +// A fallback, a function argument or a light-dark() arm can hold the read, so the whole tree is walked +function readsInheritedColor(value: StyleDescriptor): boolean { + if (!Array.isArray(value)) { + return false; + } + + if (isStyleFunction(value)) { + const args = value[2]; + + if (value[1] === "var") { + const name = Array.isArray(args) ? args[0] : args; + + if (name === INHERITED_COLOR_VARIABLE) { + return true; } } + + return readsInheritedColor(args); } + + return value.some((entry) => readsInheritedColor(entry)); } export function parseCustomDeclaration( @@ -1181,7 +1215,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; } @@ -1317,11 +1351,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") { @@ -1643,17 +1712,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( @@ -1692,7 +1757,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..c63e2e18 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -105,19 +105,28 @@ 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]; let propPath = declaration[1]; let prop: string | number; + // Only the element's own `color`: a shadow's colour is a nested `color` key that keeps the cascading read + const computingColor = + (Array.isArray(propPath) ? propPath.join(".") : propPath) === "color"; if (Array.isArray(propPath)) { const [first, ...rest] = propPath; @@ -131,7 +140,7 @@ export function applyDeclarations( if (final) { if (first !== "&") { topLevelTarget[first] ??= {}; - target = topLevelTarget[first]; + declarationTarget = topLevelTarget[first]; } let previousProp: string | number = first; @@ -143,19 +152,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 { @@ -185,20 +194,22 @@ export function applyDeclarations( inheritedVariables, renderGuards: guards, calculateProps, + computingColor, }); - 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, + computingColor, }); - applyValue(target, prop, value); + applyValue(declarationTarget, prop, value); } }); } @@ -208,10 +219,11 @@ export function applyDeclarations( inheritedVariables, renderGuards: guards, calculateProps, + computingColor, }); } - applyValue(target, prop, value); + applyValue(declarationTarget, prop, value); } } } diff --git a/src/native/styles/resolve.ts b/src/native/styles/resolve.ts index 0cb40385..6eb2aee3 100644 --- a/src/native/styles/resolve.ts +++ b/src/native/styles/resolve.ts @@ -5,7 +5,10 @@ import type { StyleDescriptor, StyleFunction, } from "react-native-css/compiler"; -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + INHERITED_COLOR_VARIABLE, + isStyleDescriptorArray, +} from "react-native-css/utilities"; import type { RenderGuard } from "../conditions/guards"; import { type Getter, type VariableContextValue } from "../reactivity"; @@ -54,6 +57,8 @@ export type ResolveValueOptions = { renderGuards?: RenderGuard[]; variableHistory?: Set; variableCycles?: Set; + /** Resolving the element's own `color`, where css-color-4 §6.4 makes `currentcolor` the inherited colour */ + computingColor?: boolean; /** Pass down to perform recursive calculations and avoid circular dependencies */ calculateProps?: typeof calculateProps; }; @@ -109,6 +114,21 @@ export function resolveValue( }; if (name === "var") { + if (options.computingColor && readsInheritedColor(value)) { + const inheritedScope: ResolveValueOptions = { + ...options, + inlineVariables: undefined, + computingColor: false, + }; + + return varResolver( + (inner) => resolveValue(inner, get, inheritedScope), + value, + get, + inheritedScope, + ); + } + return varResolver(simpleResolve, value, get, options); } else if (name in functionResolvers) { const fn = functionResolvers[name as keyof typeof functionResolvers]; @@ -166,3 +186,10 @@ export function resolveValue( } } } + +function readsInheritedColor(fn: StyleFunction): boolean { + const args = fn[2]; + const name = Array.isArray(args) ? args[0] : args; + + return name === INHERITED_COLOR_VARIABLE; +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0c95da63..dd762410 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,3 +1,4 @@ export * from "./specificity"; export * from "./style-descriptor"; export * from "./dot-notation.types"; +export * from "./inherited-color"; diff --git a/src/utilities/inherited-color.ts b/src/utilities/inherited-color.ts new file mode 100644 index 00000000..64462650 --- /dev/null +++ b/src/utilities/inherited-color.ts @@ -0,0 +1,2 @@ +/** The custom property every `color` rule publishes to its subtree and `currentcolor` reads back */ +export const INHERITED_COLOR_VARIABLE = "__rn-css-color"; From 5e1ef6fdb03de94e6796b9c6ba243f1c51c2a141 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 17:51:25 +0300 Subject: [PATCH 02/17] refactor: name the inherited-colour lookup once The compiler and the resolver each asked whether a var() reads the inherited colour, under one name with two meanings. The shallow question is now `isInheritedColorLookup` beside the variable it names, and the compiler's walk calls it for each var() it meets. Each constraint is stated in one line, citing the sections that state it. --- src/compiler/declarations.ts | 47 ++++++---------------------- src/native/styles/calculate-props.ts | 9 +----- src/native/styles/resolve.ts | 13 ++------ src/utilities/inherited-color.ts | 13 ++++++++ 4 files changed, 27 insertions(+), 55 deletions(-) diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index f4330fee..ab9f815d 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -36,7 +36,11 @@ import type { UnresolvedColor, } from "lightningcss"; -import { INHERITED_COLOR_VARIABLE, isStyleFunction } from "../utilities"; +import { + INHERITED_COLOR_VARIABLE, + isInheritedColorLookup, + isStyleFunction, +} from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, @@ -1030,17 +1034,7 @@ function readsInheritedColor(value: StyleDescriptor): boolean { } if (isStyleFunction(value)) { - const args = value[2]; - - if (value[1] === "var") { - const name = Array.isArray(args) ? args[0] : args; - - if (name === INHERITED_COLOR_VARIABLE) { - return true; - } - } - - return readsInheritedColor(args); + return isInheritedColorLookup(value) || readsInheritedColor(value[2]); } return value.some((entry) => readsInheritedColor(entry)); @@ -1351,21 +1345,10 @@ export function parseUnparsed( return; } - // CSS-wide keywords and `currentcolor` are case-insensitive, and - // lightningcss hands them through unfolded. + // Keywords are ASCII case-insensitive (css-values-4 §4.1), and lightningcss hands an unparsed one 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. + // currentcolor is the inherited colour on `color` (css-color-3 §4.4), and `unset` on an inherited property is `inherit` (css-cascade-5 §7.3.3) if ( keyword === "currentcolor" || ((keyword === "inherit" || keyword === "unset") && @@ -1374,15 +1357,7 @@ export function parseUnparsed( 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. + // React Native has no inheritance context, initial value or cascade origin for these; `unset` stays, as the literal the runtime clears with if ( keyword === "inherit" || keyword === "initial" || @@ -1712,9 +1687,7 @@ export function parseFontColorDeclaration( declaration: Extract, builder: StylesheetBuilder, ) { - // 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. + // Parsed once: light-dark() adds its dark rule as a side effect, so a second parse adds it twice const value = parseColor(declaration.value, builder); builder.addDescriptor(declaration.property, value); diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index c63e2e18..57bf3c47 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -106,14 +106,7 @@ export function applyDeclarations( topLevelTarget = target, ) { for (const declaration of declarations) { - /** - * 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. - */ + // The delayed and transform closures below run after the walk, so each must capture its own declaration's target let declarationTarget = target; if (!Array.isArray(declaration)) { diff --git a/src/native/styles/resolve.ts b/src/native/styles/resolve.ts index 6eb2aee3..27c00309 100644 --- a/src/native/styles/resolve.ts +++ b/src/native/styles/resolve.ts @@ -6,7 +6,7 @@ import type { StyleFunction, } from "react-native-css/compiler"; import { - INHERITED_COLOR_VARIABLE, + isInheritedColorLookup, isStyleDescriptorArray, } from "react-native-css/utilities"; @@ -57,7 +57,7 @@ export type ResolveValueOptions = { renderGuards?: RenderGuard[]; variableHistory?: Set; variableCycles?: Set; - /** Resolving the element's own `color`, where css-color-4 §6.4 makes `currentcolor` the inherited colour */ + /** Resolving the element's own `color`, where css-color-3 §4.4 makes `currentcolor` the inherited colour */ computingColor?: boolean; /** Pass down to perform recursive calculations and avoid circular dependencies */ calculateProps?: typeof calculateProps; @@ -114,7 +114,7 @@ export function resolveValue( }; if (name === "var") { - if (options.computingColor && readsInheritedColor(value)) { + if (options.computingColor && isInheritedColorLookup(value)) { const inheritedScope: ResolveValueOptions = { ...options, inlineVariables: undefined, @@ -186,10 +186,3 @@ export function resolveValue( } } } - -function readsInheritedColor(fn: StyleFunction): boolean { - const args = fn[2]; - const name = Array.isArray(args) ? args[0] : args; - - return name === INHERITED_COLOR_VARIABLE; -} diff --git a/src/utilities/inherited-color.ts b/src/utilities/inherited-color.ts index 64462650..f67c9926 100644 --- a/src/utilities/inherited-color.ts +++ b/src/utilities/inherited-color.ts @@ -1,2 +1,15 @@ +import type { StyleFunction } from "react-native-css/compiler"; + /** The custom property every `color` rule publishes to its subtree and `currentcolor` reads back */ export const INHERITED_COLOR_VARIABLE = "__rn-css-color"; + +export function isInheritedColorLookup(fn: StyleFunction): boolean { + if (fn[1] !== "var") { + return false; + } + + const args = fn[2]; + const name = Array.isArray(args) ? args[0] : args; + + return name === INHERITED_COLOR_VARIABLE; +} From bc4e9b840f2fe9b2f9d5707589e72536d47ee2fb Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 18:13:29 +0300 Subject: [PATCH 03/17] refactor(compiler): parseUnparsed takes only token values No caller passes parseUnparsed a string or a number: with either removed from the parameter type, tsc reports no call site, and the test suite never takes either branch. The string branch's `currentcolor` arm was a third spelling of the inherited-colour lookup that nothing reached. --- src/compiler/declarations.ts | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index ab9f815d..208da2e6 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -1188,13 +1188,7 @@ export function unparsedFunction( * This export function best efforts parsing it into a export function that we can evaluate at runtime */ export function parseUnparsed( - tokenOrValue: - | TokenOrValue - | TokenOrValue[] - | string - | number - | undefined - | null, + tokenOrValue: TokenOrValue | TokenOrValue[] | undefined | null, builder: StylesheetBuilder, property: string, allowAuto = allowAutoProperties.has(property), @@ -1203,22 +1197,6 @@ export function parseUnparsed( return; } - if (typeof tokenOrValue === "string") { - if (tokenOrValue === "true") { - return true; - } else if (tokenOrValue === "false") { - return false; - } else if (tokenOrValue === "currentcolor") { - return inheritedColorLookup(); - } else { - return tokenOrValue; - } - } - - if (typeof tokenOrValue === "number") { - return round(tokenOrValue); - } - if (Array.isArray(tokenOrValue)) { const args = reduceParseUnparsed( tokenOrValue, From 7b3ad438195dc5d88fc81bd3e39f966d824f61fd Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 18:13:38 +0300 Subject: [PATCH 04/17] test: assert inherited colours as browsers compute them Each expected value is what Chromium, Firefox and WebKit compute for the same CSS. The cases that pinned a divergence are gone: a light-dark() descendant in dark mode, a custom property holding `inherit` that is not inlined, and the string relative colour syntax resolves to. `::selection` is left to the pseudo-element mapping, and `::placeholder` also reads the element's own colour. --- src/__tests__/compiler/compiler.test.tsx | 198 +++----------- src/__tests__/native/colors.test.tsx | 251 +++++++----------- src/__tests__/native/transform.test.tsx | 6 +- .../vendor/tailwind/typography.test.tsx | 4 +- 4 files changed, 133 insertions(+), 326 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 06a58fc1..18fed71a 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -365,15 +365,7 @@ 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). + test("color: inherit compiles to the inherited-color lookup and publishes nothing", () => { expect(stylesheetFor("inherit")).toStrictEqual({ s: [ [ @@ -390,21 +382,14 @@ 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. + test("color: inherit and color: currentcolor compile alike", () => { + // currentColor on `color` is `inherit` (css-color-3 §4.4) 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`. + test("color: currentcolor compiles to the inherited-color lookup and publishes nothing", () => { expect(stylesheetFor("currentcolor")).toStrictEqual({ s: [ [ @@ -421,10 +406,7 @@ describe("CSS-wide color keywords", () => { }); }); - 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. + test("a colour of its own publishes --__rn-css-color to descendants", () => { expect(stylesheetFor("red")).toStrictEqual({ s: [ [ @@ -441,23 +423,20 @@ describe("CSS-wide color keywords", () => { }); }); - test("inherit on a non-color property is still dropped (no inheritance context)", () => { + test("font-size: inherit is dropped", () => { 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`. + test("border-color: inherit is dropped", () => { + // Only `color` publishes its value to descendants, so only `color` can inherit it expect( compile(`.child { border-color: inherit; }`).stylesheet(), ).toStrictEqual({}); }); - test("color: initial is still dropped (different semantics, out of scope)", () => { + test("color: initial is dropped", () => { expect(stylesheetFor("initial")).toStrictEqual({}); }); @@ -468,24 +447,14 @@ describe("CSS-wide color keywords", () => { ["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. + ])("%s: %s is dropped", (property, keyword) => { 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. + test("background-color: unset keeps its literal", () => { + // `unset` on a non-inherited property is `initial` (css-cascade-5 §7.3.3), and the runtime clears a value with the literal expect( compile(`.child { background-color: unset; }`).stylesheet(), ).toStrictEqual({ @@ -493,30 +462,13 @@ 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. - */ + // A second declaration keeps `--brand` from being inlined, so the keyword reaches the compiler on the custom property 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", + "--brand: %s is dropped, as a custom property has no property to inherit", (keyword) => { expect( publishedVariable(uninlinedCustomProperty(keyword), "brand"), @@ -525,16 +477,9 @@ describe("CSS-wide color keywords", () => { ); test.each(["currentcolor", "currentColor"])( - "--brand: %s resolves — the currentcolor arm is keyword-only, not property-gated", + "--brand: %s compiles to the inherited-color lookup", (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. + // lightningcss hands a custom property's tokens through unfolded, so `currentColor` relies on the compiler's case fold expect( publishedVariable(uninlinedCustomProperty(spelling), "brand"), ).toEqual([ @@ -544,21 +489,13 @@ describe("CSS-wide color keywords", () => { }, ); - 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. + test("--brand: unset keeps its literal", () => { 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. + test("a custom property declared once is inlined, so its keyword resolves on color", () => { expect( compile(`.child { --brand: inherit; color: var(--brand); }`).stylesheet(), ).toStrictEqual({ @@ -577,37 +514,22 @@ describe("CSS-wide color keywords", () => { }); }); - 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. + test("color: unset compiles like color: inherit", () => { + // `unset` on an inherited property is `inherit` (css-cascade-5 §7.3.3) expect(stylesheetFor("unset")).toStrictEqual(stylesheetFor("inherit")); }); test.each(["INHERIT", "Inherit", "UNSET", "INITIAL"])( - "keyword matching is case-insensitive (%s)", + "color: %s compiles like its lowercase spelling", (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. + // Keywords are ASCII case-insensitive (css-values-4 §4.1), and lightningcss hands an unparsed one through unfolded 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. + test("color: currentColor compiles to the inherited-color lookup", () => { expect(stylesheetFor("currentColor")).toStrictEqual({ s: [ [ @@ -624,12 +546,7 @@ describe("CSS-wide color keywords", () => { }); }); - 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. + test("border-color: currentcolor compiles to the same lookup", () => { expect( compile(`.child { border-color: currentcolor; }`).stylesheet(), ).toStrictEqual({ @@ -707,43 +624,29 @@ describe("CSS-wide color keywords", () => { }); }); - 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. + test("color: inherit on ::placeholder targets placeholderTextColor", () => { expect( - declarationsFor(`.child::${pseudoElement} { color: inherit; }`), - ).toStrictEqual([[[{}, "var", "__rn-css-color"], [targetProp], 1]]); + declarationsFor(`.child::placeholder { color: inherit; }`), + ).toStrictEqual([ + [[{}, "var", "__rn-css-color"], ["placeholderTextColor"], 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. + // React Native has no cascade origins, so `revert` has no value to roll back to 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) => @@ -754,19 +657,12 @@ function publishedVariable(css: string, name: string): StyleDescriptor[] { ); } -/** 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. - */ + // Each reads the inherited colour somewhere in its descriptor, so publishing it would hand a descendant a lookup into itself const selfReferentialColors = [ "inherit", "unset", @@ -780,10 +676,6 @@ describe("the inherited-color variable is never self-referential", () => { "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([]); }); @@ -800,17 +692,6 @@ 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]]], @@ -824,9 +705,6 @@ describe("the inherited-color variable is never self-referential", () => { ], ], ], - // 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) => { @@ -836,16 +714,12 @@ describe("the inherited-color variable is never self-referential", () => { }, ); - 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 = ( + test("color: light-dark() emits no rule twice", () => { + // Parsing light-dark() adds its dark rule as a side effect, so the colour is parsed once for the declaration and its publish + const rules = ( compile(`.child { color: light-dark(red, blue); }`).stylesheet().s ?? [] - ) - .flatMap(([, ruleSet]) => ruleSet) - .filter((rule) => rule.m !== undefined); + ).flatMap(([, ruleSet]) => ruleSet.map((rule) => JSON.stringify(rule))); - expect(darkRules).toHaveLength(1); + expect(new Set(rules).size).toBe(rules.length); }); }); diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx index 647e3eeb..b78c5412 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -1,8 +1,7 @@ -import { act, fireEvent, 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"; -import { colorScheme } from "react-native-css/runtime"; describe("hsl", () => { test("inline", () => { @@ -186,10 +185,7 @@ describe("inherit", () => { }); }); - 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. + test("color: inherit on a Text reads its parent's color", () => { registerCSS(` .button { color: white; } .label { color: inherit; } @@ -206,9 +202,7 @@ describe("inherit", () => { }); }); - 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. + test("inherit chains through an inheriting ancestor", () => { registerCSS(` .parent { color: red; } .mid { color: inherit; } @@ -251,8 +245,8 @@ describe("inherit", () => { }); }); - test("color: unset inherits the parent's color, same as inherit", () => { - // `unset` computes to `inherit` on inherited properties, and color is one. + test("color: unset inherits the parent's color", () => { + // `unset` on an inherited property is `inherit` (css-cascade-5 §7.3.3) registerCSS(` .parent { color: red; } .child { color: unset; } @@ -386,11 +380,11 @@ describe("inherit", () => { }); }); - test("color: inherit on ::placeholder and ::selection", () => { + test("color: inherit on ::placeholder reads the parent's color", () => { + // React Native has no default input colour to inherit, so this matches Tailwind's preflight `input { color: inherit }` registerCSS(` .parent { color: red; } .child::placeholder { color: inherit; } - .child::selection { color: inherit; } `); render( @@ -402,18 +396,38 @@ describe("inherit", () => { expect(screen.getByTestId("child").props).toStrictEqual({ children: undefined, placeholderTextColor: "#f00", - selectionColor: "#f00", style: {}, testID: "child", }); }); + test.each(["inherit", "currentcolor"])( + "color: %s on ::placeholder reads the element's own color", + (keyword) => { + registerCSS(` + .parent { color: red; } + .child { color: green; } + .child::placeholder { color: ${keyword}; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props).toStrictEqual({ + children: undefined, + placeholderTextColor: "#008000", + style: { color: "#008000" }, + testID: "child", + }); + }, + ); + test.each(["border-color", "background-color"])( - "%s: inherit is dropped, it does not read the color variable", + "%s: inherit is dropped", (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; } @@ -429,13 +443,7 @@ describe("inherit", () => { }, ); - 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. + test("background-color: unset clears an earlier rule's color", () => { registerCSS(` .filled { background-color: red; } .cleared { background-color: unset; } @@ -456,12 +464,7 @@ describe("inherit", () => { }); }); - 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. + test("color: inherit with no colored ancestor reads the root's label color", () => { registerCSS(`.child { color: inherit; }`); render(); @@ -471,9 +474,7 @@ describe("inherit", () => { }); }); - 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. + test("inherit resolves an ancestor color from an inlined variable", () => { registerCSS(` .parent { --brand: #ff0000; color: var(--brand); } .child { color: inherit; } @@ -493,14 +494,8 @@ describe("inherit", () => { }); }); - 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. + test("inherit resolves an ancestor color from a variable that is not inlined", () => { + // A second declaration keeps `--brand` from being inlined registerCSS(` .parent { --brand: #ff0000; color: var(--brand); } .child { --brand: #0000ff; color: inherit; } @@ -520,25 +515,6 @@ 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; } @@ -556,27 +532,6 @@ describe("inherit", () => { }); }); - 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"], @@ -585,13 +540,7 @@ describe("inherit", () => { ] 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. + // lightningcss hands a custom property's tokens through unfolded, so `currentColor` relies on the compiler's case fold const secondDefinition = route === "uninlined" ? `.other { --brand: ${spelling}; }` : ""; @@ -614,9 +563,6 @@ describe("inherit", () => { ); 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; } @@ -643,10 +589,6 @@ 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; } @@ -695,8 +637,7 @@ describe("inherit", () => { 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"`. + // React Native has no cascade origins, so `revert` has no value to roll back to registerCSS(` .parent { color: red; } .mid { color: ${keyword}; } @@ -718,7 +659,7 @@ describe("inherit", () => { }, ); - test("a light-dark() ancestor is inherited by a descendant", () => { + test("a light-dark() ancestor's color is inherited by a descendant", () => { registerCSS(` .parent { color: light-dark(red, blue); } .child { color: inherit; } @@ -736,100 +677,97 @@ describe("inherit", () => { 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][] = [ +const colorsThatReadTheInheritedColor: [css: string, midColor: string][] = [ ["inherit", "#f00"], ["unset", "#f00"], ["currentcolor", "#f00"], ["var(--missing, inherit)", "#f00"], ["var(--missing, unset)", "#f00"], ["var(--missing, currentcolor)", "#f00"], + ["light-dark(currentcolor, blue)", "#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", +describe("a color that reads the inherited color", () => { + test.each(colorsThatReadTheInheritedColor)( + "mid { color: %s } resolves against its parent", (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", - }); }, ); + + test.each( + colorsThatReadTheInheritedColor.filter( + ([, midColor]) => midColor === "#f00", + ), + )("a child of mid { color: %s } inherits the same color", (midColorValue) => { + registerCSS(` + .parent { color: red; } + .mid { color: ${midColorValue}; } + .child { color: inherit; } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }); }); // Chromium 153, Firefox 155 and WebKit 26.6 agree on each case: currentcolor is the inherited colour only inside `color` itself describe("currentcolor inside color reads the inherited colour, and nowhere else", () => { test.each([ - ["box-shadow", "box-shadow: 0 0 1px currentcolor;", "boxShadow"], + [ + "box-shadow", + "box-shadow: 0 0 1px currentcolor;", + { + boxShadow: [ + { + offsetX: 0, + offsetY: 0, + blurRadius: 1, + spreadDistance: 0, + color: "#00f", + }, + ], + }, + ], [ "text-shadow", "text-shadow: 1px 1px 1px currentcolor;", - "textShadowColor", + { + textShadowColor: "#00f", + textShadowOffset: { width: 1, height: 1 }, + textShadowRadius: 1, + }, ], ] as const)( "%s reads the element's own colour", - (_property, declaration, key) => { + (_property, declaration, shadowStyle) => { registerCSS(` .parent { color: #ff0000; } .child { color: #0000ff; ${declaration} } @@ -841,9 +779,10 @@ describe("currentcolor inside color reads the inherited colour, and nowhere else , ); - expect( - JSON.stringify(screen.getByTestId("child").props.style[key]), - ).toContain("#00f"); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#00f", + ...shadowStyle, + }); }, ); diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index 5bbf4a7e..abd922fd 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -156,11 +156,7 @@ 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", () => { + test("translate before a box-shadow lands in transform", () => { registerCSS( `.my-class { translate: 10px 20px; box-shadow: 1px 1px blue; }`, ); diff --git a/src/__tests__/vendor/tailwind/typography.test.tsx b/src/__tests__/vendor/tailwind/typography.test.tsx index c286a3ca..3ff4e94a 100644 --- a/src/__tests__/vendor/tailwind/typography.test.tsx +++ b/src/__tests__/vendor/tailwind/typography.test.tsx @@ -322,9 +322,7 @@ 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. + // currentColor on `color` is `inherit` (css-color-3 §4.4), so text-inherit renders as text-current does expect(await renderCurrentTest()).toStrictEqual({ props: { style: { From 3454d14131cd79f43fda4042fa4d99ec852606cc Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 18:59:47 +0300 Subject: [PATCH 05/17] fix: resolve CSS-wide keywords as css-cascade-5 defines them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CSS-wide keyword is a value only on its own (css-values-4 §2.1), so the compiler resolves one where it is a declaration's whole value or a var() fallback's, and rejects one inside a longer value. `revert` and `revert-layer` roll back to `unset`, as React Native has no user-agent or user origin. `unset` is `inherit` on `color` and on a custom property and `initial` elsewhere, and `initial` clears the property to React Native's default. A custom property left to inherit publishes nothing, so a read finds its ancestor's value, and its warnings now name it. A shorthand cleared by a keyword resets every longhand it sets, and a cleared filter is none. An element's inherited colour comes from its last rule that publishes one, so a later `color` that inherits no longer leaves an earlier rule's colour for its subtree, and a pseudo-element's colour stays its own. --- src/__tests__/compiler/compiler.test.tsx | 206 +++++++++++++----- src/__tests__/native/colors.test.tsx | 137 +++++++----- .../native/css-wide-keywords.test.tsx | 94 ++++++++ src/compiler/declarations.ts | 173 ++++++++++++--- src/compiler/pseudo-elements.ts | 19 +- src/native/react/rules.ts | 22 +- src/native/styles/calculate-props.ts | 15 +- src/native/styles/functions/filters.ts | 2 +- src/native/styles/shorthands/_handler.ts | 30 +++ .../styles/shorthands/logical-border-width.ts | 5 + src/utilities/inherited-color.ts | 39 +++- 11 files changed, 590 insertions(+), 152 deletions(-) create mode 100644 src/__tests__/native/css-wide-keywords.test.tsx diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 18fed71a..9923bc5a 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -361,11 +361,12 @@ test("simplifies rem", () => { }); }); -describe("CSS-wide color keywords", () => { +// Chromium 153, Firefox 155 and WebKit 26.6 compute each keyword below as css-cascade-5 §7.3 defines it +describe("CSS-wide keywords", () => { const stylesheetFor = (value: string) => compile(`.child { color: ${value}; }`).stylesheet(); - test("color: inherit compiles to the inherited-color lookup and publishes nothing", () => { + test("color: inherit compiles to the inherited-color lookup and publishes it", () => { expect(stylesheetFor("inherit")).toStrictEqual({ s: [ [ @@ -375,6 +376,7 @@ describe("CSS-wide color keywords", () => { s: [1, 1], d: [[[{}, "var", "__rn-css-color"], "color", 1]], dv: 1, + v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]], }, ], ], @@ -389,7 +391,7 @@ describe("CSS-wide color keywords", () => { ); }); - test("color: currentcolor compiles to the inherited-color lookup and publishes nothing", () => { + test("color: currentcolor compiles to the inherited-color lookup and publishes it", () => { expect(stylesheetFor("currentcolor")).toStrictEqual({ s: [ [ @@ -399,6 +401,7 @@ describe("CSS-wide color keywords", () => { s: [1, 1], d: [[[{}, "var", "__rn-css-color"], "color", 1]], dv: 1, + v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]], }, ], ], @@ -423,59 +426,150 @@ describe("CSS-wide color keywords", () => { }); }); - test("font-size: inherit is dropped", () => { - expect( - compile(`.child { font-size: inherit; }`).stylesheet(), - ).toStrictEqual({}); + test.each(["unset", "revert", "revert-layer"])( + "color: %s compiles like color: inherit", + (keyword) => { + // `unset` on an inherited property is `inherit` (css-cascade-5 §7.3.3), and with no user-agent or user origin `revert` is `unset` (§7.3.4) + expect(stylesheetFor(keyword)).toStrictEqual(stylesheetFor("inherit")); + }, + ); + + test("color: initial compiles to the literal the runtime clears with", () => { + expect(stylesheetFor("initial")).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [["unset", "color"]], + v: [["__rn-css-color", "unset"]], + }, + ], + ], + ], + }); }); - test("border-color: inherit is dropped", () => { - // Only `color` publishes its value to descendants, so only `color` can inherit it + const otherProperties = [ + ["background-color", "backgroundColor"], + ["border-color", "borderColor"], + ["font-size", "fontSize"], + ["opacity", "opacity"], + ] as const; + + test.each(otherProperties)("%s: inherit is dropped", (property) => { + // Only `color` and custom properties carry a value to descendants here expect( - compile(`.child { border-color: inherit; }`).stylesheet(), + compile(`.child { ${property}: inherit; }`).stylesheet(), ).toStrictEqual({}); }); - test("color: initial is dropped", () => { - expect(stylesheetFor("initial")).toStrictEqual({}); - }); + test.each( + otherProperties.flatMap(([property, nativeProperty]) => + (["initial", "unset", "revert", "revert-layer"] as const).map( + (keyword) => [property, keyword, nativeProperty] as const, + ), + ), + )( + "%s: %s compiles to the literal the runtime clears with", + (property, keyword, nativeProperty) => { + expect( + declarationsFor(`.child { ${property}: ${keyword}; }`), + ).toStrictEqual([["unset", nativeProperty]]); + }, + ); 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", (property, keyword) => { + ["box-shadow", "boxShadow"], + ["text-shadow", "textShadow"], + ] as const)( + "%s: initial hands the literal to the shorthand's runtime parser", + (property, nativeProperty) => { + expect(declarationsFor(`.child { ${property}: initial; }`)).toStrictEqual( + [[[{}, nativeProperty, "unset", 1], nativeProperty, 1]], + ); + }, + ); + + test("box-shadow: inherit is dropped rather than handed to the shorthand", () => { expect( - compile(`.child { ${property}: ${keyword}; }`).stylesheet(), + compile(`.child { box-shadow: inherit; }`).stylesheet(), ).toStrictEqual({}); }); - test("background-color: unset keeps its literal", () => { - // `unset` on a non-inherited property is `initial` (css-cascade-5 §7.3.3), and the runtime clears a value with the literal - expect( - compile(`.child { background-color: unset; }`).stylesheet(), - ).toStrictEqual({ - s: [["child", [{ s: [1, 1], d: [["unset", "backgroundColor"]] }]]], - }); - }); + test.each([ + ["object-fit", "initial", ["contentFit"]], + ["object-position", "revert", ["contentPosition"]], + ["corner-shape", "unset", "borderCurve"], + ["font-variant", "revert-layer", "fontVariant"], + ["-webkit-line-clamp", "initial", ["numberOfLines"]], + ["outline-offset", "initial", "outlineOffset"], + ] as const)( + "%s: %s compiles to the literal the runtime clears with", + (property, keyword, nativeProperty) => { + expect( + declarationsFor(`.child { ${property}: ${keyword}; }`), + ).toStrictEqual([["unset", nativeProperty]]); + }, + ); + + test.each(["object-fit", "corner-shape"])( + "%s: inherit is dropped with a warning naming it", + (property) => { + const compiled = compile(`.child { ${property}: inherit; }`); + + expect(compiled.stylesheet()).toStrictEqual({}); + expect(compiled.warnings()).toStrictEqual({ + values: { [property]: ["inherit"] }, + }); + }, + ); + + test.each(["color-mix(in srgb, inherit, blue)", "light-dark(inherit, blue)"])( + "color: %s is dropped, as a CSS-wide keyword is only ever a whole value", + (value) => { + expect(stylesheetFor(value)).toStrictEqual({}); + }, + ); + + test.each([ + ["inherit", [{}, "var", "__rn-css-color"]], + ["INITIAL", "unset"], + ] as const)( + "color: var(--missing, %s) falls back to what the keyword resolves to", + (keyword, fallback) => { + // Substituted, a fallback is the declaration's whole value (css-variables-1 §3) + expect( + declarationsFor(`.child { color: var(--missing, ${keyword}); }`), + ).toStrictEqual([[[{}, "var", ["missing", fallback], 1], "color", 1]]); + }, + ); // A second declaration keeps `--brand` from being inlined, so the keyword reaches the compiler on the custom property const uninlinedCustomProperty = (value: string) => `.child { --brand: ${value}; color: var(--brand); } .other { --brand: ${value}; }`; - test.each(["inherit", "initial", "revert", "revert-layer", "INHERIT"])( - "--brand: %s is dropped, as a custom property has no property to inherit", + test.each(["inherit", "unset", "revert", "revert-layer", "INHERIT"])( + "--brand: %s publishes nothing, so a read finds the ancestor's value", (keyword) => { - expect( - publishedVariable(uninlinedCustomProperty(keyword), "brand"), - ).toEqual([]); + const css = uninlinedCustomProperty(keyword); + + expect(publishedVariable(css, "brand")).toEqual([]); + expect(compile(css).warnings()).toStrictEqual({}); }, ); + test("--brand: initial is dropped with a warning", () => { + const css = uninlinedCustomProperty("initial"); + + expect(publishedVariable(css, "brand")).toEqual([]); + expect(compile(css).warnings()).toStrictEqual({ + values: { "--brand": ["initial", "initial"] }, + }); + }); + test.each(["currentcolor", "currentColor"])( "--brand: %s compiles to the inherited-color lookup", (spelling) => { @@ -489,12 +583,6 @@ describe("CSS-wide color keywords", () => { }, ); - test("--brand: unset keeps its literal", () => { - expect( - publishedVariable(uninlinedCustomProperty("unset"), "brand"), - ).toEqual(["unset", "unset"]); - }); - test("a custom property declared once is inlined, so its keyword resolves on color", () => { expect( compile(`.child { --brand: inherit; color: var(--brand); }`).stylesheet(), @@ -507,6 +595,7 @@ describe("CSS-wide color keywords", () => { s: [1, 1], d: [[[{}, "var", "__rn-css-color"], "color", 1]], dv: 1, + v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]], }, ], ], @@ -514,11 +603,6 @@ describe("CSS-wide color keywords", () => { }); }); - test("color: unset compiles like color: inherit", () => { - // `unset` on an inherited property is `inherit` (css-cascade-5 §7.3.3) - expect(stylesheetFor("unset")).toStrictEqual(stylesheetFor("inherit")); - }); - test.each(["INHERIT", "Inherit", "UNSET", "INITIAL"])( "color: %s compiles like its lowercase spelling", (spelling) => { @@ -539,6 +623,7 @@ describe("CSS-wide color keywords", () => { s: [1, 1], d: [[[{}, "var", "__rn-css-color"], "color", 1]], dv: 1, + v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]], }, ], ], @@ -575,6 +660,7 @@ describe("CSS-wide color keywords", () => { s: [1, 1, 1], d: [[[{}, "var", "__rn-css-color"], "color", 1]], dv: 1, + v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]], }, ], ], @@ -597,6 +683,7 @@ describe("CSS-wide color keywords", () => { m: [[">=", "width", 100]], d: [[[{}, "var", "__rn-css-color"], "color", 1]], dv: 1, + v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]], }, ], ], @@ -616,6 +703,7 @@ describe("CSS-wide color keywords", () => { s: [1, 2], d: [[[{}, "var", "__rn-css-color"], "color", 1]], dv: 1, + v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]], p: { h: 1 }, }, ], @@ -631,14 +719,6 @@ describe("CSS-wide color keywords", () => { [[{}, "var", "__rn-css-color"], ["placeholderTextColor"], 1], ]); }); - - test.each(["revert", "revert-layer"])( - "color: %s is dropped rather than published as a literal", - (keyword) => { - // React Native has no cascade origins, so `revert` has no value to roll back to - expect(stylesheetFor(keyword)).toStrictEqual({}); - }, - ); }); function declarationsFor(css: string): StyleDeclaration[] { @@ -661,9 +741,9 @@ function publishedInheritedColors(css: string): StyleDescriptor[] { return publishedVariable(css, "__rn-css-color"); } -describe("the inherited-color variable is never self-referential", () => { - // Each reads the inherited colour somewhere in its descriptor, so publishing it would hand a descendant a lookup into itself - const selfReferentialColors = [ +describe("the inherited-color variable is never a lookup into itself", () => { + // Each reads the inherited colour somewhere in its descriptor, so it publishes the bare lookup, which the runtime answers with what the element inherited + const colorsThatReadTheInheritedColor = [ "inherit", "unset", "currentcolor", @@ -671,14 +751,20 @@ describe("the inherited-color variable is never self-referential", () => { "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.each(selfReferentialColors)("color: %s publishes no `v`", (value) => { - expect(publishedInheritedColors(`.child { color: ${value}; }`)).toEqual([]); - }); + test.each(colorsThatReadTheInheritedColor)( + "color: %s publishes the bare lookup", + (value) => { + const published = publishedInheritedColors(`.child { color: ${value}; }`); + + expect(new Set(published.map((entry) => JSON.stringify(entry)))).toEqual( + new Set([JSON.stringify([{}, "var", "__rn-css-color"])]), + ); + }, + ); test.each([ ["red", "#f00"], diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx index b78c5412..7c878afc 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -283,19 +283,23 @@ describe("inherit", () => { }, ); - test("color: INITIAL is case-folded into the drop, not into the lookup", () => { + test("color: INITIAL clears an earlier rule's color", () => { + // React Native's default text color stands in for the initial value registerCSS(` .parent { color: red; } + .earlier { color: green; } .child { color: INITIAL; } `); render( - + , ); - expect(screen.getByTestId("child").props.style).toBeUndefined(); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: undefined, + }); }); test("a descendant override restarts the chain", () => { @@ -425,45 +429,6 @@ describe("inherit", () => { }, ); - test.each(["border-color", "background-color"])( - "%s: inherit is dropped", - (property) => { - registerCSS(` - .parent { color: red; } - .child { ${property}: inherit; } - `); - - render( - - - , - ); - - expect(screen.getByTestId("child").props.style).toBeUndefined(); - }, - ); - - test("background-color: unset clears an earlier rule's color", () => { - 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 reads the root's label color", () => { registerCSS(`.child { color: inherit; }`); @@ -634,31 +599,106 @@ describe("inherit", () => { }); }); - test.each(["revert", "revert-layer"])( - "color: %s publishes nothing to descendants", + test.each(["inherit", "unset", "currentcolor", "revert", "revert-layer"])( + "color: %s over an earlier rule hands its subtree the parent's color", (keyword) => { - // React Native has no cascade origins, so `revert` has no value to roll back to + // With no user-agent or user origin, `revert` is `unset` (css-cascade-5 §7.3.4) registerCSS(` .parent { color: red; } - .mid { color: ${keyword}; } + .earlier { color: green; } + .mid { color: ${keyword}; border-color: currentcolor; } .child { color: inherit; } `); render( - + , ); - expect(screen.getByTestId("mid").props.style).toBeUndefined(); + expect(screen.getByTestId("mid").props.style).toStrictEqual({ + color: "#f00", + borderColor: "#f00", + }); expect(screen.getByTestId("child").props.style).toStrictEqual({ color: "#f00", }); }, ); + test("a ::placeholder color leaves the element's own color", () => { + registerCSS(` + .parent { color: blue; } + .child { border-color: currentcolor; } + .child::placeholder { color: red; } + .grandchild { color: inherit; } + `); + + render( + + + + + , + ); + + const child = screen.getByTestId("child"); + + expect(child.props.placeholderTextColor).toBe("#f00"); + expect(child.props.style).toStrictEqual({ borderColor: "#00f" }); + expect(screen.getByTestId("grandchild").props.style).toStrictEqual({ + color: "#00f", + }); + }); + + test.each(["color-mix(in srgb, inherit, blue)", "light-dark(inherit, blue)"])( + "color: %s leaves an earlier rule's color", + (value) => { + // A CSS-wide keyword is only ever a whole value, so this declaration is invalid (css-values-4 §2.1) + registerCSS(` + .parent { color: red; } + .earlier { color: green; } + .child { color: ${value}; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#008000", + }); + }, + ); + + test.each([ + ["inherit", "#f00"], + ["initial", undefined], + ] as const)( + "color: var(--missing, %s) applies the keyword over an earlier rule", + (keyword, expectedColor) => { + registerCSS(` + .parent { color: red; } + .earlier { color: green; } + .child { color: var(--missing, ${keyword}); } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: expectedColor, + }); + }, + ); + test("a light-dark() ancestor's color is inherited by a descendant", () => { registerCSS(` .parent { color: light-dark(red, blue); } @@ -689,7 +729,6 @@ const colorsThatReadTheInheritedColor: [css: string, midColor: string][] = [ ["var(--missing, currentcolor)", "#f00"], ["light-dark(currentcolor, blue)", "#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)"], ]; describe("a color that reads the inherited color", () => { diff --git a/src/__tests__/native/css-wide-keywords.test.tsx b/src/__tests__/native/css-wide-keywords.test.tsx new file mode 100644 index 00000000..2744f13f --- /dev/null +++ b/src/__tests__/native/css-wide-keywords.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS } from "react-native-css/jest"; + +const resettingKeywords = ["initial", "unset", "revert", "revert-layer"]; + +const clearedProperties: readonly (readonly [ + property: string, + earlier: string, + cleared: Record, +])[] = [ + ["background-color", "red", { backgroundColor: undefined }], + ["opacity", "0.5", { opacity: undefined }], + ["box-shadow", "1px 1px red", { boxShadow: undefined }], + ["filter", "blur(2px)", { filter: undefined }], + ["transform", "translateX(10px)", {}], + ["line-height", "30px", {}], + [ + "text-shadow", + "1px 1px red", + { + textShadowColor: undefined, + textShadowOffset: undefined, + textShadowRadius: undefined, + }, + ], + [ + "border", + "2px solid red", + { borderWidth: undefined, borderStyle: undefined, borderColor: undefined }, + ], +]; + +function renderStyle(css: string): unknown { + registerCSS(css); + + render(); + + return screen.getByTestId("child").props.style; +} + +// Chromium 153, Firefox 155 and WebKit 26.6 reset each of these to its initial value, which React Native's default matches +test.each( + clearedProperties.flatMap(([property, earlier, cleared]) => + resettingKeywords.map( + (keyword) => [property, keyword, earlier, cleared] as const, + ), + ), +)( + "%s: %s clears an earlier rule's value", + (property, keyword, earlier, cleared) => { + expect( + renderStyle( + `.earlier { ${property}: ${earlier}; } .child { ${property}: ${keyword}; }`, + ), + ).toStrictEqual(cleared); + }, +); + +test.each(resettingKeywords)( + "border-block-width: %s resets to React Native's default width", + (keyword) => { + expect( + renderStyle( + `.earlier { border-block-width: 3px; } .child { border-block-width: ${keyword}; }`, + ), + ).toStrictEqual({ + borderTopWidth: undefined, + borderBottomWidth: undefined, + }); + }, +); + +// Chromium 153, Firefox 155 and WebKit 26.6 read the ancestor's value for each, as a custom property inherits (css-variables-1 §2) +test.each(["inherit", "unset", "revert", "revert-layer"])( + "--brand: %s reads the ancestor's --brand", + (keyword) => { + registerCSS(` + .parent { --brand: green; } + .child { --brand: ${keyword}; color: var(--brand); } + .other { --brand: ${keyword}; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "green", + }); + }, +); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 208da2e6..ef24c56f 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -987,17 +987,23 @@ export function parseUnparsedDeclaration( */ builder.descriptorProperty = property; - if (unparsedRuntimeParsing.has(property)) { - const args = parseUnparsed(declaration.value.value, builder, property); + const keyword = soleCSSWideKeyword(declaration.value.value); + const value = + keyword === undefined + ? parseUnparsed(declaration.value.value, builder, property) + : parseCSSWideKeyword(keyword, property, builder); + if (keyword !== undefined && value === undefined) { + return; + } + + if (unparsedRuntimeParsing.has(property)) { if (property === "animation") { - builder.addDescriptor("animation", [{}, property, args]); + builder.addDescriptor("animation", [{}, property, value]); } else { - builder.addDescriptor(property, [{}, toRNProperty(property), args, 1]); + builder.addDescriptor(property, [{}, toRNProperty(property), value, 1]); } } else { - const value = parseUnparsed(declaration.value.value, builder, property); - builder.addDescriptor(property, value); if (property === "font-size") { @@ -1015,16 +1021,115 @@ function inheritedColorLookup() { return [{}, "var", INHERITED_COLOR_VARIABLE] as const satisfies StyleFunction; } -// A descendant resolves a published colour lazily, so one that reads the channel it is published under would resolve into itself -function publishInheritedColor( - value: StyleDescriptor, +const CSS_WIDE_KEYWORDS = [ + "inherit", + "initial", + "unset", + "revert", + "revert-layer", +] as const; + +type CSSWideKeyword = (typeof CSS_WIDE_KEYWORDS)[number]; + +function isCSSWideKeyword(keyword: string): keyword is CSSWideKeyword { + return (CSS_WIDE_KEYWORDS as readonly string[]).includes(keyword); +} + +// A CSS-wide keyword is a value only on its own (css-values-4 §2.1) +function soleCSSWideKeyword( + tokens: TokenOrValue[] | null | undefined, +): CSSWideKeyword | undefined { + const [token, ...rest] = (tokens ?? []).filter( + (candidate) => + candidate.type !== "token" || candidate.value.type !== "white-space", + ); + + if ( + token === undefined || + rest.length > 0 || + token.type !== "token" || + token.value.type !== "ident" + ) { + return; + } + + const keyword = token.value.value.toLowerCase(); + + return isCSSWideKeyword(keyword) ? keyword : undefined; +} + +function parseCSSWideKeyword( + keyword: CSSWideKeyword, + property: string, builder: StylesheetBuilder, -) { - if (readsInheritedColor(value)) { +): StyleDescriptor { + const isCustomProperty = property.startsWith("--"); + // Only `color` and custom properties reach descendants here, so only they inherit where `unset` would (css-cascade-5 §7.3.3) + const inherits = isCustomProperty || property === "color"; + // React Native has no user-agent or user origin, so `revert` and `revert-layer` roll back to `unset` (css-cascade-5 §7.3.4, §7.3.5) + const resolved = + keyword === "inherit" || keyword === "initial" + ? keyword + : inherits + ? "inherit" + : "initial"; + + if (resolved === "inherit" && property === "color") { + return inheritedColorLookup(); + } else if (resolved === "inherit" && isCustomProperty) { + // An undeclared custom property already reads its ancestor's value return; + } else if (resolved === "initial" && !isCustomProperty) { + // The literal the runtime clears with, which leaves React Native's default to stand in for the initial value + return "unset"; } - builder.addDescriptor(`--${INHERITED_COLOR_VARIABLE}`, value); + builder.addWarning("value", keyword); + return; +} + +function parseDeclarationValue( + tokens: TokenOrValue[] | null | undefined, + builder: StylesheetBuilder, + property: string, + allowAuto?: boolean, +): StyleDescriptor { + const keyword = soleCSSWideKeyword(tokens); + + return keyword === undefined + ? parseUnparsed(tokens, builder, property, allowAuto) + : parseCSSWideKeyword(keyword, property, builder); +} + +function addCSSWideKeyword( + tokens: TokenOrValue[], + property: string, + builder: StylesheetBuilder, + nativeProperty = property, +): boolean { + const keyword = soleCSSWideKeyword(tokens); + + if (keyword === undefined) { + return false; + } + + builder.addDescriptor( + nativeProperty, + parseCSSWideKeyword(keyword, property, builder), + ); + + return true; +} + +// A descendant resolves a published colour lazily, so one that reads the inherited colour publishes the bare lookup, which the runtime answers with what the element inherited +function publishInheritedColor( + value: StyleDescriptor, + builder: StylesheetBuilder, +) { + builder.addDescriptor( + `--${INHERITED_COLOR_VARIABLE}`, + readsInheritedColor(value) ? inheritedColorLookup() : value, + ); } // A fallback, a function argument or a light-dark() arm can hold the read, so the whole tree is walked @@ -1046,13 +1151,15 @@ export function parseCustomDeclaration( ) { const property = declaration.value.name; + builder.setWarningProperty(property); + if (isMappingProperty(property)) { // Consumed before declarations so property order cannot affect mapping. return; } else if (property === "-webkit-line-clamp") { builder.addDescriptor( property, - parseUnparsed(declaration.value.value, builder, property), + parseDeclarationValue(declaration.value.value, builder, property), ); } else if (property === "-rn-ripple-style") { if ( @@ -1076,11 +1183,15 @@ export function parseCustomDeclaration( // https://github.com/parcel-bundler/lightningcss/issues/1048 builder.addDescriptor( property, - parseUnparsed(declaration.value.value, builder, property), + parseDeclarationValue(declaration.value.value, builder, property), ); } else if (property === "corner-shape") { parseCornerShape(declaration.value, builder); } else if (property === "font-variant") { + if (addCSSWideKeyword(declaration.value.value, property, builder)) { + return; + } + const parsedFontVariant = parseFontVariantCaps( parseUnparsed( declaration.value.value, @@ -1099,7 +1210,7 @@ export function parseCustomDeclaration( ) { builder.addDescriptor( property, - parseUnparsed(declaration.value.value, builder, property), + parseDeclarationValue(declaration.value.value, builder, property), ); } else { builder.addWarning("property", declaration.value.name); @@ -1228,7 +1339,8 @@ export function parseUnparsed( } case "var": { let args: StyleDescriptor = tokenOrValue.value.name.ident.slice(2); - const fallback = parseUnparsed( + // Substituted, a fallback is the declaration's whole value, so it can be a CSS-wide keyword (css-variables-1 §3) + const fallback = parseDeclarationValue( tokenOrValue.value.fallback, builder, property, @@ -1326,22 +1438,12 @@ export function parseUnparsed( // Keywords are ASCII case-insensitive (css-values-4 §4.1), and lightningcss hands an unparsed one through unfolded const keyword = value.toLowerCase(); - // currentcolor is the inherited colour on `color` (css-color-3 §4.4), and `unset` on an inherited property is `inherit` (css-cascade-5 §7.3.3) - if ( - keyword === "currentcolor" || - ((keyword === "inherit" || keyword === "unset") && - property === "color") - ) { + if (keyword === "currentcolor") { return inheritedColorLookup(); } - // React Native has no inheritance context, initial value or cascade origin for these; `unset` stays, as the literal the runtime clears with - if ( - keyword === "inherit" || - keyword === "initial" || - keyword === "revert" || - keyword === "revert-layer" - ) { + // Inside a longer value a CSS-wide keyword is invalid (css-values-4 §2.1) + if (isCSSWideKeyword(keyword)) { builder.addWarning("value", value); return; } @@ -3293,7 +3395,7 @@ function parseObjectFit( }); builder.addDescriptor( "object-fit", - parseUnparsed(declaration.value, builder, "object-fit"), + parseDeclarationValue(declaration.value, builder, "object-fit"), ); } @@ -3304,6 +3406,11 @@ function parseObjectPosition( builder.addMapping({ "object-position": ["contentPosition"], }); + + if (addCSSWideKeyword(declaration.value, "object-position", builder)) { + return; + } + builder.addDescriptor("object-position", [ {}, "join", @@ -3315,6 +3422,12 @@ function parseCornerShape( declaration: CustomProperty, builder: StylesheetBuilder, ) { + if ( + addCSSWideKeyword(declaration.value, "corner-shape", builder, "borderCurve") + ) { + return; + } + const shape = parseUnparsed(declaration.value, builder, "corner-shape"); if (shape === "round") { diff --git a/src/compiler/pseudo-elements.ts b/src/compiler/pseudo-elements.ts index d379da8a..e754be44 100644 --- a/src/compiler/pseudo-elements.ts +++ b/src/compiler/pseudo-elements.ts @@ -1,4 +1,4 @@ -import { isStyleFunction } from "../utilities"; +import { INHERITED_COLOR_VARIABLE, isStyleFunction } from "../utilities"; import type { StyleDeclaration, StyleRule } from "./compiler.types"; export function modifyRuleForSelection(rule: StyleRule): StyleRule | undefined { @@ -10,7 +10,7 @@ export function modifyRuleForSelection(rule: StyleRule): StyleRule | undefined { return modifyStyleDeclaration(declaration, "color", "selectionColor"); }); - return rule; + return withoutInheritedColor(rule); } export function modifyRuleForPlaceholder( @@ -24,6 +24,21 @@ export function modifyRuleForPlaceholder( return modifyStyleDeclaration(declaration, "color", "placeholderTextColor"); }); + return withoutInheritedColor(rule); +} + +// A pseudo-element's colour is its own, so the element and its subtree keep theirs +function withoutInheritedColor(rule: StyleRule): StyleRule { + const variables = rule.v?.filter( + ([name]) => name !== INHERITED_COLOR_VARIABLE, + ); + + if (variables?.length) { + rule.v = variables; + } else { + delete rule.v; + } + return rule; } diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index e0fe8d1a..c621f0c5 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -1,6 +1,14 @@ /* eslint-disable */ -import type { InlineVariable, StyleRule } from "react-native-css/compiler"; +import type { + InlineVariable, + StyleDescriptor, + StyleRule, +} from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native-internal"; +import { + assignPublishedColor, + assignRuleVariables, +} from "react-native-css/utilities"; import { testRule } from "../conditions"; import { DEFAULT_CONTAINER_NAME } from "../conditions/container-query"; @@ -41,6 +49,7 @@ export function updateRules( let usesVariables = false; let variables: VariableContextValue | undefined; + let publishedColor: StyleDescriptor | undefined; let containers: ContainerContextValue | undefined; const inlineVariables = new Set(); @@ -131,13 +140,12 @@ export function updateRules( } if (rule.v) { - if (variables === inheritedVariables) { + if (variables === undefined || variables === inheritedVariables) { variables = { ...inheritedVariables }; } - for (const v of rule.v) { - variables![v[0]] = v[1]; - } + publishedColor = + assignRuleVariables(variables, rule.v) ?? publishedColor; } if (rule.c) { @@ -206,6 +214,10 @@ export function updateRules( } } + if (variables !== undefined && variables !== inheritedVariables) { + assignPublishedColor(variables, publishedColor); + } + pressable = activeFamily.has(state.ruleEffectGetter); if (!rules.size && !state.stylesObs && !inlineVariables.size) { diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 57bf3c47..c082a08e 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -2,9 +2,14 @@ import type { InlineVariable, StyleDeclaration, + StyleDescriptor, StyleRule, } from "react-native-css/compiler"; -import { Specificity as S } from "react-native-css/utilities"; +import { + assignPublishedColor, + assignRuleVariables, + Specificity as S, +} from "react-native-css/utilities"; import type { RenderGuard } from "../conditions/guards"; import { applyValue, getDeepPath } from "../objects"; @@ -32,6 +37,7 @@ export function calculateProps( const delayedStyles: (() => void)[] = []; const transformStyles: (() => void)[] = []; + let publishedColor: StyleDescriptor | undefined; for (const rule of rules) { if (VAR_SYMBOL in rule) { @@ -44,9 +50,8 @@ export function calculateProps( } if (rule.v) { - for (const variable of rule.v) { - inlineVariables[variable[0]] = variable[1]; - } + publishedColor = + assignRuleVariables(inlineVariables, rule.v) ?? publishedColor; } if (rule.d) { @@ -79,6 +84,8 @@ export function calculateProps( } } + assignPublishedColor(inlineVariables, publishedColor); + for (const delayedStyle of delayedStyles) { delayedStyle(); } diff --git a/src/native/styles/functions/filters.ts b/src/native/styles/functions/filters.ts index 4a88a98a..726f8e1d 100644 --- a/src/native/styles/functions/filters.ts +++ b/src/native/styles/functions/filters.ts @@ -108,6 +108,6 @@ export const filter: StyleFunctionResolver = (resolveValue, descriptor) => { ); } if (value === undefined || value === "initial") return; - if (value === "none") return null; + if (value === null || value === "none") return null; return typeof value === "string" ? value : [value]; }; diff --git a/src/native/styles/shorthands/_handler.ts b/src/native/styles/shorthands/_handler.ts index b8147d1a..c98ee4fb 100644 --- a/src/native/styles/shorthands/_handler.ts +++ b/src/native/styles/shorthands/_handler.ts @@ -38,6 +38,10 @@ export function shorthandHandler( ? resolve(value[2]) : value; + if (args === null) { + return clearedLonghands(mappings, defaults, returnType); + } + if (!Array.isArray(args)) { return; } @@ -133,3 +137,29 @@ export function shorthandHandler( } }; } + +// The runtime resolves `unset` to null, and a shorthand reset that way resets every longhand it sets (css-cascade-5 §7.3.3) +function clearedLonghands( + mappings: ShorthandRequiredValue[][], + defaults: ShorthandDefaultValue[], + returnType: "shorthandObject" | "tuples" | "object", +) { + const longhands = new Set( + [...mappings.flat(), ...defaults].flatMap(([prop]) => + typeof prop === "string" ? [prop] : prop.slice(0, 1), + ), + ); + + if (returnType === "tuples") { + return Array.from(longhands, (prop) => [undefined, prop]); + } + + const target: Record = + returnType === "shorthandObject" ? { [ShortHandSymbol]: true } : {}; + + for (const prop of longhands) { + target[prop] = undefined; + } + + return target; +} diff --git a/src/native/styles/shorthands/logical-border-width.ts b/src/native/styles/shorthands/logical-border-width.ts index 147a3517..3913c12f 100644 --- a/src/native/styles/shorthands/logical-border-width.ts +++ b/src/native/styles/shorthands/logical-border-width.ts @@ -4,6 +4,11 @@ import type { StyleFunctionResolver } from "../resolve"; function logicalWidth(start: string, end: string): StyleFunctionResolver { return (resolve, descriptor) => { const resolved = resolve(descriptor[2]); + + if (resolved === null) { + return { [ShortHandSymbol]: true, [start]: undefined, [end]: undefined }; + } + const values: unknown[] = Array.isArray(resolved) ? resolved : [resolved]; if ( values.length < 1 || diff --git a/src/utilities/inherited-color.ts b/src/utilities/inherited-color.ts index f67c9926..d025a92b 100644 --- a/src/utilities/inherited-color.ts +++ b/src/utilities/inherited-color.ts @@ -1,4 +1,10 @@ -import type { StyleFunction } from "react-native-css/compiler"; +import type { + StyleDescriptor, + StyleFunction, + StyleRule, +} from "react-native-css/compiler"; + +import { isStyleFunction } from "./style-descriptor"; /** The custom property every `color` rule publishes to its subtree and `currentcolor` reads back */ export const INHERITED_COLOR_VARIABLE = "__rn-css-color"; @@ -13,3 +19,34 @@ export function isInheritedColorLookup(fn: StyleFunction): boolean { return name === INHERITED_COLOR_VARIABLE; } + +/** Assigns a rule's custom properties, and returns the inherited colour it publishes for `assignPublishedColor` */ +export function assignRuleVariables( + variables: Record, + entries: NonNullable, +): StyleDescriptor | undefined { + let color: StyleDescriptor | undefined; + + for (const [name, value] of entries) { + if (name === INHERITED_COLOR_VARIABLE) { + color = value; + } else { + variables[name] = value; + } + } + + return color; +} + +// The last rule's colour is the element's, and one that reads the inherited colour leaves the inherited one in place, as CSS inherits a computed value +export function assignPublishedColor( + variables: Record, + color: StyleDescriptor | undefined, +): void { + if ( + color !== undefined && + !(isStyleFunction(color) && isInheritedColorLookup(color)) + ) { + variables[INHERITED_COLOR_VARIABLE] = color; + } +} From a35348b5f222cd696fb35e3b42ec372b30058975 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 19:03:22 +0300 Subject: [PATCH 06/17] test: a comment beside a CSS-wide keyword leaves it the whole value The first pass prints the stylesheet before the second parses it, so no white space from a comment reaches the keyword, and the filter for it was unreachable. --- src/__tests__/compiler/compiler.test.tsx | 19 +++++++++++++++++++ src/compiler/declarations.ts | 5 +---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 9923bc5a..d6e3bd47 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -570,6 +570,25 @@ describe("CSS-wide keywords", () => { }); }); + test("a comment beside a keyword leaves the keyword the whole value", () => { + expect(stylesheetFor("/* reset */ inherit")).toStrictEqual( + stylesheetFor("inherit"), + ); + expect( + declarationsFor( + `.child { color: var(--missing, /* reset */ inherit /* here */); }`, + ), + ).toStrictEqual( + declarationsFor(`.child { color: var(--missing, inherit); }`), + ); + expect( + publishedVariable( + uninlinedCustomProperty("/* reset */ inherit"), + "brand", + ), + ).toEqual([]); + }); + test.each(["currentcolor", "currentColor"])( "--brand: %s compiles to the inherited-color lookup", (spelling) => { diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index ef24c56f..bffc9519 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -1039,10 +1039,7 @@ function isCSSWideKeyword(keyword: string): keyword is CSSWideKeyword { function soleCSSWideKeyword( tokens: TokenOrValue[] | null | undefined, ): CSSWideKeyword | undefined { - const [token, ...rest] = (tokens ?? []).filter( - (candidate) => - candidate.type !== "token" || candidate.value.type !== "white-space", - ); + const [token, ...rest] = tokens ?? []; if ( token === undefined || From 487490b2e779cac966ed33a50a8b0c6ada32b8c4 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 19:15:05 +0300 Subject: [PATCH 07/17] fix(compiler): ignore a declaration whose value cannot be parsed A shorthand parsed at runtime was registered with an empty argument when its value failed to parse, which cleared an earlier rule's value; browsers ignore an invalid declaration, so it is left out. The CSS-wide keyword cases join the existing keyword suite. --- .../native/css-wide-keywords.test.tsx | 94 ------------- src/__tests__/native/keywords.test.tsx | 133 ++++++++++++++++-- src/compiler/declarations.ts | 3 +- 3 files changed, 122 insertions(+), 108 deletions(-) delete mode 100644 src/__tests__/native/css-wide-keywords.test.tsx diff --git a/src/__tests__/native/css-wide-keywords.test.tsx b/src/__tests__/native/css-wide-keywords.test.tsx deleted file mode 100644 index 2744f13f..00000000 --- a/src/__tests__/native/css-wide-keywords.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { render, screen } from "@testing-library/react-native"; -import { View } from "react-native-css/components/View"; -import { registerCSS } from "react-native-css/jest"; - -const resettingKeywords = ["initial", "unset", "revert", "revert-layer"]; - -const clearedProperties: readonly (readonly [ - property: string, - earlier: string, - cleared: Record, -])[] = [ - ["background-color", "red", { backgroundColor: undefined }], - ["opacity", "0.5", { opacity: undefined }], - ["box-shadow", "1px 1px red", { boxShadow: undefined }], - ["filter", "blur(2px)", { filter: undefined }], - ["transform", "translateX(10px)", {}], - ["line-height", "30px", {}], - [ - "text-shadow", - "1px 1px red", - { - textShadowColor: undefined, - textShadowOffset: undefined, - textShadowRadius: undefined, - }, - ], - [ - "border", - "2px solid red", - { borderWidth: undefined, borderStyle: undefined, borderColor: undefined }, - ], -]; - -function renderStyle(css: string): unknown { - registerCSS(css); - - render(); - - return screen.getByTestId("child").props.style; -} - -// Chromium 153, Firefox 155 and WebKit 26.6 reset each of these to its initial value, which React Native's default matches -test.each( - clearedProperties.flatMap(([property, earlier, cleared]) => - resettingKeywords.map( - (keyword) => [property, keyword, earlier, cleared] as const, - ), - ), -)( - "%s: %s clears an earlier rule's value", - (property, keyword, earlier, cleared) => { - expect( - renderStyle( - `.earlier { ${property}: ${earlier}; } .child { ${property}: ${keyword}; }`, - ), - ).toStrictEqual(cleared); - }, -); - -test.each(resettingKeywords)( - "border-block-width: %s resets to React Native's default width", - (keyword) => { - expect( - renderStyle( - `.earlier { border-block-width: 3px; } .child { border-block-width: ${keyword}; }`, - ), - ).toStrictEqual({ - borderTopWidth: undefined, - borderBottomWidth: undefined, - }); - }, -); - -// Chromium 153, Firefox 155 and WebKit 26.6 read the ancestor's value for each, as a custom property inherits (css-variables-1 §2) -test.each(["inherit", "unset", "revert", "revert-layer"])( - "--brand: %s reads the ancestor's --brand", - (keyword) => { - registerCSS(` - .parent { --brand: green; } - .child { --brand: ${keyword}; color: var(--brand); } - .other { --brand: ${keyword}; } - `); - - render( - - - , - ); - - expect(screen.getByTestId("child").props.style).toStrictEqual({ - color: "green", - }); - }, -); diff --git a/src/__tests__/native/keywords.test.tsx b/src/__tests__/native/keywords.test.tsx index f1b3defc..08ec6ec0 100644 --- a/src/__tests__/native/keywords.test.tsx +++ b/src/__tests__/native/keywords.test.tsx @@ -1,17 +1,124 @@ -import { View } from "react-native"; - -import { renderHook } from "@testing-library/react-native"; +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; import { registerCSS } from "react-native-css/jest"; -import { useNativeCss } from "react-native-css/native"; -test("unset", () => { - registerCSS(`.my-class { background-color: unset; }`); +const resettingKeywords = ["initial", "unset", "revert", "revert-layer"]; + +const clearedProperties: readonly (readonly [ + property: string, + earlier: string, + cleared: Record, +])[] = [ + ["background-color", "red", { backgroundColor: undefined }], + ["opacity", "0.5", { opacity: undefined }], + ["box-shadow", "1px 1px red", { boxShadow: undefined }], + ["filter", "blur(2px)", { filter: undefined }], + ["transform", "translateX(10px)", {}], + ["line-height", "30px", {}], + [ + "text-shadow", + "1px 1px red", + { + textShadowColor: undefined, + textShadowOffset: undefined, + textShadowRadius: undefined, + }, + ], + [ + "border", + "2px solid red", + { borderWidth: undefined, borderStyle: undefined, borderColor: undefined }, + ], +]; + +function renderStyle(css: string): unknown { + registerCSS(css); + + render(); + + return screen.getByTestId("child").props.style; +} + +// Chromium 153, Firefox 155 and WebKit 26.6 reset each of these to its initial value, which React Native's default matches +test.each( + clearedProperties.flatMap(([property, earlier, cleared]) => + resettingKeywords.map( + (keyword) => [property, keyword, earlier, cleared] as const, + ), + ), +)( + "%s: %s clears an earlier rule's value", + (property, keyword, earlier, cleared) => { + expect( + renderStyle( + `.earlier { ${property}: ${earlier}; } .child { ${property}: ${keyword}; }`, + ), + ).toStrictEqual(cleared); + }, +); + +test.each(resettingKeywords)( + "border-block-width: %s resets to React Native's default width", + (keyword) => { + expect( + renderStyle( + `.earlier { border-block-width: 3px; } .child { border-block-width: ${keyword}; }`, + ), + ).toStrictEqual({ + borderTopWidth: undefined, + borderBottomWidth: undefined, + }); + }, +); + +// Chromium 153, Firefox 155 and WebKit 26.6 read the ancestor's value for each, as a custom property inherits (css-variables-1 §2) +test.each(["inherit", "unset", "revert", "revert-layer"])( + "--brand: %s reads the ancestor's --brand", + (keyword) => { + registerCSS(` + .parent { --brand: green; } + .child { --brand: ${keyword}; color: var(--brand); } + .other { --brand: ${keyword}; } + `); + + render( + + + , + ); - const { result } = renderHook(() => { - return useNativeCss(View, { className: "my-class" }); - }); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "green", + }); + }, +); - expect(result.current.props).toStrictEqual({ - style: { backgroundColor: undefined }, - }); -}); +// Chromium 153, Firefox 155 and WebKit 26.6 ignore each invalid declaration and keep the earlier value +test.each([ + ["filter", "blur(2px)", { filter: [{ blur: 2 }] }], + [ + "box-shadow", + "1px 1px red", + { + boxShadow: [ + { + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + color: "#f00", + }, + ], + }, + ], + ["transform", "translateX(10px)", { transform: [{ translateX: 10 }] }], +] as const)( + "%s: foo(1) leaves an earlier rule's value", + (property, earlier, kept) => { + expect( + renderStyle( + `.earlier { ${property}: ${earlier}; } .child { ${property}: foo(1); }`, + ), + ).toStrictEqual(kept); + }, +); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index bffc9519..bc62b9db 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -993,7 +993,8 @@ export function parseUnparsedDeclaration( ? parseUnparsed(declaration.value.value, builder, property) : parseCSSWideKeyword(keyword, property, builder); - if (keyword !== undefined && value === undefined) { + // A value the compiler cannot parse leaves the declaration invalid, so an earlier rule's value stands + if (value === undefined) { return; } From 5ccf375d3a2511da306bc90d02da223e34284ff7 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 19:15:13 +0300 Subject: [PATCH 08/17] fix(native): a var() that leaves none in a filter or transform leaves none A filter or transform list that a var() fills with `none`, a cleared value or `initial` is `none` alone or invalid at computed-value time, and either way the property is `none`; the resolvers returned the keyword inside the list. --- .../native/filter-list-runtime.test.tsx | 22 +++++++++++++++++++ src/__tests__/native/transform.test.tsx | 16 ++++++++++++++ src/native/styles/functions/filters.ts | 8 +++---- src/native/styles/resets-to-none.ts | 4 ++++ src/native/styles/shorthands/transform.ts | 7 +++--- 5 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 src/native/styles/resets-to-none.ts diff --git a/src/__tests__/native/filter-list-runtime.test.tsx b/src/__tests__/native/filter-list-runtime.test.tsx index 0e7a2840..7b9e99c3 100644 --- a/src/__tests__/native/filter-list-runtime.test.tsx +++ b/src/__tests__/native/filter-list-runtime.test.tsx @@ -58,6 +58,28 @@ describe.each([undefined, false] as const)( screen.rerender(); expect(filter()).toEqual([firstShadow, secondShadow]); }); + + // Chromium 153, Firefox 155 and WebKit 26.6 leave no filter for each + test.each([ + ["filter: var(--effect)", "none", [{ blur: 3 }]], + [ + "filter: brightness(0.5) var(--effect)", + "none", + [{ brightness: 0.5 }, { blur: 3 }], + ], + ])( + "%s with --effect: %s removes the filter", + (declaration, keyword, before) => { + registerCSS( + `.subject { --effect: blur(3px); ${declaration}; } .keyword { --effect: ${keyword}; }`, + { inlineVariables }, + ); + render(); + expect(filter()).toEqual(before); + screen.rerender(); + expect(filter()).toBeUndefined(); + }, + ); }, ); diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index abd922fd..fed88597 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -202,3 +202,19 @@ describe("a transform's target survives a later nested declaration", () => { }); }); }); + +// Chromium 153, Firefox 155 and WebKit 26.6 leave no transform for each +test.each([ + ["transform: var(--t)", "none"], + ["transform: translateX(10px) var(--t)", "none"], +])("%s with --t: %s leaves no transform", (declaration, keyword) => { + registerCSS( + `.my-class { --t: rotate(1deg); ${declaration}; } .keyword { --t: ${keyword}; }`, + ); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual({}); +}); diff --git a/src/native/styles/functions/filters.ts b/src/native/styles/functions/filters.ts index 726f8e1d..92e6bdd0 100644 --- a/src/native/styles/functions/filters.ts +++ b/src/native/styles/functions/filters.ts @@ -1,5 +1,6 @@ import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { resetsToNone } from "../resets-to-none"; import type { StyleFunctionResolver } from "../resolve"; import { shorthandHandler } from "../shorthands/_handler"; @@ -103,11 +104,10 @@ export const filter: StyleFunctionResolver = (resolveValue, descriptor) => { const value: unknown = resolveValue(descriptor[2]); if (Array.isArray(value)) { const filters: unknown[] = value.flat(Infinity); - return filters.filter( - (entry) => entry !== undefined && entry !== "initial", - ); + if (filters.some(resetsToNone)) return null; + return filters.filter((entry) => entry !== undefined); } if (value === undefined || value === "initial") return; - if (value === null || value === "none") return null; + if (resetsToNone(value)) return null; return typeof value === "string" ? value : [value]; }; diff --git a/src/native/styles/resets-to-none.ts b/src/native/styles/resets-to-none.ts new file mode 100644 index 00000000..6f79991b --- /dev/null +++ b/src/native/styles/resets-to-none.ts @@ -0,0 +1,4 @@ +// A keyword a var() leaves in a list-valued property is `none` alone or invalid at computed-value time, and either way the property is `none` (css-variables-1 §3.1) +export function resetsToNone(value: unknown): boolean { + return value === null || value === "none" || value === "initial"; +} diff --git a/src/native/styles/shorthands/transform.ts b/src/native/styles/shorthands/transform.ts index ef603dd8..1f2852a7 100644 --- a/src/native/styles/shorthands/transform.ts +++ b/src/native/styles/shorthands/transform.ts @@ -1,3 +1,4 @@ +import { resetsToNone } from "../resets-to-none"; import type { StyleFunctionResolver } from "../resolve"; /** @@ -11,9 +12,9 @@ export const transform: StyleFunctionResolver = ( const transforms = resolveValue(transformDescriptor[2]); if (Array.isArray(transforms)) { - return transforms.filter( - (transform) => transform !== undefined && transform !== "initial", - ) as unknown; + return transforms.some(resetsToNone) + ? undefined + : (transforms.filter((transform) => transform !== undefined) as unknown); } else if (transforms) { // If it's a single transform, wrap it in an array return [transforms]; From 7569ab2b6b0bf902c41bcd287d33992b335e25fb Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 19:15:29 +0300 Subject: [PATCH 09/17] refactor(native): reset a cleared shorthand only where a value can arrive cleared Only a shorthand that returns its longhands as an object receives a cleared argument: animation wraps its argument in a list, and box-shadow and drop-shadow handle it before the handler. --- src/native/styles/shorthands/_handler.ts | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/native/styles/shorthands/_handler.ts b/src/native/styles/shorthands/_handler.ts index c98ee4fb..e20f4a53 100644 --- a/src/native/styles/shorthands/_handler.ts +++ b/src/native/styles/shorthands/_handler.ts @@ -38,8 +38,8 @@ export function shorthandHandler( ? resolve(value[2]) : value; - if (args === null) { - return clearedLonghands(mappings, defaults, returnType); + if (args === null && returnType === "shorthandObject") { + return clearedLonghands(mappings, defaults); } if (!Array.isArray(args)) { @@ -142,23 +142,14 @@ export function shorthandHandler( function clearedLonghands( mappings: ShorthandRequiredValue[][], defaults: ShorthandDefaultValue[], - returnType: "shorthandObject" | "tuples" | "object", ) { - const longhands = new Set( - [...mappings.flat(), ...defaults].flatMap(([prop]) => - typeof prop === "string" ? [prop] : prop.slice(0, 1), - ), + const target: Record = { [ShortHandSymbol]: true }; + const longhands = [...mappings.flat(), ...defaults].flatMap(([prop]) => + typeof prop === "string" ? [prop] : prop.slice(0, 1), ); - if (returnType === "tuples") { - return Array.from(longhands, (prop) => [undefined, prop]); - } - - const target: Record = - returnType === "shorthandObject" ? { [ShortHandSymbol]: true } : {}; - - for (const prop of longhands) { - target[prop] = undefined; + for (const longhand of longhands) { + target[longhand] = undefined; } return target; From bcf0214879ed5cdc3b377cd9826ffb517b8b98c5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 19:15:38 +0300 Subject: [PATCH 10/17] test: a pseudo-element rule keeps its other custom properties --- src/__tests__/compiler/compiler.test.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index d6e3bd47..603b6090 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -738,6 +738,16 @@ describe("CSS-wide keywords", () => { [[{}, "var", "__rn-css-color"], ["placeholderTextColor"], 1], ]); }); + + test.each(["placeholder", "selection"])( + "a ::%s rule publishes no colour, and keeps its other custom properties", + (pseudoElement) => { + const css = `.child::${pseudoElement} { --brand: red; color: var(--brand); } .other { --brand: blue; }`; + + expect(publishedInheritedColors(css)).toEqual([]); + expect(publishedVariable(css, "brand")).toEqual(["red", "blue"]); + }, + ); }); function declarationsFor(css: string): StyleDeclaration[] { From 0a7808ba3cf1d0ea91c8986950a81f27cdaac2f9 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 19:42:10 +0300 Subject: [PATCH 11/17] fix(compiler): a CSS-wide keyword inside a longer value invalidates the declaration A keyword is a value only on its own, so one inside a function, a colour or a list makes the whole declaration invalid; rejecting the token alone left `box-shadow: 1px 1px inherit` as a shadow and `blur(inherit)` as an empty blur. A var() or env() defers its value, and a custom property's value is any tokens, so neither is checked. --- src/__tests__/compiler/compiler.test.tsx | 32 +++++++-- src/__tests__/native/keywords.test.tsx | 40 ++++++----- src/compiler/declarations.ts | 88 +++++++++++++++++++++--- 3 files changed, 130 insertions(+), 30 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 603b6090..ca56611e 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -526,10 +526,25 @@ describe("CSS-wide keywords", () => { }, ); - test.each(["color-mix(in srgb, inherit, blue)", "light-dark(inherit, blue)"])( - "color: %s is dropped, as a CSS-wide keyword is only ever a whole value", - (value) => { - expect(stylesheetFor(value)).toStrictEqual({}); + test.each([ + ["color", "color-mix(in srgb, inherit, blue)"], + ["color", "light-dark(inherit, blue)"], + ["color", "light-dark(blue, inherit)"], + ["color", "rgb(0 0 0 / inherit)"], + ["filter", "blur(inherit)"], + ["transform", "translateX(inherit)"], + ["box-shadow", "1px 1px INHERIT"], + ])( + "%s: %s is dropped, as a CSS-wide keyword is only ever a whole value", + (property, value) => { + const compiled = compile(`.child { ${property}: ${value}; }`); + + expect(compiled.stylesheet()).toStrictEqual({}); + expect(compiled.warnings()).toStrictEqual({ + values: { + [property]: [value.includes("INHERIT") ? "INHERIT" : "inherit"], + }, + }); }, ); @@ -570,6 +585,15 @@ describe("CSS-wide keywords", () => { }); }); + test("--brand keeps the tokens around a CSS-wide keyword inside its value", () => { + const css = uninlinedCustomProperty("1px INHERIT"); + + expect(publishedVariable(css, "brand")).toEqual([1, 1]); + expect(compile(css).warnings()).toStrictEqual({ + values: { "--brand": ["INHERIT", "INHERIT"] }, + }); + }); + test("a comment beside a keyword leaves the keyword the whole value", () => { expect(stylesheetFor("/* reset */ inherit")).toStrictEqual( stylesheetFor("inherit"), diff --git a/src/__tests__/native/keywords.test.tsx b/src/__tests__/native/keywords.test.tsx index 08ec6ec0..b245d5e6 100644 --- a/src/__tests__/native/keywords.test.tsx +++ b/src/__tests__/native/keywords.test.tsx @@ -93,31 +93,35 @@ test.each(["inherit", "unset", "revert", "revert-layer"])( }, ); +const earlierShadow = { + boxShadow: [ + { + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + color: "#f00", + }, + ], +}; + // Chromium 153, Firefox 155 and WebKit 26.6 ignore each invalid declaration and keep the earlier value test.each([ - ["filter", "blur(2px)", { filter: [{ blur: 2 }] }], + ["filter", "foo(1)", "blur(2px)", { filter: [{ blur: 2 }] }], + ["box-shadow", "foo(1)", "1px 1px red", earlierShadow], + ["box-shadow", "1px 1px inherit", "1px 1px red", earlierShadow], [ - "box-shadow", - "1px 1px red", - { - boxShadow: [ - { - offsetX: 1, - offsetY: 1, - blurRadius: 0, - spreadDistance: 0, - color: "#f00", - }, - ], - }, + "transform", + "foo(1)", + "translateX(10px)", + { transform: [{ translateX: 10 }] }, ], - ["transform", "translateX(10px)", { transform: [{ translateX: 10 }] }], ] as const)( - "%s: foo(1) leaves an earlier rule's value", - (property, earlier, kept) => { + "%s: %s leaves an earlier rule's value", + (property, invalid, earlier, kept) => { expect( renderStyle( - `.earlier { ${property}: ${earlier}; } .child { ${property}: foo(1); }`, + `.earlier { ${property}: ${earlier}; } .child { ${property}: ${invalid}; }`, ), ).toStrictEqual(kept); }, diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index bc62b9db..6b96d5a8 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -987,11 +987,11 @@ export function parseUnparsedDeclaration( */ builder.descriptorProperty = property; - const keyword = soleCSSWideKeyword(declaration.value.value); - const value = - keyword === undefined - ? parseUnparsed(declaration.value.value, builder, property) - : parseCSSWideKeyword(keyword, property, builder); + const value = parseDeclarationValue( + declaration.value.value, + builder, + property, + ); // A value the compiler cannot parse leaves the declaration invalid, so an earlier rule's value stands if (value === undefined) { @@ -1094,9 +1094,81 @@ function parseDeclarationValue( ): StyleDescriptor { const keyword = soleCSSWideKeyword(tokens); - return keyword === undefined - ? parseUnparsed(tokens, builder, property, allowAuto) - : parseCSSWideKeyword(keyword, property, builder); + if (keyword !== undefined) { + return parseCSSWideKeyword(keyword, property, builder); + } + + // A CSS-wide keyword inside a property's longer value makes the declaration invalid (css-values-4 §2.1); a custom property's value is any tokens + const nested = property.startsWith("--") + ? undefined + : nestedCSSWideKeyword(tokens ?? []); + + if (nested !== undefined) { + builder.addWarning("value", nested); + return; + } + + return parseUnparsed(tokens, builder, property, allowAuto); +} + +// A var() or env() defers its value to computed-value time, so a keyword behind one is not looked for +function nestedCSSWideKeyword( + tokens: readonly TokenOrValue[], +): string | undefined { + for (const tokenOrValue of tokens) { + const keyword = cssWideKeywordIn(tokenOrValue); + + if (keyword !== undefined) { + return keyword; + } + } + + return; +} + +function cssWideKeywordIn(tokenOrValue: TokenOrValue): string | undefined { + switch (tokenOrValue.type) { + case "token": + return tokenOrValue.value.type === "ident" && + isCSSWideKeyword(tokenOrValue.value.value.toLowerCase()) + ? tokenOrValue.value.value + : undefined; + case "function": + return nestedCSSWideKeyword(tokenOrValue.value.arguments); + case "unresolved-color": + return cssWideKeywordInColor(tokenOrValue.value); + case "var": + case "env": + case "color": + case "url": + case "length": + case "angle": + case "time": + case "resolution": + case "dashed-ident": + case "animation-name": + return; + default: { + tokenOrValue satisfies never; + return; + } + } +} + +function cssWideKeywordInColor(color: UnresolvedColor): string | undefined { + switch (color.type) { + case "rgb": + case "hsl": + return nestedCSSWideKeyword(color.alpha); + case "light-dark": + return ( + nestedCSSWideKeyword(color.light) ?? nestedCSSWideKeyword(color.dark) + ); + default: { + color satisfies never; + return; + } + } } function addCSSWideKeyword( From be3faab30bebaf460f52078f1df3b0b9cfe3901b Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 20:00:22 +0300 Subject: [PATCH 12/17] fix(compiler): object-position, corner-shape and font-variant take a CSS-wide keyword whole These three parse their own value, so a keyword inside it was dropped as a token and the rest applied: `object-position: left inherit` set `left`. They now share the declaration's keyword decision, so the declaration is invalid and an earlier rule's value stands, as it does in Chromium 153, Firefox 155 and WebKit 26.6. --- src/__tests__/compiler/compiler.test.tsx | 5 ++- src/__tests__/native/keywords.test.tsx | 32 +++++++++++++++++ src/compiler/declarations.ts | 44 +++++++++++++++--------- 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index ca56611e..ec405bc9 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -533,6 +533,9 @@ describe("CSS-wide keywords", () => { ["color", "rgb(0 0 0 / inherit)"], ["filter", "blur(inherit)"], ["transform", "translateX(inherit)"], + ["object-position", "left inherit"], + ["corner-shape", "round inherit"], + ["font-variant", "small-caps inherit"], ["box-shadow", "1px 1px INHERIT"], ])( "%s: %s is dropped, as a CSS-wide keyword is only ever a whole value", @@ -585,7 +588,7 @@ describe("CSS-wide keywords", () => { }); }); - test("--brand keeps the tokens around a CSS-wide keyword inside its value", () => { + test("--brand: 1px INHERIT stays declared, and its keyword is dropped with a warning", () => { const css = uninlinedCustomProperty("1px INHERIT"); expect(publishedVariable(css, "brand")).toEqual([1, 1]); diff --git a/src/__tests__/native/keywords.test.tsx b/src/__tests__/native/keywords.test.tsx index b245d5e6..f5e3ebdf 100644 --- a/src/__tests__/native/keywords.test.tsx +++ b/src/__tests__/native/keywords.test.tsx @@ -126,3 +126,35 @@ test.each([ ).toStrictEqual(kept); }, ); + +test.each([ + [ + "object-position", + "left inherit", + "right top", + { contentPosition: "right top" }, + ], + [ + "font-variant", + "all-small-caps inherit", + "small-caps", + { style: { fontVariant: "small-caps" } }, + ], + [ + "corner-shape", + "round inherit", + "squircle", + { style: { borderCurve: "continuous" } }, + ], +] as const)( + "%s: %s leaves an earlier rule's value", + (property, invalid, earlier, kept) => { + registerCSS( + `.earlier { ${property}: ${earlier}; } .child { ${property}: ${invalid}; }`, + ); + + render(); + + expect(screen.getByTestId("child").props).toMatchObject(kept); + }, +); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 6b96d5a8..5c291e23 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -1058,8 +1058,8 @@ function soleCSSWideKeyword( function parseCSSWideKeyword( keyword: CSSWideKeyword, - property: string, builder: StylesheetBuilder, + property: string, ): StyleDescriptor { const isCustomProperty = property.startsWith("--"); // Only `color` and custom properties reach descendants here, so only they inherit where `unset` would (css-cascade-5 §7.3.3) @@ -1086,16 +1086,16 @@ function parseCSSWideKeyword( return; } -function parseDeclarationValue( +// The value a declaration's CSS-wide keyword gives it, or undefined when it holds none +function parseCSSWideKeywordValue( tokens: TokenOrValue[] | null | undefined, builder: StylesheetBuilder, property: string, - allowAuto?: boolean, -): StyleDescriptor { +): { readonly value: StyleDescriptor } | undefined { const keyword = soleCSSWideKeyword(tokens); if (keyword !== undefined) { - return parseCSSWideKeyword(keyword, property, builder); + return { value: parseCSSWideKeyword(keyword, builder, property) }; } // A CSS-wide keyword inside a property's longer value makes the declaration invalid (css-values-4 §2.1); a custom property's value is any tokens @@ -1105,10 +1105,23 @@ function parseDeclarationValue( if (nested !== undefined) { builder.addWarning("value", nested); - return; + return { value: undefined }; } - return parseUnparsed(tokens, builder, property, allowAuto); + return; +} + +function parseDeclarationValue( + tokens: TokenOrValue[] | null | undefined, + builder: StylesheetBuilder, + property: string, + allowAuto?: boolean, +): StyleDescriptor { + const keywordValue = parseCSSWideKeywordValue(tokens, builder, property); + + return keywordValue === undefined + ? parseUnparsed(tokens, builder, property, allowAuto) + : keywordValue.value; } // A var() or env() defers its value to computed-value time, so a keyword behind one is not looked for @@ -1173,20 +1186,17 @@ function cssWideKeywordInColor(color: UnresolvedColor): string | undefined { function addCSSWideKeyword( tokens: TokenOrValue[], - property: string, builder: StylesheetBuilder, + property: string, nativeProperty = property, ): boolean { - const keyword = soleCSSWideKeyword(tokens); + const keywordValue = parseCSSWideKeywordValue(tokens, builder, property); - if (keyword === undefined) { + if (keywordValue === undefined) { return false; } - builder.addDescriptor( - nativeProperty, - parseCSSWideKeyword(keyword, property, builder), - ); + builder.addDescriptor(nativeProperty, keywordValue.value); return true; } @@ -1258,7 +1268,7 @@ export function parseCustomDeclaration( } else if (property === "corner-shape") { parseCornerShape(declaration.value, builder); } else if (property === "font-variant") { - if (addCSSWideKeyword(declaration.value.value, property, builder)) { + if (addCSSWideKeyword(declaration.value.value, builder, property)) { return; } @@ -3477,7 +3487,7 @@ function parseObjectPosition( "object-position": ["contentPosition"], }); - if (addCSSWideKeyword(declaration.value, "object-position", builder)) { + if (addCSSWideKeyword(declaration.value, builder, "object-position")) { return; } @@ -3493,7 +3503,7 @@ function parseCornerShape( builder: StylesheetBuilder, ) { if ( - addCSSWideKeyword(declaration.value, "corner-shape", builder, "borderCurve") + addCSSWideKeyword(declaration.value, builder, "corner-shape", "borderCurve") ) { return; } From 4b644ef07e8d59064a194f3265168ed9f125aadc Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 20:10:10 +0300 Subject: [PATCH 13/17] fix(compiler): a var() or env() fallback is the whole value only when the function is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fallback replaces its var() or env(), so a CSS-wide keyword in it applies only when the function is the declaration's whole value (css-variables-1 §3). Inside a longer value the keyword leaves the declaration invalid at computed-value time, yet it was resolved everywhere: `color-mix(in srgb, var(--missing, inherit), blue)` mixed the inherited colour with blue, and `translateX(var(--missing, initial))` compiled `unset` into the function. Inside a longer value the fallback is now dropped with a warning. An env() fallback is parsed for the declaring property, as a var() fallback is, rather than under the env() name. --- src/__tests__/compiler/compiler.test.tsx | 85 ++++++++++++++ src/__tests__/native/colors.test.tsx | 11 +- src/__tests__/native/keywords.test.tsx | 9 ++ src/compiler/declarations.ts | 134 ++++++++++++++++++----- 4 files changed, 206 insertions(+), 33 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index ec405bc9..ca637a19 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -564,6 +564,91 @@ describe("CSS-wide keywords", () => { }, ); + test("a whole-value var() fallback that is a var() passes its keyword fallback through", () => { + expect( + declarationsFor(`.child { color: var(--a, var(--b, inherit)); }`), + ).toStrictEqual([ + [ + [ + {}, + "var", + ["a", [{}, "var", ["b", [{}, "var", "__rn-css-color"]], 1]], + 1, + ], + "color", + 1, + ], + ]); + }); + + test.each([ + ["padding-top", "paddingTop", "initial", "unset"], + ["padding-top", "paddingTop", "4px", 4], + ["color", "color", "inherit", [{}, "var", "__rn-css-color"]], + ] as const)( + "%s: env(safe-area-inset-top, %s) falls back to what the value resolves to there", + (property, nativeProperty, value, fallback) => { + expect( + declarationsFor( + `.child { ${property}: env(safe-area-inset-top, ${value}); }`, + ), + ).toStrictEqual([ + [ + [{}, "var", ["react-native-css-safe-area-inset-top", fallback], 1], + nativeProperty, + 1, + ], + ]); + }, + ); + + test.each([ + [ + "width", + "calc(var(--missing, initial) + 1px)", + "calc(var(--missing) + 1px)", + "initial", + ], + [ + "color", + "color-mix(in srgb, var(--missing, inherit), blue)", + "color-mix(in srgb, var(--missing), blue)", + "inherit", + ], + [ + "transform", + "translateX(var(--missing, initial))", + "translateX(var(--missing))", + "initial", + ], + [ + "box-shadow", + "1px 1px var(--missing, INHERIT)", + "1px 1px var(--missing)", + "INHERIT", + ], + ["width", "var(--missing, 10px inherit)", "var(--missing)", "inherit"], + [ + "padding-top", + "calc(env(safe-area-inset-top, initial) + 1px)", + "calc(env(safe-area-inset-top) + 1px)", + "initial", + ], + ])( + "%s: %s drops the fallback, as a CSS-wide keyword leaves it invalid there", + (property, value, withoutFallback, keyword) => { + // Substituted inside a longer value, a CSS-wide keyword is invalid (css-values-4 §2.1) + const compiled = compile(`.child { ${property}: ${value}; }`); + + expect(compiled.stylesheet()).toStrictEqual( + compile(`.child { ${property}: ${withoutFallback}; }`).stylesheet(), + ); + expect(compiled.warnings()).toStrictEqual({ + values: { [property]: [keyword] }, + }); + }, + ); + // A second declaration keeps `--brand` from being inlined, so the keyword reaches the compiler on the custom property const uninlinedCustomProperty = (value: string) => `.child { --brand: ${value}; color: var(--brand); } diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx index 7c878afc..0ea33475 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -676,15 +676,16 @@ describe("inherit", () => { ); test.each([ - ["inherit", "#f00"], - ["initial", undefined], + ["var(--missing, inherit)", "#f00"], + ["var(--missing, initial)", undefined], + ["var(--missing, var(--also-missing, inherit))", "#f00"], ] as const)( - "color: var(--missing, %s) applies the keyword over an earlier rule", - (keyword, expectedColor) => { + "color: %s applies the keyword over an earlier rule", + (value, expectedColor) => { registerCSS(` .parent { color: red; } .earlier { color: green; } - .child { color: var(--missing, ${keyword}); } + .child { color: ${value}; } `); render( diff --git a/src/__tests__/native/keywords.test.tsx b/src/__tests__/native/keywords.test.tsx index f5e3ebdf..cc57e1af 100644 --- a/src/__tests__/native/keywords.test.tsx +++ b/src/__tests__/native/keywords.test.tsx @@ -71,6 +71,15 @@ test.each(resettingKeywords)( }, ); +// Chromium 153, Firefox 155 and WebKit 26.6 apply a keyword fallback of an undefined env() as the whole value +test("padding-top: env(safe-area-inset-top, initial) clears an earlier rule's value when no inset is defined", () => { + expect( + renderStyle( + `.earlier { padding-top: 10px; } .child { padding-top: env(safe-area-inset-top, initial); }`, + ), + ).toStrictEqual({ paddingTop: undefined }); +}); + // Chromium 153, Firefox 155 and WebKit 26.6 read the ancestor's value for each, as a custom property inherits (css-variables-1 §2) test.each(["inherit", "unset", "revert", "revert-layer"])( "--brand: %s reads the ancestor's --brand", diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 5c291e23..db69c782 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -34,6 +34,7 @@ import type { TokenOrValue, Translate, UnresolvedColor, + Variable, } from "lightningcss"; import { @@ -1119,9 +1120,91 @@ function parseDeclarationValue( ): StyleDescriptor { const keywordValue = parseCSSWideKeywordValue(tokens, builder, property); - return keywordValue === undefined - ? parseUnparsed(tokens, builder, property, allowAuto) - : keywordValue.value; + if (keywordValue !== undefined) { + return keywordValue.value; + } + + const [token, ...rest] = tokens ?? []; + + if (token === undefined || rest.length > 0) { + return parseUnparsed(tokens, builder, property, allowAuto); + } + + switch (token.type) { + case "var": + return listSoleFunction( + parseVariable(token.value, builder, property, "whole-value", allowAuto), + property, + ); + case "env": + return listSoleFunction( + parseEnv(token.value, builder, property, "whole-value"), + property, + ); + default: + return parseUnparsed(tokens, builder, property, allowAuto); + } +} + +// A filter or transform value is a list of functions, so one function alone is a list of one +function listSoleFunction( + value: StyleDescriptor, + property: string, +): StyleDescriptor { + return (property === "filter" || property === "transform") && + isStyleFunction(value) + ? [value] + : value; +} + +// Substituted, a fallback replaces its var() or env(), so it is the declaration's whole value only when the function is (css-variables-1 §3) +type SubstitutionPosition = "whole-value" | "inside-value"; + +function parseSubstitutionFallback( + tokens: TokenOrValue[] | null | undefined, + builder: StylesheetBuilder, + property: string, + position: SubstitutionPosition, + allowAuto?: boolean, +): StyleDescriptor { + switch (position) { + case "whole-value": + return parseDeclarationValue(tokens, builder, property, allowAuto); + case "inside-value": { + // Inside a longer value a CSS-wide keyword is invalid (css-values-4 §2.1) + const keyword = nestedCSSWideKeyword(tokens ?? []); + + if (keyword !== undefined) { + builder.addWarning("value", keyword); + return; + } + + return parseUnparsed(tokens, builder, property, allowAuto); + } + default: { + position satisfies never; + return; + } + } +} + +function parseVariable( + variable: Variable, + builder: StylesheetBuilder, + property: string, + position: SubstitutionPosition, + allowAuto?: boolean, +): StyleFunction { + const name = variable.name.ident.slice(2); + const fallback = parseSubstitutionFallback( + variable.fallback, + builder, + property, + position, + allowAuto, + ); + + return [{}, "var", fallback === undefined ? name : [name, fallback], 1]; } // A var() or env() defers its value to computed-value time, so a keyword behind one is not looked for @@ -1398,14 +1481,9 @@ export function parseUnparsed( if (!args) return; if (Array.isArray(args) && args.length === 1) { return args[0]; - } else if ( - (property === "filter" || property === "transform") && - isStyleFunction(args) - ) { - return [args]; - } else { - return args; } + + return listSoleFunction(args, property); } switch (tokenOrValue.type) { @@ -1417,21 +1495,14 @@ export function parseUnparsed( allowAuto, ); } - case "var": { - let args: StyleDescriptor = tokenOrValue.value.name.ident.slice(2); - // Substituted, a fallback is the declaration's whole value, so it can be a CSS-wide keyword (css-variables-1 §3) - const fallback = parseDeclarationValue( - tokenOrValue.value.fallback, + case "var": + return parseVariable( + tokenOrValue.value, builder, property, + "inside-value", allowAuto, ); - if (fallback !== undefined) { - args = [args, fallback]; - } - - return [{}, "var", args, 1]; - } case "function": { switch (tokenOrValue.value.name) { case "blur": @@ -1588,7 +1659,7 @@ export function parseUnparsed( case "color": return parseColor(tokenOrValue.value, builder); case "env": - return parseEnv(tokenOrValue.value, builder); + return parseEnv(tokenOrValue.value, builder, property, "inside-value"); case "time": return parseTime(tokenOrValue.value); case "url": @@ -2871,6 +2942,8 @@ const allowAutoProperties = new Set(["pointer-events"]); export function parseEnv( value: EnvironmentVariable, builder: StylesheetBuilder, + property: string, + position: SubstitutionPosition, ): StyleFunction | undefined { switch (value.name.type) { case "ua": @@ -2879,15 +2952,20 @@ export function parseEnv( case "safe-area-inset-right": case "safe-area-inset-bottom": case "safe-area-inset-left": { - const fallback = parseUnparsed( + const name = `react-native-css-${value.name.value}`; + const fallback = parseSubstitutionFallback( value.fallback, builder, - value.name.value, + property, + position, ); - return fallback - ? [{}, "var", [`react-native-css-${value.name.value}`, fallback], 1] - : [{}, "var", [`react-native-css-${value.name.value}`], 1]; + return [ + {}, + "var", + fallback === undefined ? [name] : [name, fallback], + 1, + ]; } case "viewport-segment-width": case "viewport-segment-height": @@ -3022,7 +3100,7 @@ export function parseCalcArguments( for (const arg of args) { switch (arg.type) { case "env": { - parsed.push(parseEnv(arg.value, builder)); + parsed.push(parseEnv(arg.value, builder, property, "inside-value")); break; } case "var": From d4543eda4d2dc7ef206c4946e29bf41349702406 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 20:12:33 +0300 Subject: [PATCH 14/17] test: an env() fallback inside a function drops its keyword --- src/__tests__/compiler/compiler.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index ca637a19..a16ec599 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -634,6 +634,12 @@ describe("CSS-wide keywords", () => { "calc(env(safe-area-inset-top) + 1px)", "initial", ], + [ + "transform", + "translateY(env(safe-area-inset-top, initial))", + "translateY(env(safe-area-inset-top))", + "initial", + ], ])( "%s: %s drops the fallback, as a CSS-wide keyword leaves it invalid there", (property, value, withoutFallback, keyword) => { From 364a4f3862e2f85363c1faa64cb6b293527ac7c7 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 20:16:19 +0300 Subject: [PATCH 15/17] test: name each env() fallback in its test title --- src/__tests__/compiler/compiler.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index a16ec599..4a293aa4 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -582,12 +582,12 @@ describe("CSS-wide keywords", () => { }); test.each([ - ["padding-top", "paddingTop", "initial", "unset"], - ["padding-top", "paddingTop", "4px", 4], - ["color", "color", "inherit", [{}, "var", "__rn-css-color"]], + ["padding-top", "initial", "paddingTop", "unset"], + ["padding-top", "4px", "paddingTop", 4], + ["color", "inherit", "color", [{}, "var", "__rn-css-color"]], ] as const)( "%s: env(safe-area-inset-top, %s) falls back to what the value resolves to there", - (property, nativeProperty, value, fallback) => { + (property, value, nativeProperty, fallback) => { expect( declarationsFor( `.child { ${property}: env(safe-area-inset-top, ${value}); }`, From 6f2d8b1a2922fef60a3d3a21eb1510cc4be6dbea Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 20:20:34 +0300 Subject: [PATCH 16/17] test: a light-dark() inside a shadow is walked for a keyword --- src/__tests__/compiler/compiler.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 4a293aa4..f6c107be 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -536,6 +536,7 @@ describe("CSS-wide keywords", () => { ["object-position", "left inherit"], ["corner-shape", "round inherit"], ["font-variant", "small-caps inherit"], + ["box-shadow", "1px 1px light-dark(blue, inherit)"], ["box-shadow", "1px 1px INHERIT"], ])( "%s: %s is dropped, as a CSS-wide keyword is only ever a whole value", From 5eddc3398855ce4aba8aed7b13857af87cde5bbf Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 20:34:22 +0300 Subject: [PATCH 17/17] fix(compiler): a CSS-wide keyword's warning quotes it as written A keyword on its own was reported lowercased, while a nested one and every other value warning quote the value as the stylesheet spells it. --- src/__tests__/compiler/compiler.test.tsx | 9 +++++++++ src/compiler/declarations.ts | 22 ++++++++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index f6c107be..b3a5e520 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -526,6 +526,15 @@ describe("CSS-wide keywords", () => { }, ); + test.each(["INHERIT", "1px 1px INHERIT"])( + "box-shadow: %s is reported as written", + (value) => { + expect( + compile(`.child { box-shadow: ${value}; }`).warnings(), + ).toStrictEqual({ values: { "box-shadow": ["INHERIT"] } }); + }, + ); + test.each([ ["color", "color-mix(in srgb, inherit, blue)"], ["color", "light-dark(inherit, blue)"], diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index db69c782..f6fff6ff 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -1037,10 +1037,15 @@ function isCSSWideKeyword(keyword: string): keyword is CSSWideKeyword { return (CSS_WIDE_KEYWORDS as readonly string[]).includes(keyword); } +interface SoleCSSWideKeyword { + keyword: CSSWideKeyword; + written: string; +} + // A CSS-wide keyword is a value only on its own (css-values-4 §2.1) function soleCSSWideKeyword( tokens: TokenOrValue[] | null | undefined, -): CSSWideKeyword | undefined { +): SoleCSSWideKeyword | undefined { const [token, ...rest] = tokens ?? []; if ( @@ -1052,13 +1057,14 @@ function soleCSSWideKeyword( return; } - const keyword = token.value.value.toLowerCase(); + const written = token.value.value; + const keyword = written.toLowerCase(); - return isCSSWideKeyword(keyword) ? keyword : undefined; + return isCSSWideKeyword(keyword) ? { keyword, written } : undefined; } function parseCSSWideKeyword( - keyword: CSSWideKeyword, + { keyword, written }: SoleCSSWideKeyword, builder: StylesheetBuilder, property: string, ): StyleDescriptor { @@ -1083,7 +1089,7 @@ function parseCSSWideKeyword( return "unset"; } - builder.addWarning("value", keyword); + builder.addWarning("value", written); return; } @@ -1093,10 +1099,10 @@ function parseCSSWideKeywordValue( builder: StylesheetBuilder, property: string, ): { readonly value: StyleDescriptor } | undefined { - const keyword = soleCSSWideKeyword(tokens); + const sole = soleCSSWideKeyword(tokens); - if (keyword !== undefined) { - return { value: parseCSSWideKeyword(keyword, builder, property) }; + if (sole !== undefined) { + return { value: parseCSSWideKeyword(sole, builder, property) }; } // A CSS-wide keyword inside a property's longer value makes the declaration invalid (css-values-4 §2.1); a custom property's value is any tokens