From 6b4e992b1b5035d86418f390a07dabacd0dac521 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 16:05:23 +0300 Subject: [PATCH 01/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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 From f9caff0601c340bb3837fbc4f2608f73047e8b7c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 22:41:06 +0300 Subject: [PATCH 18/29] fix: resolve a custom property's CSS-wide keyword on the property itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom property set to inherit, unset, revert or revert-layer was dropped, so an earlier rule's value on the same element won, and one set to initial was dropped with a warning and read the ancestor's value. Each now compiles to a value the runtime resolves where the property is declared: inherit reads the parent's value over every declaration on the element, and initial is the guaranteed-invalid value, which takes a var() fallback and hides an ancestor's declaration (css-variables-1 §2). A declaration a var() leaves without a value is invalid at computed-value time and behaves as unset (css-variables-1 §3.1): color inherits, a runtime-parsed shorthand resets its longhands, and any other property is cleared. A filter list with such an entry has no filter, an empty fallback substitutes nothing, and the inliner never folds an inheriting keyword and compiles a read of an inlined initial the same way. A context lookup reads only a variable's own entries, so var(--constructor) takes its fallback. --- src/__tests__/compiler/compiler.test.tsx | 71 ++-- .../native/filter-list-runtime.test.tsx | 18 + src/__tests__/native/keywords.test.tsx | 321 ++++++++++++++++++ src/__tests__/native/transform.test.tsx | 3 +- src/__tests__/native/variables.test.tsx | 58 ++++ src/compiler/css-wide-keywords.ts | 43 +++ src/compiler/declarations.ts | 88 ++--- src/compiler/inline-variables.ts | 53 ++- src/native/react/rules.ts | 5 +- src/native/styles/calculate-props.ts | 75 +++- src/native/styles/functions/filters.ts | 6 +- src/native/styles/list-entries.ts | 13 + src/native/styles/shorthands/transform.ts | 7 +- src/native/styles/variables.ts | 83 +++-- src/utilities/custom-property-keywords.ts | 31 ++ src/utilities/index.ts | 1 + src/utilities/inherited-color.ts | 36 +- 17 files changed, 784 insertions(+), 128 deletions(-) create mode 100644 src/compiler/css-wide-keywords.ts create mode 100644 src/native/styles/list-entries.ts create mode 100644 src/utilities/custom-property-keywords.ts diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b3a5e520..261d8d50 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -670,23 +670,37 @@ describe("CSS-wide keywords", () => { `.child { --brand: ${value}; color: var(--brand); } .other { --brand: ${value}; }`; + // css-variables-1 §2: the keyword is the custom property's own value, which a read then substitutes test.each(["inherit", "unset", "revert", "revert-layer", "INHERIT"])( - "--brand: %s publishes nothing, so a read finds the ancestor's value", + "--brand: %s compiles to the parent's value", (keyword) => { const css = uninlinedCustomProperty(keyword); - expect(publishedVariable(css, "brand")).toEqual([]); + expect(publishedVariable(css, "brand")).toEqual([ + [{}, "inherit"], + [{}, "inherit"], + ]); expect(compile(css).warnings()).toStrictEqual({}); }, ); - test("--brand: initial is dropped with a warning", () => { + test("whitespace around the keyword leaves it the whole value", () => { + expect( + publishedVariable(uninlinedCustomProperty(" inherit "), "brand"), + ).toEqual([ + [{}, "inherit"], + [{}, "inherit"], + ]); + }); + + test("--brand: initial compiles to the guaranteed-invalid value", () => { const css = uninlinedCustomProperty("initial"); - expect(publishedVariable(css, "brand")).toEqual([]); - expect(compile(css).warnings()).toStrictEqual({ - values: { "--brand": ["initial", "initial"] }, - }); + expect(publishedVariable(css, "brand")).toEqual([ + [{}, "initial"], + [{}, "initial"], + ]); + expect(compile(css).warnings()).toStrictEqual({}); }); test("--brand: 1px INHERIT stays declared, and its keyword is dropped with a warning", () => { @@ -714,7 +728,10 @@ describe("CSS-wide keywords", () => { uninlinedCustomProperty("/* reset */ inherit"), "brand", ), - ).toEqual([]); + ).toEqual([ + [{}, "inherit"], + [{}, "inherit"], + ]); }); test.each(["currentcolor", "currentColor"])( @@ -730,24 +747,28 @@ describe("CSS-wide keywords", () => { }, ); - test("a custom property declared once is inlined, so its keyword resolves on color", () => { + test("a custom property set to inherit is never inlined, because its value is its parent's", () => { expect( - compile(`.child { --brand: inherit; color: var(--brand); }`).stylesheet(), - ).toStrictEqual({ - s: [ - [ - "child", - [ - { - s: [1, 1], - d: [[[{}, "var", "__rn-css-color"], "color", 1]], - dv: 1, - v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]], - }, - ], - ], - ], - }); + publishedVariable( + `.child { --brand: inherit; color: var(--brand); }`, + "brand", + ), + ).toEqual([[{}, "inherit"]]); + }); + + test("a custom property set to initial is inlined as the guaranteed-invalid value, so a read takes its fallback", () => { + expect( + declarationsFor(`.child { --brand: initial; color: var(--brand, red); }`), + ).toStrictEqual(declarationsFor(`.child { color: red; }`)); + }); + + test("a read of an inlined initial with no fallback is invalid at computed-value time, so it compiles as unset", () => { + expect( + declarationsFor(`.child { --brand: initial; width: var(--brand); }`), + ).toStrictEqual(declarationsFor(`.child { width: unset; }`)); + expect( + declarationsFor(`.child { --brand: initial; color: var(--brand); }`), + ).toStrictEqual(declarationsFor(`.child { color: unset; }`)); }); test.each(["INHERIT", "Inherit", "UNSET", "INITIAL"])( diff --git a/src/__tests__/native/filter-list-runtime.test.tsx b/src/__tests__/native/filter-list-runtime.test.tsx index 7b9e99c3..dde03557 100644 --- a/src/__tests__/native/filter-list-runtime.test.tsx +++ b/src/__tests__/native/filter-list-runtime.test.tsx @@ -80,6 +80,24 @@ describe.each([undefined, false] as const)( expect(filter()).toBeUndefined(); }, ); + + // Chromium 153, Firefox 155 and WebKit 26.6: an empty fallback substitutes nothing, while an undefined variable with none invalidates the list + test("an empty fallback contributes no filter", () => { + registerCSS(`.subject { filter: var(--missing,) blur(2px); }`, { + inlineVariables, + }); + render(); + expect(filter()).toEqual([{ blur: 2 }]); + }); + + test("an undefined variable with no fallback leaves no filter", () => { + registerCSS( + `.earlier { filter: blur(4px); } .subject { filter: var(--missing) blur(2px); }`, + { inlineVariables }, + ); + render(); + expect(filter()).toBeUndefined(); + }); }, ); diff --git a/src/__tests__/native/keywords.test.tsx b/src/__tests__/native/keywords.test.tsx index cc57e1af..f52939d1 100644 --- a/src/__tests__/native/keywords.test.tsx +++ b/src/__tests__/native/keywords.test.tsx @@ -102,6 +102,327 @@ test.each(["inherit", "unset", "revert", "revert-layer"])( }, ); +const routes = [ + ["inlined", {}], + ["kept", { inlineVariables: false }], +] as const; + +function renderNested( + css: string, + options: (typeof routes)[number][1], + childClassName = "child", +): unknown { + registerCSS(css, options); + + render( + + + , + ); + + return screen.getByTestId("child").props.style; +} + +// Chromium 153, Firefox 155 and WebKit 26.6 resolve the keyword on the custom property itself, so it wins over an earlier rule's value (css-variables-1 §2) +test.each(["inherit", "unset", "revert", "revert-layer"])( + "--brand: %s over an earlier rule reads the ancestor's --brand", + (keyword) => { + expect( + renderNested( + ` + .parent { --brand: green; } + .earlier { --brand: blue; } + .child { --brand: ${keyword}; color: var(--brand); } + `, + {}, + "earlier child", + ), + ).toStrictEqual({ color: "green" }); + }, +); + +test("--brand: inherit over an earlier rule hands the subtree the ancestor's --brand", () => { + registerCSS(` + .parent { --brand: green; } + .earlier { --brand: blue; } + .middle { --brand: inherit; } + .child { color: var(--brand); } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "green", + }); +}); + +test("--brand: inherit with no ancestor --brand hands the subtree the guaranteed-invalid value", () => { + registerCSS(` + .earlier { --brand: blue; } + .middle { --brand: inherit; } + .child { color: var(--brand, green); } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "green", + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6: * declares on the parent too, so it is the value the parent hands down +test("--parent-own: inherit under no declaring ancestor reads what * declares", () => { + expect( + renderNested( + ` + * { --parent-own: red; } + .child { --parent-own: inherit; color: var(--parent-own, green); } + `, + {}, + ), + ).toStrictEqual({ color: "red" }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 give a custom property set to initial the guaranteed-invalid value (css-variables-1 §2.2), so a var() reading it takes its fallback +describe.each(routes)("--brand: initial, %s", (_route, options) => { + test("takes the var() fallback over an ancestor's --brand", () => { + expect( + renderNested( + ` + .parent { --brand: blue; } + .child { --brand: initial; color: var(--brand, green); } + `, + options, + ), + ).toStrictEqual({ color: "green" }); + }); + + test("makes a custom property reading it the guaranteed-invalid value too", () => { + expect( + renderNested( + ` + .child { --source: initial; --reader: var(--source); color: var(--reader, green); } + .other { --reader: 1px; } + `, + options, + ), + ).toStrictEqual({ color: "green" }); + }); + + test("hides the ancestor's --brand from a descendant", () => { + registerCSS( + ` + .parent { --brand: blue; } + .middle { --brand: initial; } + .child { color: var(--brand, green); } + `, + options, + ); + + render( + + + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "green", + }); + }); + + test("over an earlier rule makes var(--brand) invalid, so the colour inherits", () => { + expect( + renderNested( + ` + .parent { color: green; --brand: red; } + .earlier { --brand: blue; } + .child { --brand: initial; color: var(--brand); } + `, + options, + "earlier child", + ), + ).toStrictEqual({ color: "#008000" }); + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6: the root has no parent, so inherit gives the guaranteed-invalid value there too +test.each(["inherit", "initial"])( + ":root { --brand: %s } leaves var(--brand) its fallback", + (keyword) => { + expect( + renderNested( + ` + :root { --root-brand: ${keyword}; } + .other { --root-brand: blue; } + .child { color: var(--root-brand, green); } + `, + {}, + ), + ).toStrictEqual({ color: "green" }); + }, +); + +// Chromium 153, Firefox 155 and WebKit 26.6: * declares on the element itself, below its classes +test("* { --brand: inherit } reads the parent's --brand", () => { + expect( + renderNested( + ` + .parent { --universal-inherit: blue; } + * { --universal-inherit: inherit; } + .child { color: var(--universal-inherit, green); } + `, + {}, + ), + ).toStrictEqual({ color: "blue" }); +}); + +test("* { --brand: initial } leaves var(--brand) its fallback", () => { + expect( + renderNested( + ` + * { --universal-initial: initial; } + .other { --universal-initial: blue; } + .child { color: var(--universal-initial, green); } + `, + {}, + ), + ).toStrictEqual({ color: "green" }); +}); + +test("* { --universal-over-ancestor: initial } hides an ancestor's declaration", () => { + expect( + renderNested( + ` + .parent { --universal-over-ancestor: blue; } + * { --universal-over-ancestor: initial; } + .child { color: var(--universal-over-ancestor, green); } + `, + {}, + ), + ).toStrictEqual({ color: "green" }); +}); + +test("a class's --brand: inherit wins over *'s, and reads the parent's", () => { + expect( + renderNested( + ` + .parent { --class-over-universal: blue; } + * { --class-over-universal: red; } + .child { --class-over-universal: inherit; color: var(--class-over-universal, green); } + `, + {}, + ), + ).toStrictEqual({ color: "blue" }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 make a declaration whose var() resolves to nothing unset (css-variables-1 §3.1): color inherits, the others reset to their initial value, which React Native's default matches +describe.each(routes)( + "a var() that resolves to nothing, %s", + (_route, options) => { + test("--brand: inherit with no ancestor --brand: the colour inherits", () => { + expect( + renderNested( + ` + .parent { color: red; } + .child { --brand: inherit; color: var(--brand); } + `, + options, + ), + ).toStrictEqual({ color: "#f00" }); + }); + + test("color over an earlier rule inherits", () => { + expect( + renderNested( + ` + .parent { color: green; } + .earlier { color: red; } + .child { color: var(--missing); } + `, + options, + "earlier child", + ), + ).toStrictEqual({ color: "#008000" }); + }); + + test.each( + clearedProperties + .filter( + ([property]) => + property !== "line-height" && property !== "transform", + ) + .map( + ([property, earlier, cleared]) => + [property, earlier, cleared] as const, + ), + )("%s clears an earlier rule's value", (property, earlier, cleared) => { + registerCSS( + `.earlier { ${property}: ${earlier}; } .child { ${property}: var(--missing); }`, + options, + ); + + render(); + + expect(screen.getByTestId("child").props.style).toStrictEqual(cleared); + }); + + test("an empty fallback as the whole value inherits the colour", () => { + expect( + renderNested( + ` + .parent { color: green; } + .earlier { color: red; } + .child { color: var(--missing,); } + `, + options, + "earlier child", + ), + ).toStrictEqual({ color: "#008000" }); + }); + + test("transform leaves no transform", () => { + registerCSS( + `.earlier { transform: translateX(10px); } .child { transform: var(--missing); }`, + options, + ); + + render(); + + const style = screen.getByTestId("child").props.style as { + transform?: unknown[]; + }; + expect(style.transform ?? []).toStrictEqual([]); + }); + + test("width: calc() of it clears an earlier rule's width", () => { + registerCSS( + `.earlier { width: 50px; } .child { width: calc(var(--missing) * 2); }`, + options, + ); + + render(); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + width: undefined, + }); + }); + }, +); + const earlierShadow = { boxShadow: [ { diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index fed88597..2571cab3 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -113,11 +113,12 @@ describe("transform", () => { }); }); + // Chromium 153, Firefox 155 and WebKit 26.6: Tailwind's empty fallbacks let an undefined variable substitute nothing test("rotate-x-45", () => { registerCSS(` .rotate-45 { --tw-rotate-x: rotateX(45deg); - transform: var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y); + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); }`); const component = render( diff --git a/src/__tests__/native/variables.test.tsx b/src/__tests__/native/variables.test.tsx index e61340b5..52e24f21 100644 --- a/src/__tests__/native/variables.test.tsx +++ b/src/__tests__/native/variables.test.tsx @@ -271,3 +271,61 @@ test("variable overriding with classes", () => { const component = screen.getByTestId(testID); expect(component.props.style).toStrictEqual({ color: "#f00" }); }); + +// Chromium 153, Firefox 155 and WebKit 26.6 read a custom property's name as its own, never as a property every object inherits +test.each(["constructor", "toString", "hasOwnProperty"])( + "var(--%s) with no declaration takes its fallback", + (name) => { + registerCSS(` + .parent { --unrelated: 1px; } + .other { --unrelated: 2px; } + .test { color: var(--${name}, green); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "green", + }); + }, +); + +test("var(--constructor) reads the ancestor's declaration", () => { + registerCSS(` + .parent { --constructor: blue; } + .other { --constructor: red; } + .test { color: var(--constructor); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "blue", + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 make each member of a cycle the guaranteed-invalid value (css-variables-1 §2.3), wherever it is declared +test.each([ + ["*", "universal-cycle"], + [":root", "root-cycle"], +])("a cycle declared on %s leaves var() its fallback", (selector, name) => { + registerCSS(` + ${selector} { --${name}-a: var(--${name}-b); --${name}-b: var(--${name}-a); } + .other { --${name}-a: 1px; --${name}-b: 1px; } + .test { color: var(--${name}-a, green); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "green", + }); +}); diff --git a/src/compiler/css-wide-keywords.ts b/src/compiler/css-wide-keywords.ts new file mode 100644 index 00000000..92dc50ba --- /dev/null +++ b/src/compiler/css-wide-keywords.ts @@ -0,0 +1,43 @@ +import type { TokenOrValue } from "lightningcss"; + +const CSS_WIDE_KEYWORDS = [ + "inherit", + "initial", + "unset", + "revert", + "revert-layer", +] as const; + +export type CSSWideKeyword = (typeof CSS_WIDE_KEYWORDS)[number]; + +export function isCSSWideKeyword(keyword: string): keyword is CSSWideKeyword { + return (CSS_WIDE_KEYWORDS as readonly string[]).includes(keyword); +} + +export interface SoleCSSWideKeyword { + keyword: CSSWideKeyword; + written: string; +} + +// A CSS-wide keyword is a value only on its own (css-values-4 §2.1), and a custom property's value keeps the whitespace around it +export function soleCSSWideKeyword( + tokens: TokenOrValue[] | null | undefined, +): SoleCSSWideKeyword | undefined { + const [token, ...rest] = (tokens ?? []).filter( + (part) => part.type !== "token" || part.value.type !== "white-space", + ); + + if ( + token === undefined || + rest.length > 0 || + token.type !== "token" || + token.value.type !== "ident" + ) { + return; + } + + const written = token.value.value; + const keyword = written.toLowerCase(); + + return isCSSWideKeyword(keyword) ? { keyword, written } : undefined; +} diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index f6fff6ff..569b907f 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -38,7 +38,10 @@ import type { } from "lightningcss"; import { + guaranteedInvalidValue, INHERITED_COLOR_VARIABLE, + inheritedColorLookup, + inheritedValue, isInheritedColorLookup, isStyleFunction, } from "../utilities"; @@ -48,6 +51,11 @@ import type { StyleFunction, StyleRule, } from "./compiler.types"; +import { + isCSSWideKeyword, + soleCSSWideKeyword, + type SoleCSSWideKeyword, +} from "./css-wide-keywords"; import { parseEasingFunction, parseIterationCount } from "./keyframes"; import { toRNProperty } from "./selectors"; import type { StylesheetBuilder } from "./stylesheet"; @@ -1018,51 +1026,6 @@ export function parseUnparsedDeclaration( } } -// 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; -} - -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); -} - -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, -): SoleCSSWideKeyword | undefined { - const [token, ...rest] = tokens ?? []; - - if ( - token === undefined || - rest.length > 0 || - token.type !== "token" || - token.value.type !== "ident" - ) { - return; - } - - const written = token.value.value; - const keyword = written.toLowerCase(); - - return isCSSWideKeyword(keyword) ? { keyword, written } : undefined; -} - function parseCSSWideKeyword( { keyword, written }: SoleCSSWideKeyword, builder: StylesheetBuilder, @@ -1079,12 +1042,11 @@ function parseCSSWideKeyword( ? "inherit" : "initial"; - if (resolved === "inherit" && property === "color") { + if (isCustomProperty) { + return resolved === "inherit" ? inheritedValue() : guaranteedInvalidValue(); + } else 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) { + } else if (resolved === "initial") { // The literal the runtime clears with, which leaves React Native's default to stand in for the initial value return "unset"; } @@ -1202,17 +1164,29 @@ function parseVariable( allowAuto?: boolean, ): StyleFunction { const name = variable.name.ident.slice(2); - const fallback = parseSubstitutionFallback( - variable.fallback, - builder, - property, - position, - allowAuto, - ); + // An empty fallback substitutes nothing, which a longer value can hold (css-variables-1 §3) + const fallback = isEmptyFallback(variable.fallback) + ? [] + : parseSubstitutionFallback( + variable.fallback, + builder, + property, + position, + allowAuto, + ); return [{}, "var", fallback === undefined ? name : [name, fallback], 1]; } +function isEmptyFallback(tokens: TokenOrValue[] | null | undefined): boolean { + return ( + Array.isArray(tokens) && + tokens.every( + (token) => token.type === "token" && token.value.type === "white-space", + ) + ); +} + // 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[], diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts index 609760c1..118a6771 100644 --- a/src/compiler/inline-variables.ts +++ b/src/compiler/inline-variables.ts @@ -6,6 +6,7 @@ import type { } from "lightningcss"; import type { UniqueVarInfo } from "./compiler.types"; +import { soleCSSWideKeyword } from "./css-wide-keywords"; export function inlineVariables( stylesheet: StyleSheet, @@ -118,6 +119,14 @@ function replaceDeclaration( return; } + if (readsGuaranteedInvalid(declaration.value.value, vars)) { + // css-variables-1 §3.1: the declaration is invalid at computed-value time, as if its value were `unset`, and a custom property's is the guaranteed-invalid value + declaration.value.value = [ + keywordToken(declaration.property === "custom" ? "initial" : "unset"), + ]; + return declaration; + } + declaration.value.value = declaration.value.value.flatMap((part) => { return flattenPart(part, vars); }); @@ -125,6 +134,33 @@ function replaceDeclaration( return declaration; } +function readsGuaranteedInvalid( + parts: TokenOrValue[], + vars: Map, +): boolean { + return parts.some((part) => { + if (part.type === "var") { + const varInfo = vars.get(part.value.name.ident); + + if (varInfo === undefined || varInfo.value !== undefined) { + return false; + } + + return part.value.fallback + ? readsGuaranteedInvalid(part.value.fallback, vars) + : true; + } else if (part.type === "function") { + return readsGuaranteedInvalid(part.value.arguments, vars); + } + + return false; + }); +} + +function keywordToken(keyword: "initial" | "unset"): TokenOrValue { + return { type: "token", value: { type: "ident", value: keyword } }; +} + function flattenPart( part: TokenOrValue, vars: Map, @@ -187,16 +223,15 @@ function flattenVar( return flattenPart(part, vars); }); - // If the variable is shorthand for "initial", substitute it for undefined - if ( - varInfoValue?.length === 2 && - varInfoValue[0]?.type === "token" && - varInfoValue[0].value.type === "ident" && - varInfoValue[0].value.value === "initial" && - varInfoValue[1]?.type === "token" && - varInfoValue[1].value.type === "white-space" - ) { + const keyword = soleCSSWideKeyword(varInfo.value)?.keyword; + + if (keyword === "initial") { + // css-variables-1 §2.2: the guaranteed-invalid value, which no var() substitutes varInfoValue = undefined; + } else if (keyword !== undefined) { + // The parent's value, which only the runtime knows + vars.delete(name); + return; } varInfo = { diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index c621f0c5..32ea4ae2 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -7,7 +7,7 @@ import type { import { StyleCollection } from "react-native-css/native-internal"; import { assignPublishedColor, - assignRuleVariables, + assignSubtreeVariables, } from "react-native-css/utilities"; import { testRule } from "../conditions"; @@ -145,7 +145,8 @@ export function updateRules( } publishedColor = - assignRuleVariables(variables, rule.v) ?? publishedColor; + assignSubtreeVariables(variables, rule.v, inheritedVariables) ?? + publishedColor; } if (rule.c) { diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index c082a08e..5be071c8 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -3,11 +3,14 @@ import type { InlineVariable, StyleDeclaration, StyleDescriptor, + StyleFunction, StyleRule, } from "react-native-css/compiler"; import { assignPublishedColor, assignRuleVariables, + inheritedColorLookup, + isStyleFunction, Specificity as S, } from "react-native-css/utilities"; @@ -19,7 +22,7 @@ import { type VariableContextValue, } from "../reactivity"; import { transformKeys } from "./defaults"; -import { resolveValue } from "./resolve"; +import { resolveValue, type ResolveValueOptions } from "./resolve"; export function calculateProps( get: Getter, @@ -189,7 +192,7 @@ export function applyDeclarations( if (transformKeys.has(prop)) { transformStyles.push(() => { - value = resolveValue(originalValue, get, { + value = resolveDeclaration(originalValue, prop, get, { inlineVariables, inheritedVariables, renderGuards: guards, @@ -202,7 +205,7 @@ export function applyDeclarations( delayedStyles.push(() => { if (getDeepPath(declarationTarget, prop) === value) { delete declarationTarget[prop]; - value = resolveValue(originalValue, get, { + value = resolveDeclaration(originalValue, prop, get, { inlineVariables, inheritedVariables, renderGuards: guards, @@ -214,7 +217,7 @@ export function applyDeclarations( }); } } else { - value = resolveValue(value, get, { + value = resolveDeclaration(value, prop, get, { inlineVariables, inheritedVariables, renderGuards: guards, @@ -227,3 +230,67 @@ export function applyDeclarations( } } } + +// css-variables-1 §3.1: a declaration a var() leaves without a value is invalid at computed-value time, and behaves as if its value were `unset` +function resolveDeclaration( + descriptor: StyleDescriptor, + prop: string | number, + get: Getter, + options: ResolveValueOptions, +): unknown { + const value = resolveValue(descriptor, get, options); + + if (!isInvalidAtComputedValueTime(descriptor, value)) { + return value; + } + + return resolveValue( + unsetDescriptor(descriptor, prop, options.computingColor), + get, + options, + ); +} + +// The `unset` the compiler writes for the same property: the inherited colour for `color`, the runtime parser's own reset for a property it parses, and the clearing literal otherwise +function unsetDescriptor( + descriptor: StyleDescriptor, + prop: string | number, + computingColor: boolean | undefined, +): StyleDescriptor { + if (computingColor) { + return inheritedColorLookup(); + } else if (isStyleFunction(descriptor) && descriptor[1] === prop) { + const [marker, name, , ...processing] = descriptor; + return [marker, name, "unset", ...processing] as StyleFunction; + } + + return "unset"; +} + +// A var() that leaves the declaration nothing: no value, or as the whole value an empty fallback +function isInvalidAtComputedValueTime( + descriptor: StyleDescriptor, + value: unknown, +): boolean { + if (!readsVariable(descriptor)) { + return false; + } + + return ( + value === undefined || + (isStyleFunction(descriptor) && + descriptor[1] === "var" && + Array.isArray(value) && + value.length === 0) + ); +} + +function readsVariable(descriptor: StyleDescriptor): boolean { + if (!Array.isArray(descriptor)) { + return false; + } else if (isStyleFunction(descriptor)) { + return descriptor[1] === "var" || readsVariable(descriptor[2]); + } + + return descriptor.some(readsVariable); +} diff --git a/src/native/styles/functions/filters.ts b/src/native/styles/functions/filters.ts index 92e6bdd0..de9b7a9e 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 { resolveListEntries } from "../list-entries"; import { resetsToNone } from "../resets-to-none"; import type { StyleFunctionResolver } from "../resolve"; import { shorthandHandler } from "../shorthands/_handler"; @@ -101,7 +102,10 @@ export const dropShadow: StyleFunctionResolver = ( /** CSS variables may expand to multiple filters; native filter arrays must be flat. */ export const filter: StyleFunctionResolver = (resolveValue, descriptor) => { - const value: unknown = resolveValue(descriptor[2]); + const list = descriptor[2]; + const value: unknown = isStyleDescriptorArray(list) + ? resolveListEntries(resolveValue, list) + : resolveValue(list); if (Array.isArray(value)) { const filters: unknown[] = value.flat(Infinity); if (filters.some(resetsToNone)) return null; diff --git a/src/native/styles/list-entries.ts b/src/native/styles/list-entries.ts new file mode 100644 index 00000000..4bb18edb --- /dev/null +++ b/src/native/styles/list-entries.ts @@ -0,0 +1,13 @@ +import type { StyleDescriptor } from "react-native-css/compiler"; + +import type { SimpleResolveValue } from "./resolve"; + +/** Each entry of a list resolved on its own, or undefined when one resolves to nothing, which makes the whole declaration invalid at computed-value time (css-variables-1 §3.1) */ +export function resolveListEntries( + resolveValue: SimpleResolveValue, + list: readonly StyleDescriptor[], +): unknown[] | undefined { + const resolved = list.map((entry) => resolveValue(entry)); + + return resolved.some((entry) => entry === undefined) ? undefined : resolved; +} diff --git a/src/native/styles/shorthands/transform.ts b/src/native/styles/shorthands/transform.ts index 1f2852a7..0694a659 100644 --- a/src/native/styles/shorthands/transform.ts +++ b/src/native/styles/shorthands/transform.ts @@ -12,9 +12,12 @@ export const transform: StyleFunctionResolver = ( const transforms = resolveValue(transformDescriptor[2]); if (Array.isArray(transforms)) { - return transforms.some(resetsToNone) + // An empty fallback substitutes no transform + const entries: unknown[] = transforms.flat(); + + return entries.some(resetsToNone) ? undefined - : (transforms.filter((transform) => transform !== undefined) as unknown); + : entries.filter((transform) => transform !== undefined); } else if (transforms) { // If it's a single transform, wrap it in an array return [transforms]; diff --git a/src/native/styles/variables.ts b/src/native/styles/variables.ts index 0b785645..7c46a544 100644 --- a/src/native/styles/variables.ts +++ b/src/native/styles/variables.ts @@ -3,11 +3,19 @@ import { rootVariables, universalVariables, } from "react-native-css/native-internal"; -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + isGuaranteedInvalidValue, + isInheritedValue, + isStyleDescriptorArray, +} from "react-native-css/utilities"; import { VAR_SYMBOL, type Getter } from "../reactivity"; import type { ResolveValueOptions, SimpleResolveValue } from "./resolve"; +type Lookup = + | { readonly settled: true; readonly value: unknown } + | { readonly settled: false }; + export function varResolver( resolve: SimpleResolveValue, fn: StyleFunction, @@ -56,41 +64,66 @@ export function varResolver( variableHistory.add(name); - try { - let value = resolve(inlineVariables?.[name] as StyleDescriptor); - if (variableCycles.has(name)) return resolve(fallback); - if (value !== undefined) { - options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; - options.inlineVariables[name] = value; + const memoise = (value: unknown) => { + options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; + options.inlineVariables[name] = value; + return value; + }; - return value; + // A universal selector declares the variable on every element: below a class, above inheritance + const lookUpUniversal = (): Lookup => { + const universal = get(universalVariables(name)); + if (isGuaranteedInvalidValue(universal)) { + return { settled: true, value: resolve(fallback) }; + } else if (isInheritedValue(universal)) { + return { settled: false }; } - // A universal selector declares the variable on this element. It has lower - // specificity than the element's class declarations, but beats inheritance. - value = resolve(get(universalVariables(name))); - if (variableCycles.has(name)) return resolve(fallback); - if (value !== undefined) { - options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; - options.inlineVariables[name] = value; - return value; + const value = resolve(universal); + if (variableCycles.has(name)) { + return { settled: true, value: resolve(fallback) }; } - // Guards retain the ancestor's raw descriptor, matching the context value. - if (name in variables) { + return value === undefined + ? { settled: false } + : { settled: true, value: memoise(value) }; + }; + + try { + const declared = inlineVariables?.[name] as StyleDescriptor; + if (isGuaranteedInvalidValue(declared)) return resolve(fallback); + + // A custom property set to `inherit` takes its parent's value, over every declaration on the element + const inheriting = isInheritedValue(declared); + + if (!inheriting) { + const value = resolve(declared); + if (variableCycles.has(name)) return resolve(fallback); + if (value !== undefined) return memoise(value); + + const universal = lookUpUniversal(); + if (universal.settled) return universal.value; + } + + // Guards retain the ancestor's raw descriptor, matching the context value. An entry with no value is no declaration. + if (Object.hasOwn(variables, name) && variables[name] !== undefined) { renderGuards?.push(["v", name, variables[name]]); - value = resolve(variables[name]); + if (isGuaranteedInvalidValue(variables[name])) return resolve(fallback); + const value = resolve(variables[name]); return variableCycles.has(name) ? resolve(fallback) : value; } - value = resolve(get(rootVariables(name))); - if (variableCycles.has(name)) return resolve(fallback); - if (value !== undefined) { - options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; - options.inlineVariables[name] = value; - return value; + if (inheriting) { + // Undeclared in the context, the parent's own value is what `*` declares on it + const universal = lookUpUniversal(); + if (universal.settled) return universal.value; } + // At the root a keyword resolves to nothing, so the fallback applies + const value = resolve(get(rootVariables(name))); + if (variableCycles.has(name)) return resolve(fallback); + if (value !== undefined) return memoise(value); + return resolve(fallback); } finally { variableHistory.delete(name); diff --git a/src/utilities/custom-property-keywords.ts b/src/utilities/custom-property-keywords.ts new file mode 100644 index 00000000..da6cdb0f --- /dev/null +++ b/src/utilities/custom-property-keywords.ts @@ -0,0 +1,31 @@ +import type { StyleFunction } from "react-native-css/compiler"; + +import { isStyleFunction } from "./style-descriptor"; + +// css-variables-1 §2: a custom property set to a CSS-wide keyword takes the keyword's value itself, which `var()` then substitutes +const INHERITED_VALUE = "inherit"; +const GUARANTEED_INVALID_VALUE = "initial"; + +/** The value of a custom property set to `inherit`, `unset`, `revert` or `revert-layer`: its parent's */ +export function inheritedValue(): StyleFunction { + return [{}, INHERITED_VALUE]; +} + +/** The value of a custom property set to `initial`: the guaranteed-invalid value, which no `var()` substitutes */ +export function guaranteedInvalidValue(): StyleFunction { + return [{}, GUARANTEED_INVALID_VALUE]; +} + +export function isInheritedValue(value: unknown): boolean { + return ( + isStyleFunction(value) && value.length === 2 && value[1] === INHERITED_VALUE + ); +} + +export function isGuaranteedInvalidValue(value: unknown): boolean { + return ( + isStyleFunction(value) && + value.length === 2 && + value[1] === GUARANTEED_INVALID_VALUE + ); +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index dd762410..b53d57e3 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -2,3 +2,4 @@ export * from "./specificity"; export * from "./style-descriptor"; export * from "./dot-notation.types"; export * from "./inherited-color"; +export * from "./custom-property-keywords"; diff --git a/src/utilities/inherited-color.ts b/src/utilities/inherited-color.ts index d025a92b..6aac8efe 100644 --- a/src/utilities/inherited-color.ts +++ b/src/utilities/inherited-color.ts @@ -4,11 +4,17 @@ import type { StyleRule, } from "react-native-css/compiler"; +import { isInheritedValue } from "./custom-property-keywords"; 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"; +// A fresh tuple per call, because a descriptor is owned by the rule it lands in +export function inheritedColorLookup() { + return [{}, "var", INHERITED_COLOR_VARIABLE] as const satisfies StyleFunction; +} + export function isInheritedColorLookup(fn: StyleFunction): boolean { if (fn[1] !== "var") { return false; @@ -20,10 +26,36 @@ 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` */ +/** Assigns a rule's custom properties to the element's own, and returns the inherited colour it publishes for `assignPublishedColor` */ export function assignRuleVariables( variables: Record, entries: NonNullable, +): StyleDescriptor | undefined { + return assignEntries(entries, (name, value) => { + variables[name] = value; + }); +} + +/** Assigns a rule's custom properties to what its subtree inherits, where one set to `inherit` hands down the parent's value */ +export function assignSubtreeVariables( + variables: Record, + entries: NonNullable, + parentVariables: Readonly>, +): StyleDescriptor | undefined { + return assignEntries(entries, (name, value) => { + if (!isInheritedValue(value)) { + variables[name] = value; + } else { + variables[name] = Object.hasOwn(parentVariables, name) + ? parentVariables[name] + : undefined; + } + }); +} + +function assignEntries( + entries: NonNullable, + assign: (name: string, value: StyleDescriptor) => void, ): StyleDescriptor | undefined { let color: StyleDescriptor | undefined; @@ -31,7 +63,7 @@ export function assignRuleVariables( if (name === INHERITED_COLOR_VARIABLE) { color = value; } else { - variables[name] = value; + assign(name, value); } } From 25ae772703d867bc47a3daea70dbb4809816b9d9 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 23:53:44 +0300 Subject: [PATCH 19/29] fix: a var() that substitutes nothing invalidates its value wherever it sits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A var() with no fallback for a variable that has no value left its declaration a value when it sat inside a list, a function or another custom property: `--reader: var(--source) 1px` read `1px`, and a custom property whose value failed fell back to its ancestor's declaration instead of the reader's fallback. The lookup now records the failed substitution itself, so the declaration is invalid at computed-value time and a custom property holding one is the guaranteed-invalid value (css-variables-1 §3.1). A channel rncss publishes for a computed value, such as the em, is a default when nothing publishes it, never a failure. The inliner flattens every variable a value reads, inside functions and fallbacks too, before substituting it, so the result no longer depends on declaration order, and leaves each member of a cycle to the runtime. A custom property reading the guaranteed-invalid value is guaranteed-invalid itself, a declaration whose value substitutes to nothing is unset, and an empty custom property keeps its empty value, which substitutes nothing inside a list at runtime as well. --- src/__tests__/compiler/compiler.test.tsx | 8 + src/__tests__/native/keywords.test.tsx | 172 ++++++++++++++++++++++ src/__tests__/native/transform.test.tsx | 15 ++ src/__tests__/native/variables.test.tsx | 50 +++++++ src/compiler/compiler.types.ts | 13 +- src/compiler/css-wide-keywords.ts | 6 +- src/compiler/declarations.ts | 10 +- src/compiler/inline-variables.ts | 108 +++++++++----- src/native/styles/calculate-props.ts | 27 ++-- src/native/styles/functions/filters.ts | 6 +- src/native/styles/list-entries.ts | 13 -- src/native/styles/resolve.ts | 80 +++++++--- src/native/styles/shorthands/transform.ts | 2 +- src/native/styles/variables.ts | 170 +++++++++++++++------ 14 files changed, 534 insertions(+), 146 deletions(-) delete mode 100644 src/native/styles/list-entries.ts diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 261d8d50..3ea8a806 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -756,6 +756,14 @@ describe("CSS-wide keywords", () => { ).toEqual([[{}, "inherit"]]); }); + test("a variable two branches read is inlined into both, as no cycle", () => { + expect( + declarationsFor( + `.child { --diamond-reader: calc(var(--diamond-left) + var(--diamond-right)); --diamond-left: var(--diamond-base); --diamond-right: var(--diamond-base); --diamond-base: 3px; width: var(--diamond-reader, 1px); }`, + ), + ).toStrictEqual(declarationsFor(`.child { width: 6px; }`)); + }); + test("a custom property set to initial is inlined as the guaranteed-invalid value, so a read takes its fallback", () => { expect( declarationsFor(`.child { --brand: initial; color: var(--brand, red); }`), diff --git a/src/__tests__/native/keywords.test.tsx b/src/__tests__/native/keywords.test.tsx index f52939d1..30f5e1ac 100644 --- a/src/__tests__/native/keywords.test.tsx +++ b/src/__tests__/native/keywords.test.tsx @@ -244,6 +244,30 @@ describe.each(routes)("--brand: initial, %s", (_route, options) => { }); }); + test("hides a :root declaration from a descendant", () => { + registerCSS( + ` + :root { --hidden-root: red; } + .middle { --hidden-root: initial; } + .child { color: var(--hidden-root, green); } + .other { --hidden-root: blue; } + `, + options, + ); + + render( + + + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "green", + }); + }); + test("over an earlier rule makes var(--brand) invalid, so the colour inherits", () => { expect( renderNested( @@ -259,6 +283,141 @@ describe.each(routes)("--brand: initial, %s", (_route, options) => { }); }); +// Chromium 153, Firefox 155 and WebKit 26.6 give a custom property its var() leaves without a value the guaranteed-invalid value (css-variables-1 §3.1), so a var() reading it takes its fallback +describe.each(routes)( + "a custom property a var() leaves without a value, %s", + (_route, options) => { + test("takes the var() fallback over an ancestor's declaration", () => { + expect( + renderNested( + ` + .parent { --unresolved: blue; } + .child { --unresolved: var(--missing); color: var(--unresolved, green); } + `, + options, + ), + ).toStrictEqual({ color: "green" }); + }); + + test("hides the ancestor's declaration from a descendant", () => { + registerCSS( + ` + .parent { --unresolved-down: blue; } + .middle { --unresolved-down: var(--missing); } + .child { color: var(--unresolved-down, green); } + `, + options, + ); + + render( + + + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "green", + }); + }); + + test("reads the guaranteed-invalid value inside a longer value", () => { + registerCSS( + `.child { --source: initial; --reader: var(--source) 1px; width: var(--reader, 5px); }`, + options, + ); + + render(); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + width: 5, + }); + }); + + test("reads it inside a function declared before the variable it reads", () => { + registerCSS( + `.child { --reader: calc(var(--source) * 2); --source: initial; width: var(--reader, 5px); }`, + options, + ); + + render(); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + width: 5, + }); + }); + + test.each([ + ["declared once", ""], + ["declared twice", ".other { --reader: 1px; }"], + ])( + "an empty value substituted whole lets the colour inherit, the variable %s", + (_declarations, other) => { + expect( + renderNested( + ` + .parent { color: green; } + .child { --source: initial; --reader: var(--source,); color: var(--reader, red); } + ${other} + `, + options, + ), + ).toStrictEqual({ color: "#008000" }); + }, + ); + + // Chromium 153, Firefox 155 and WebKit 26.6 substitute an empty custom property's value as nothing, over an ancestor's + test("an authored empty value shadows the ancestor's, and lets the colour inherit", () => { + expect( + renderNested( + ` + .parent { color: green; --authored-empty: red; } + .child { --authored-empty: ; color: var(--authored-empty, blue); } + `, + options, + ), + ).toStrictEqual({ color: "#008000" }); + }); + + test("an authored empty value substitutes nothing inside a longer value", () => { + registerCSS( + `.child { --authored-empty-list: ; width: calc(4px var(--authored-empty-list) + 2px); }`, + options, + ); + + render(); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + width: 6, + }); + }); + + // Chromium 153, Firefox 155 and WebKit 26.6 unset both, reading neither the fallback nor the parent's declaration + test("a value that resolves to nothing reads the same each time it is read", () => { + registerCSS( + ` + .parent { --memo: 9px; } + .child { --memo-empty: initial; --memo: calc(var(--memo-empty,) * 2); width: var(--memo, 5px); height: var(--memo, 5px); } + .other { --memo: 1px; --memo-empty: 1px; } + `, + options, + ); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + width: undefined, + height: undefined, + }); + }); + }, +); + // Chromium 153, Firefox 155 and WebKit 26.6: the root has no parent, so inherit gives the guaranteed-invalid value there too test.each(["inherit", "initial"])( ":root { --brand: %s } leaves var(--brand) its fallback", @@ -303,6 +462,19 @@ test("* { --brand: initial } leaves var(--brand) its fallback", () => { ).toStrictEqual({ color: "green" }); }); +test("--brand: inherit under *'s initial leaves var(--brand) its fallback", () => { + expect( + renderNested( + ` + * { --universal-gone: initial; } + .child { --universal-gone: inherit; color: var(--universal-gone, green); } + .other { --universal-gone: blue; } + `, + {}, + ), + ).toStrictEqual({ color: "green" }); +}); + test("* { --universal-over-ancestor: initial } hides an ancestor's declaration", () => { expect( renderNested( diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index 2571cab3..f9b6211a 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -219,3 +219,18 @@ test.each([ expect(component.props.style).toStrictEqual({}); }); + +// Chromium 153, Firefox 155 and WebKit 26.6 substitute each transform a variable holds, in place +test("a var() holding several transforms substitutes each of them", () => { + registerCSS( + `.my-class { --several: rotate(45deg) scale(2); transform: translateX(1px) var(--several); } .other { --several: none; }`, + ); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + transform: [{ translateX: 1 }, { rotate: "45deg" }, { scale: 2 }], + }); +}); diff --git a/src/__tests__/native/variables.test.tsx b/src/__tests__/native/variables.test.tsx index 52e24f21..3ebad172 100644 --- a/src/__tests__/native/variables.test.tsx +++ b/src/__tests__/native/variables.test.tsx @@ -329,3 +329,53 @@ test.each([ color: "green", }); }); + +// Chromium 153, Firefox 155 and WebKit 26.6 compute a variable read inside a function from the variable's own computed value, wherever either is declared +test.each([ + [ + "after", + ".child { --order-source: 3px; --order-middle: var(--order-source); --order-reader: calc(var(--order-middle) * 2); width: var(--order-reader, 5px); }", + ], + [ + "before", + ".child { --order-reader: calc(var(--order-middle) * 2); --order-middle: var(--order-source); --order-source: 3px; width: var(--order-reader, 5px); }", + ], + [ + "inside an undeclared variable's fallback, before", + ".child { --order-reader: var(--order-absent, calc(var(--order-middle) * 2)); --order-middle: var(--order-source); --order-source: 3px; width: var(--order-reader); }", + ], +])( + "a variable read inside a function declared %s the chain it reads resolves through it", + (_order, css) => { + registerCSS(css); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 6 }); + }, +); + +// Chromium 153, Firefox 155 and WebKit 26.6 give every member of a cycle the guaranteed-invalid value however few rules declare it, and resolve a variable two branches read in both +test.each([ + [ + "a cycle declared once leaves var() its fallback", + ".child { --cycle-once-a: var(--cycle-once-b); --cycle-once-b: var(--cycle-once-a); color: var(--cycle-once-b, green); }", + { color: "green" }, + ], + [ + "a cycle read twice in one declaration takes each fallback", + ".child { --cycle-x: var(--cycle-y); --cycle-y: var(--cycle-x); width: calc(var(--cycle-x, 2px) + var(--cycle-x, 3px)); } .other { --cycle-x: 1px; --cycle-y: 1px; }", + { width: 5 }, + ], + [ + "a variable two branches read is no cycle", + ".child { --diamond-reader: calc(var(--diamond-left) + var(--diamond-right)); --diamond-left: var(--diamond-base); --diamond-right: var(--diamond-base); --diamond-base: 3px; width: var(--diamond-reader, 1px); }", + { width: 6 }, + ], +])("%s", (_name, css, style) => { + registerCSS(css); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual(style); +}); diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..d063df44 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -159,11 +159,14 @@ export type InlineVariable = { [key: string]: unknown | undefined; }; -export type UniqueVarInfo = { - count: number; - value: TokenOrValue[] | undefined; - flat?: true; -}; +/** A custom property's value as declared, until flattening settles what it substitutes: tokens, or undefined for the guaranteed-invalid value */ +export type UniqueVarInfo = + | { readonly flat?: undefined; count: number; value: TokenOrValue[] } + | { + readonly flat: true; + count: number; + value: TokenOrValue[] | undefined; + }; /****************************** Animations ******************************/ diff --git a/src/compiler/css-wide-keywords.ts b/src/compiler/css-wide-keywords.ts index 92dc50ba..26d49978 100644 --- a/src/compiler/css-wide-keywords.ts +++ b/src/compiler/css-wide-keywords.ts @@ -15,13 +15,13 @@ export function isCSSWideKeyword(keyword: string): keyword is CSSWideKeyword { } export interface SoleCSSWideKeyword { - keyword: CSSWideKeyword; - written: string; + readonly keyword: CSSWideKeyword; + readonly written: string; } // A CSS-wide keyword is a value only on its own (css-values-4 §2.1), and a custom property's value keeps the whitespace around it export function soleCSSWideKeyword( - tokens: TokenOrValue[] | null | undefined, + tokens: readonly TokenOrValue[] | null | undefined, ): SoleCSSWideKeyword | undefined { const [token, ...rest] = (tokens ?? []).filter( (part) => part.type !== "token" || part.value.type !== "white-space", diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 569b907f..ef863d21 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -1165,7 +1165,7 @@ function parseVariable( ): StyleFunction { const name = variable.name.ident.slice(2); // An empty fallback substitutes nothing, which a longer value can hold (css-variables-1 §3) - const fallback = isEmptyFallback(variable.fallback) + const fallback = isEmptyTokenList(variable.fallback) ? [] : parseSubstitutionFallback( variable.fallback, @@ -1178,7 +1178,7 @@ function parseVariable( return [{}, "var", fallback === undefined ? name : [name, fallback], 1]; } -function isEmptyFallback(tokens: TokenOrValue[] | null | undefined): boolean { +function isEmptyTokenList(tokens: TokenOrValue[] | null | undefined): boolean { return ( Array.isArray(tokens) && tokens.every( @@ -1346,6 +1346,12 @@ export function parseCustomDeclaration( if (parsedFontVariant !== undefined) { builder.addDescriptor(property, parsedFontVariant); } + } else if ( + property.startsWith("--") && + isEmptyTokenList(declaration.value.value) + ) { + // css-variables-1 §2: a custom property's value may be empty, and substitutes nothing + builder.addDescriptor(property, []); } else if ( validProperties.has(property) || property.startsWith("--") || diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts index 118a6771..62811155 100644 --- a/src/compiler/inline-variables.ts +++ b/src/compiler/inline-variables.ts @@ -119,23 +119,34 @@ function replaceDeclaration( return; } - if (readsGuaranteedInvalid(declaration.value.value, vars)) { - // css-variables-1 §3.1: the declaration is invalid at computed-value time, as if its value were `unset`, and a custom property's is the guaranteed-invalid value + const value = readsGuaranteedInvalid(declaration.value.value, vars) + ? undefined + : declaration.value.value.flatMap((part) => flattenPart(part, vars)); + + // css-variables-1 §3.1: a declaration a var() leaves without a value is invalid at computed-value time, as if its value were `unset`, and a custom property's is the guaranteed-invalid value + if ( + value === undefined || + (declaration.property === "unparsed" && substitutesNothing(value)) + ) { declaration.value.value = [ keywordToken(declaration.property === "custom" ? "initial" : "unset"), ]; return declaration; } - declaration.value.value = declaration.value.value.flatMap((part) => { - return flattenPart(part, vars); - }); + declaration.value.value = value; return declaration; } +function substitutesNothing(parts: readonly TokenOrValue[]): boolean { + return parts.every( + (part) => part.type === "token" && part.value.type === "white-space", + ); +} + function readsGuaranteedInvalid( - parts: TokenOrValue[], + parts: readonly TokenOrValue[], vars: Map, ): boolean { return parts.some((part) => { @@ -195,50 +206,79 @@ function flattenPart( function flattenVar( name: string, vars: Map, - seen = new Set(), + path = new Set(), ) { - if (seen.has(name)) { - vars.delete(name); + if (path.has(name)) { + // css-variables-1 §2.3: every variable in a cycle is the guaranteed-invalid value, which the runtime's cycle guard answers + leaveCycleToRuntime(name, path, vars); + return; } - seen.add(name); - - let varInfo = vars.get(name); + const varInfo = vars.get(name); if (!varInfo || varInfo.flat) { return; } - let varInfoValue = varInfo.value?.flatMap((part) => { - if (part.type === "var") { - const name = part.value.name.ident; + const parts = varInfo.value; - flattenVar(name, vars, seen); + // Each variable the value reads is flattened before the value substitutes it, whichever was declared first + path.add(name); + for (const readName of readVariableNames(parts)) { + flattenVar(readName, vars, path); + } + path.delete(name); - const nestedVarInfo = vars.get(part.value.name.ident); - if (nestedVarInfo?.value) { - return nestedVarInfo.value; - } - } - return flattenPart(part, vars); - }); + if (!vars.has(name)) { + return; + } - const keyword = soleCSSWideKeyword(varInfo.value)?.keyword; + const keyword = soleCSSWideKeyword(parts)?.keyword; - if (keyword === "initial") { - // css-variables-1 §2.2: the guaranteed-invalid value, which no var() substitutes - varInfoValue = undefined; - } else if (keyword !== undefined) { + if (keyword !== undefined && keyword !== "initial") { // The parent's value, which only the runtime knows vars.delete(name); return; } - varInfo = { - count: 1, - flat: true, - value: varInfoValue, - }; + // css-variables-1 §2.2, §3.1: `initial`, and a value that reads the guaranteed-invalid value, are the guaranteed-invalid value, which no var() substitutes + const value = + keyword === "initial" || readsGuaranteedInvalid(parts, vars) + ? undefined + : parts.flatMap((part) => flattenPart(part, vars)); + + vars.set(name, { count: 1, flat: true, value }); +} + +function leaveCycleToRuntime( + reentered: string, + path: ReadonlySet, + vars: Map, +) { + let inCycle = false; + + for (const name of path) { + inCycle ||= name === reentered; + + if (inCycle) { + vars.delete(name); + } + } +} + +// The names `flattenPart` substitutes: each var(), its fallback's, and those inside a function's arguments +function readVariableNames( + parts: readonly TokenOrValue[], + names = new Set(), +): ReadonlySet { + for (const part of parts) { + if (part.type === "var") { + names.add(part.value.name.ident); + readVariableNames(part.value.fallback ?? [], names); + } else if (part.type === "function") { + readVariableNames(part.value.arguments, names); + } + } - vars.set(name, varInfo); + return names; } diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 5be071c8..1a0a1c19 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -22,7 +22,12 @@ import { type VariableContextValue, } from "../reactivity"; import { transformKeys } from "./defaults"; -import { resolveValue, type ResolveValueOptions } from "./resolve"; +import { + isEmptySubstitution, + resolveValue, + type ResolveValueOptions, + type Substitution, +} from "./resolve"; export function calculateProps( get: Getter, @@ -238,9 +243,10 @@ function resolveDeclaration( get: Getter, options: ResolveValueOptions, ): unknown { - const value = resolveValue(descriptor, get, options); + const substitution: Substitution = { failed: false }; + const value = resolveValue(descriptor, get, { ...options, substitution }); - if (!isInvalidAtComputedValueTime(descriptor, value)) { + if (!isInvalidAtComputedValueTime(descriptor, value, substitution)) { return value; } @@ -267,22 +273,19 @@ function unsetDescriptor( return "unset"; } -// A var() that leaves the declaration nothing: no value, or as the whole value an empty fallback +// A var() that substitutes nothing anywhere in the value, or leaves the declaration nothing: no value, or as the whole value an empty fallback function isInvalidAtComputedValueTime( descriptor: StyleDescriptor, value: unknown, + substitution: Readonly, ): boolean { - if (!readsVariable(descriptor)) { + if (substitution.failed) { + return true; + } else if (!readsVariable(descriptor)) { return false; } - return ( - value === undefined || - (isStyleFunction(descriptor) && - descriptor[1] === "var" && - Array.isArray(value) && - value.length === 0) - ); + return value === undefined || isEmptySubstitution(descriptor, value); } function readsVariable(descriptor: StyleDescriptor): boolean { diff --git a/src/native/styles/functions/filters.ts b/src/native/styles/functions/filters.ts index de9b7a9e..92e6bdd0 100644 --- a/src/native/styles/functions/filters.ts +++ b/src/native/styles/functions/filters.ts @@ -1,6 +1,5 @@ import { isStyleDescriptorArray } from "react-native-css/utilities"; -import { resolveListEntries } from "../list-entries"; import { resetsToNone } from "../resets-to-none"; import type { StyleFunctionResolver } from "../resolve"; import { shorthandHandler } from "../shorthands/_handler"; @@ -102,10 +101,7 @@ export const dropShadow: StyleFunctionResolver = ( /** CSS variables may expand to multiple filters; native filter arrays must be flat. */ export const filter: StyleFunctionResolver = (resolveValue, descriptor) => { - const list = descriptor[2]; - const value: unknown = isStyleDescriptorArray(list) - ? resolveListEntries(resolveValue, list) - : resolveValue(list); + const value: unknown = resolveValue(descriptor[2]); if (Array.isArray(value)) { const filters: unknown[] = value.flat(Infinity); if (filters.some(resetsToNone)) return null; diff --git a/src/native/styles/list-entries.ts b/src/native/styles/list-entries.ts deleted file mode 100644 index 4bb18edb..00000000 --- a/src/native/styles/list-entries.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { StyleDescriptor } from "react-native-css/compiler"; - -import type { SimpleResolveValue } from "./resolve"; - -/** Each entry of a list resolved on its own, or undefined when one resolves to nothing, which makes the whole declaration invalid at computed-value time (css-variables-1 §3.1) */ -export function resolveListEntries( - resolveValue: SimpleResolveValue, - list: readonly StyleDescriptor[], -): unknown[] | undefined { - const resolved = list.map((entry) => resolveValue(entry)); - - return resolved.some((entry) => entry === undefined) ? undefined : resolved; -} diff --git a/src/native/styles/resolve.ts b/src/native/styles/resolve.ts index 27c00309..51f58630 100644 --- a/src/native/styles/resolve.ts +++ b/src/native/styles/resolve.ts @@ -8,6 +8,7 @@ import type { import { isInheritedColorLookup, isStyleDescriptorArray, + isStyleFunction, } from "react-native-css/utilities"; import type { RenderGuard } from "../conditions/guards"; @@ -59,10 +60,17 @@ export type ResolveValueOptions = { variableCycles?: Set; /** Resolving the element's own `color`, where css-color-3 §4.4 makes `currentcolor` the inherited colour */ computingColor?: boolean; + /** Where a var() that substitutes nothing is recorded, since a list drops the entry it leaves */ + substitution?: Substitution; /** Pass down to perform recursive calculations and avoid circular dependencies */ calculateProps?: typeof calculateProps; }; +/** Whether a var() in the value being resolved substituted nothing, which makes the value invalid at computed-value time (css-variables-1 §3.1) */ +export interface Substitution { + failed: boolean; +} + export function resolveValue( value: StyleDescriptor, get: Getter, @@ -96,15 +104,13 @@ export function resolveValue( } if (isStyleDescriptorArray(value)) { - value = value - .map((d) => resolveValue(d, get, options)) - .filter((d) => d !== undefined); + return value.flatMap((entry) => { + const resolved: unknown = resolveValue(entry, get, options); - if (castToArray && !Array.isArray(value)) { - return [value]; - } else { - return value; - } + return resolved === undefined || isEmptySubstitution(entry, resolved) + ? [] + : [resolved]; + }); } const name = value[1]; @@ -114,22 +120,7 @@ export function resolveValue( }; if (name === "var") { - if (options.computingColor && isInheritedColorLookup(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); + return resolveVariable(value, get, options); } else if (name in functionResolvers) { const fn = functionResolvers[name as keyof typeof functionResolvers]; @@ -186,3 +177,44 @@ export function resolveValue( } } } + +// css-variables-1 §3: a var() whose value or fallback is empty substitutes nothing +export function isEmptySubstitution( + descriptor: StyleDescriptor, + value: unknown, +): boolean { + return ( + isStyleFunction(descriptor) && + descriptor[1] === "var" && + Array.isArray(value) && + value.length === 0 + ); +} + +function resolveVariable( + fn: StyleFunction, + get: Getter, + options: ResolveValueOptions, +): unknown { + if (options.computingColor && isInheritedColorLookup(fn)) { + const inheritedScope: ResolveValueOptions = { + ...options, + inlineVariables: undefined, + computingColor: false, + }; + + return varResolver( + (inner) => resolveValue(inner, get, inheritedScope), + fn, + get, + inheritedScope, + ); + } + + return varResolver( + (inner) => resolveValue(inner, get, options), + fn, + get, + options, + ); +} diff --git a/src/native/styles/shorthands/transform.ts b/src/native/styles/shorthands/transform.ts index 0694a659..0d180263 100644 --- a/src/native/styles/shorthands/transform.ts +++ b/src/native/styles/shorthands/transform.ts @@ -12,7 +12,7 @@ export const transform: StyleFunctionResolver = ( const transforms = resolveValue(transformDescriptor[2]); if (Array.isArray(transforms)) { - // An empty fallback substitutes no transform + // A var() holding several transforms substitutes each of them into the list const entries: unknown[] = transforms.flat(); return entries.some(resetsToNone) diff --git a/src/native/styles/variables.ts b/src/native/styles/variables.ts index 7c46a544..61d748d1 100644 --- a/src/native/styles/variables.ts +++ b/src/native/styles/variables.ts @@ -12,9 +12,23 @@ import { import { VAR_SYMBOL, type Getter } from "../reactivity"; import type { ResolveValueOptions, SimpleResolveValue } from "./resolve"; -type Lookup = - | { readonly settled: true; readonly value: unknown } - | { readonly settled: false }; +// A variable rncss publishes itself carries a property's computed value, which always exists, so one nothing publishes leaves the property its default +const COMPUTED_CHANNEL_PREFIX = "__rn-css-"; + +function isComputedChannel(name: string): boolean { + return name.startsWith(COMPUTED_CHANNEL_PREFIX); +} + +// What one source declares for the variable: nothing, its parent's value, the guaranteed-invalid value, or a value +type Declaration = + | { readonly kind: "absent" } + | { readonly kind: "inherit" } + | { readonly kind: "guaranteed-invalid" } + | { readonly kind: "value"; readonly value: unknown }; + +const ABSENT: Declaration = { kind: "absent" }; +const INHERIT: Declaration = { kind: "inherit" }; +const GUARANTEED_INVALID: Declaration = { kind: "guaranteed-invalid" }; export function varResolver( resolve: SimpleResolveValue, @@ -49,7 +63,20 @@ export function varResolver( return; } - if (variableCycles.has(name)) return resolve(fallback); + // css-variables-1 §3.1: a var() with no fallback for a variable that has no value leaves its declaration invalid at computed-value time + const substituteFallback = (): unknown => { + if ( + fallback === undefined && + options.substitution && + !isComputedChannel(name) + ) { + options.substitution.failed = true; + } + + return resolve(fallback); + }; + + if (variableCycles.has(name)) return substituteFallback(); // Share the active resolution path with recursive calls. Mark only the // actual cycle, so a dependent variable can still use its own fallback. @@ -65,67 +92,116 @@ export function varResolver( variableHistory.add(name); const memoise = (value: unknown) => { - options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; - options.inlineVariables[name] = value; + if (value !== undefined) { + options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; + options.inlineVariables[name] = value; + } + return value; }; - // A universal selector declares the variable on every element: below a class, above inheritance - const lookUpUniversal = (): Lookup => { - const universal = get(universalVariables(name)); - if (isGuaranteedInvalidValue(universal)) { - return { settled: true, value: resolve(fallback) }; - } else if (isInheritedValue(universal)) { - return { settled: false }; + // css-variables-1 §3.1: a value a var() leaves without one, like a member of a cycle, is the guaranteed-invalid value + const readDeclaration = ( + descriptor: StyleDescriptor | undefined, + ): Declaration => { + if (descriptor === undefined) { + return ABSENT; + } else if (isGuaranteedInvalidValue(descriptor)) { + return GUARANTEED_INVALID; + } else if (isInheritedValue(descriptor)) { + return INHERIT; } - const value = resolve(universal); - if (variableCycles.has(name)) { - return { settled: true, value: resolve(fallback) }; - } + const outer = options.substitution; + const substitution = { failed: false }; + options.substitution = substitution; - return value === undefined - ? { settled: false } - : { settled: true, value: memoise(value) }; - }; + try { + const value = resolve(descriptor); - try { - const declared = inlineVariables?.[name] as StyleDescriptor; - if (isGuaranteedInvalidValue(declared)) return resolve(fallback); + return substitution.failed || variableCycles.has(name) + ? GUARANTEED_INVALID + : { kind: "value", value }; + } finally { + options.substitution = outer; + } + }; - // A custom property set to `inherit` takes its parent's value, over every declaration on the element - const inheriting = isInheritedValue(declared); + // The parent's value: what it hands down, or else what `*` declares on it, then what :root does + const readParentValue = (readsUniversal: boolean): unknown => { + const handedDown = readOwnEntry(variables, name); - if (!inheriting) { - const value = resolve(declared); - if (variableCycles.has(name)) return resolve(fallback); - if (value !== undefined) return memoise(value); + // Guards retain the ancestor's raw descriptor, matching the context value + if (handedDown !== undefined) { + renderGuards?.push(["v", name, handedDown]); + const inherited = readDeclaration(handedDown); - const universal = lookUpUniversal(); - if (universal.settled) return universal.value; + if (inherited.kind === "value") { + return inherited.value; + } else if (inherited.kind === "guaranteed-invalid") { + return substituteFallback(); + } } - // Guards retain the ancestor's raw descriptor, matching the context value. An entry with no value is no declaration. - if (Object.hasOwn(variables, name) && variables[name] !== undefined) { - renderGuards?.push(["v", name, variables[name]]); - if (isGuaranteedInvalidValue(variables[name])) return resolve(fallback); - const value = resolve(variables[name]); - return variableCycles.has(name) ? resolve(fallback) : value; - } + if (readsUniversal) { + const universal = readDeclaration(get(universalVariables(name))); - if (inheriting) { - // Undeclared in the context, the parent's own value is what `*` declares on it - const universal = lookUpUniversal(); - if (universal.settled) return universal.value; + if (universal.kind === "value") { + return memoise(universal.value); + } else if (universal.kind === "guaranteed-invalid") { + return substituteFallback(); + } } // At the root a keyword resolves to nothing, so the fallback applies - const value = resolve(get(rootVariables(name))); - if (variableCycles.has(name)) return resolve(fallback); - if (value !== undefined) return memoise(value); + const root = readDeclaration(get(rootVariables(name))); - return resolve(fallback); + return root.kind === "value" ? memoise(root.value) : substituteFallback(); + }; + + try { + const own = readDeclaration(readOwnEntry(inlineVariables, name)); + + switch (own.kind) { + case "value": + return memoise(own.value); + case "guaranteed-invalid": + return substituteFallback(); + case "inherit": + // A custom property set to `inherit` takes its parent's value, over every declaration on the element + return readParentValue(true); + case "absent": + break; + default: + own satisfies never; + } + + // A universal selector declares the variable on every element: below a class, above inheritance + const universal = readDeclaration(get(universalVariables(name))); + + switch (universal.kind) { + case "value": + return memoise(universal.value); + case "guaranteed-invalid": + return substituteFallback(); + case "inherit": + case "absent": + return readParentValue(false); + default: + universal satisfies never; + return; + } } finally { variableHistory.delete(name); } } + +// A variable's name is its own, never a property every object inherits, and an entry with no value is no declaration +function readOwnEntry( + record: Readonly> | undefined, + name: string, +): StyleDescriptor | undefined { + return record !== undefined && Object.hasOwn(record, name) + ? (record[name] as StyleDescriptor | undefined) + : undefined; +} From 0008002ead5c307f677a553f7fdc4dce6a710c31 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 23:58:36 +0300 Subject: [PATCH 20/29] fix: a list whose every var() substitutes nothing is invalid at runtime too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compiler made a declaration whose value substitutes to nothing unset, while the runtime handed React Native the empty list it left: `margin: var(--a,) var(--b,)` with neither declared rendered `margin: []` over an earlier rule's value. The list resolver records when every entry substituted nothing, which leaves a property's value invalid at computed-value time and a custom property's value empty, as css-variables-1 §3.1 defines. --- src/__tests__/native/keywords.test.tsx | 17 +++++++++++++++++ src/native/styles/calculate-props.ts | 6 +++--- src/native/styles/resolve.ts | 23 ++++++++++++++++++----- src/native/styles/variables.ts | 8 ++++++-- 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/__tests__/native/keywords.test.tsx b/src/__tests__/native/keywords.test.tsx index 30f5e1ac..3e7fa58d 100644 --- a/src/__tests__/native/keywords.test.tsx +++ b/src/__tests__/native/keywords.test.tsx @@ -367,6 +367,23 @@ describe.each(routes)( }, ); + test("a value whose every var() substitutes nothing clears an earlier rule's value", () => { + registerCSS( + ` + .earlier { margin: 5px; } + .child { --empty-start: initial; --empty-end: initial; margin: var(--empty-start,) var(--empty-end,); } + .other { --empty-end: 1px; } + `, + options, + ); + + render(); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + margin: undefined, + }); + }); + // Chromium 153, Firefox 155 and WebKit 26.6 substitute an empty custom property's value as nothing, over an ancestor's test("an authored empty value shadows the ancestor's, and lets the colour inherit", () => { expect( diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 1a0a1c19..ace34178 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -243,7 +243,7 @@ function resolveDeclaration( get: Getter, options: ResolveValueOptions, ): unknown { - const substitution: Substitution = { failed: false }; + const substitution: Substitution = { failed: false, emptied: false }; const value = resolveValue(descriptor, get, { ...options, substitution }); if (!isInvalidAtComputedValueTime(descriptor, value, substitution)) { @@ -273,13 +273,13 @@ function unsetDescriptor( return "unset"; } -// A var() that substitutes nothing anywhere in the value, or leaves the declaration nothing: no value, or as the whole value an empty fallback +// A var() with no value and no fallback anywhere in the value, or a value its var()s leave empty function isInvalidAtComputedValueTime( descriptor: StyleDescriptor, value: unknown, substitution: Readonly, ): boolean { - if (substitution.failed) { + if (substitution.failed || substitution.emptied) { return true; } else if (!readsVariable(descriptor)) { return false; diff --git a/src/native/styles/resolve.ts b/src/native/styles/resolve.ts index 51f58630..e1fb2fc1 100644 --- a/src/native/styles/resolve.ts +++ b/src/native/styles/resolve.ts @@ -66,9 +66,12 @@ export type ResolveValueOptions = { calculateProps?: typeof calculateProps; }; -/** Whether a var() in the value being resolved substituted nothing, which makes the value invalid at computed-value time (css-variables-1 §3.1) */ +/** What the var()s in the value being resolved left it (css-variables-1 §3.1) */ export interface Substitution { + /** A var() had no value and no fallback, which leaves any value invalid at computed-value time */ failed: boolean; + /** Every entry of a list substituted nothing, which leaves a property's value invalid and a custom property's empty */ + emptied: boolean; } export function resolveValue( @@ -104,13 +107,23 @@ export function resolveValue( } if (isStyleDescriptorArray(value)) { - return value.flatMap((entry) => { + let substitutedNothing = value.length > 0; + const entries = value.flatMap((entry) => { const resolved: unknown = resolveValue(entry, get, options); - return resolved === undefined || isEmptySubstitution(entry, resolved) - ? [] - : [resolved]; + if (isEmptySubstitution(entry, resolved)) { + return []; + } + + substitutedNothing = false; + return resolved === undefined ? [] : [resolved]; }); + + if (substitutedNothing && options.substitution) { + options.substitution.emptied = true; + } + + return entries; } const name = value[1]; diff --git a/src/native/styles/variables.ts b/src/native/styles/variables.ts index 61d748d1..7ac9e7df 100644 --- a/src/native/styles/variables.ts +++ b/src/native/styles/variables.ts @@ -10,7 +10,11 @@ import { } from "react-native-css/utilities"; import { VAR_SYMBOL, type Getter } from "../reactivity"; -import type { ResolveValueOptions, SimpleResolveValue } from "./resolve"; +import type { + ResolveValueOptions, + SimpleResolveValue, + Substitution, +} from "./resolve"; // A variable rncss publishes itself carries a property's computed value, which always exists, so one nothing publishes leaves the property its default const COMPUTED_CHANNEL_PREFIX = "__rn-css-"; @@ -113,7 +117,7 @@ export function varResolver( } const outer = options.substitution; - const substitution = { failed: false }; + const substitution: Substitution = { failed: false, emptied: false }; options.substitution = substitution; try { From 50f41880a73f66aa606c2bf94d249b00b3980a7e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 17 Oct 2026 01:10:22 +0300 Subject: [PATCH 21/29] test: a variable that leads into a cycle is inlined, and the cycle is left to the runtime --- src/__tests__/compiler/compiler.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 3ea8a806..2ec02aa3 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -756,6 +756,17 @@ describe("CSS-wide keywords", () => { ).toEqual([[{}, "inherit"]]); }); + test("a variable that leads into a cycle is inlined, and only the cycle's members are left to the runtime", () => { + const published = ( + compile( + `.child { --lead: var(--cycle-x); --cycle-x: var(--cycle-y); --cycle-y: var(--cycle-x); color: var(--lead); }`, + ).stylesheet().s?.[0]?.[1]?.[0]?.v ?? [] + ).map(([name]) => name); + + expect(published).toEqual(expect.arrayContaining(["cycle-x", "cycle-y"])); + expect(published).not.toContain("lead"); + }); + test("a variable two branches read is inlined into both, as no cycle", () => { expect( declarationsFor( From 17e542307858e6513262df25e041b961f228db5a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 17 Oct 2026 01:12:03 +0300 Subject: [PATCH 22/29] test: read the fold of a variable leading into a cycle off the declaration that reads it --- src/__tests__/compiler/compiler.test.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 2ec02aa3..b66f4831 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -757,14 +757,15 @@ describe("CSS-wide keywords", () => { }); test("a variable that leads into a cycle is inlined, and only the cycle's members are left to the runtime", () => { - const published = ( - compile( - `.child { --lead: var(--cycle-x); --cycle-x: var(--cycle-y); --cycle-y: var(--cycle-x); color: var(--lead); }`, - ).stylesheet().s?.[0]?.[1]?.[0]?.v ?? [] - ).map(([name]) => name); + const rule = compile( + `.child { --lead: var(--cycle-x); --cycle-x: var(--cycle-y); --cycle-y: var(--cycle-x); color: var(--lead); }`, + ).stylesheet().s?.[0]?.[1]?.[0]; - expect(published).toEqual(expect.arrayContaining(["cycle-x", "cycle-y"])); - expect(published).not.toContain("lead"); + expect((rule?.v ?? []).map(([name]) => name)).toEqual( + expect.arrayContaining(["cycle-x", "cycle-y"]), + ); + expect(JSON.stringify(rule?.d)).toContain('"cycle-x"'); + expect(JSON.stringify(rule?.d)).not.toContain('"lead"'); }); test("a variable two branches read is inlined into both, as no cycle", () => { From 52b9fb618c46fcf3eca2407af44a78dadd3aff9c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 17 Oct 2026 01:21:43 +0300 Subject: [PATCH 23/29] refactor(native): name the variables rncss publishes itself library variables --- src/native/styles/variables.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/native/styles/variables.ts b/src/native/styles/variables.ts index 7ac9e7df..5e730ad3 100644 --- a/src/native/styles/variables.ts +++ b/src/native/styles/variables.ts @@ -17,10 +17,10 @@ import type { } from "./resolve"; // A variable rncss publishes itself carries a property's computed value, which always exists, so one nothing publishes leaves the property its default -const COMPUTED_CHANNEL_PREFIX = "__rn-css-"; +const LIBRARY_VARIABLE_PREFIX = "__rn-css-"; -function isComputedChannel(name: string): boolean { - return name.startsWith(COMPUTED_CHANNEL_PREFIX); +function isLibraryVariable(name: string): boolean { + return name.startsWith(LIBRARY_VARIABLE_PREFIX); } // What one source declares for the variable: nothing, its parent's value, the guaranteed-invalid value, or a value @@ -72,7 +72,7 @@ export function varResolver( if ( fallback === undefined && options.substitution && - !isComputedChannel(name) + !isLibraryVariable(name) ) { options.substitution.failed = true; } From 1cd578790c327a0208ebd71bb1e0559b2671678c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 17 Oct 2026 01:21:54 +0300 Subject: [PATCH 24/29] refactor(native): a filter's value is never the initial keyword, which the compiler resolves --- src/native/styles/functions/filters.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/native/styles/functions/filters.ts b/src/native/styles/functions/filters.ts index 92e6bdd0..69012cc2 100644 --- a/src/native/styles/functions/filters.ts +++ b/src/native/styles/functions/filters.ts @@ -107,7 +107,7 @@ export const filter: StyleFunctionResolver = (resolveValue, descriptor) => { if (filters.some(resetsToNone)) return null; return filters.filter((entry) => entry !== undefined); } - if (value === undefined || value === "initial") return; + if (value === undefined) return; if (resetsToNone(value)) return null; return typeof value === "string" ? value : [value]; }; From 6427f528e5d8cb4a9b9b881cf465eb2efe733e55 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 17 Oct 2026 01:55:43 +0300 Subject: [PATCH 25/29] test: an undefined variable that is a filter's whole value leaves no filter --- src/__tests__/native/filter-list-runtime.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/__tests__/native/filter-list-runtime.test.tsx b/src/__tests__/native/filter-list-runtime.test.tsx index dde03557..52bfb591 100644 --- a/src/__tests__/native/filter-list-runtime.test.tsx +++ b/src/__tests__/native/filter-list-runtime.test.tsx @@ -98,6 +98,15 @@ describe.each([undefined, false] as const)( render(); expect(filter()).toBeUndefined(); }); + + test("an undefined variable that is the whole value leaves no filter", () => { + registerCSS( + `.earlier { filter: blur(4px); } .subject { filter: var(--missing); }`, + { inlineVariables }, + ); + render(); + expect(filter()).toBeUndefined(); + }); }, ); From e58f2c49ecff3d3f8b3778c2731e089c1f9e4993 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 17 Oct 2026 04:13:35 +0300 Subject: [PATCH 26/29] fix(native): reset every animation longhand for a CSS-wide keyword The animation shorthand answered `unset` with nothing, so `animation: initial`, `animation: unset` and `animation: var(--missing)` left an earlier rule's animation running. It now resets every longhand it sets, as the other shorthands do. --- src/__tests__/native/animations.test.tsx | 31 +++++++++++++++++++++++ src/native/styles/shorthands/_handler.ts | 9 +++---- src/native/styles/shorthands/animation.ts | 28 +++++++++++--------- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/__tests__/native/animations.test.tsx b/src/__tests__/native/animations.test.tsx index c9565d16..4467ec78 100644 --- a/src/__tests__/native/animations.test.tsx +++ b/src/__tests__/native/animations.test.tsx @@ -133,6 +133,37 @@ test("an invalid numeric animation name is omitted without throwing", () => { expect(style()?.animationName).toBeUndefined(); }); +const clearedAnimation = { + animationName: undefined, + animationDelay: undefined, + animationDirection: undefined, + animationDuration: undefined, + animationFillMode: undefined, + animationIterationCount: undefined, + animationPlayState: undefined, + animationTimingFunction: undefined, +}; + +// Chromium 153, Firefox 155 and WebKit 26.6 reset every animation longhand for `initial`, `unset` and a var() that resolves to nothing +describe.each([undefined, false] as const)( + "an animation reset with inlineVariables=%s", + (inlineVariables) => { + test.each(["initial", "unset", "var(--missing)"])( + "animation: %s after a written animation leaves no animation", + (reset) => { + registerCSS( + `${keyframes} .earlier { animation: slide-in 1s; } .reset { animation: ${reset}; }`, + { inlineVariables }, + ); + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual( + clearedAnimation, + ); + }, + ); + }, +); + test("multiple animation names preserve keyframe order and timing metadata", () => { registerCSS(`${keyframes} .motion { animation: slide-in 1s linear 250ms 2 alternate both, diff --git a/src/native/styles/shorthands/_handler.ts b/src/native/styles/shorthands/_handler.ts index e20f4a53..47ae8495 100644 --- a/src/native/styles/shorthands/_handler.ts +++ b/src/native/styles/shorthands/_handler.ts @@ -39,7 +39,7 @@ export function shorthandHandler( : value; if (args === null && returnType === "shorthandObject") { - return clearedLonghands(mappings, defaults); + return clearedLonghands([...mappings.flat(), ...defaults]); } if (!Array.isArray(args)) { @@ -139,12 +139,11 @@ 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[], +export function clearedLonghands( + longhandEntries: readonly ShorthandRequiredValue[], ) { const target: Record = { [ShortHandSymbol]: true }; - const longhands = [...mappings.flat(), ...defaults].flatMap(([prop]) => + const longhands = longhandEntries.flatMap(([prop]) => typeof prop === "string" ? [prop] : prop.slice(0, 1), ); diff --git a/src/native/styles/shorthands/animation.ts b/src/native/styles/shorthands/animation.ts index f28aacd9..76fccf59 100644 --- a/src/native/styles/shorthands/animation.ts +++ b/src/native/styles/shorthands/animation.ts @@ -3,7 +3,7 @@ import { StyleCollection } from "react-native-css/native-internal"; import { applyShorthand } from "../../objects"; import type { StyleFunctionResolver } from "../resolve"; -import { shorthandHandler } from "./_handler"; +import { clearedLonghands, shorthandHandler } from "./_handler"; const name = ["animationName", "string", "none"] as const; const delay = ["animationDelay", "number", 0] as const; @@ -33,6 +33,16 @@ const timingFunction = [ ["linear", "ease", "ease-in", "ease-out", "ease-in-out", "object"], "ease", ] as const; +const longhands = [ + name, + delay, + direction, + duration, + fill, + iteration, + playState, + timingFunction, +]; export const animationShorthand = shorthandHandler( [ @@ -48,16 +58,7 @@ export const animationShorthand = shorthandHandler( [duration, delay, iteration, timingFunction, name], [name, duration, timingFunction, delay, iteration, fill], ], - [ - name, - delay, - direction, - duration, - fill, - iteration, - playState, - timingFunction, - ], + longhands, "tuples", ); @@ -75,7 +76,10 @@ export const animation: StyleFunctionResolver = ( ); if (!Array.isArray(animationShortHandTuples)) { - return; + // css-cascade-5 §7.3.3: a shorthand reset to `unset` resets every longhand it sets + return resolveValue(value[2]) === null + ? clearedLonghands(longhands) + : undefined; } const nameTuple = animationShortHandTuples.find( From a13f915d8cf937cdc62137b3bc97319cec1a9c23 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 17 Oct 2026 04:21:32 +0300 Subject: [PATCH 27/29] fix(native): resolve every declaration in cascade order once the element's variables are assigned #474's cascade order over #391's computed-value-time handling: every rule's variables are assigned before any declaration resolves, and every declaration resolves in cascade order. A declaration a var() leaves invalid resolves to `unset`, and one the runtime cannot read is ignored, so the earlier rule's value stands: `transform: none rotate(10deg)` after `translateX(10px)` keeps the translation. transform, line-height and animation-name now answer `unset` with a reset, since nothing now means ignored. --- src/__tests__/native/animations.test.tsx | 43 +++ .../native/deferred-declarations.test.tsx | 258 ++++++++++++++++++ src/__tests__/native/keywords.test.tsx | 47 +--- src/__tests__/native/transform.test.tsx | 17 +- src/native/styles/calculate-props.ts | 118 +++----- src/native/styles/line-height.ts | 4 + src/native/styles/shorthands/animation.ts | 24 +- src/native/styles/shorthands/transform.ts | 2 + 8 files changed, 391 insertions(+), 122 deletions(-) create mode 100644 src/__tests__/native/deferred-declarations.test.tsx diff --git a/src/__tests__/native/animations.test.tsx b/src/__tests__/native/animations.test.tsx index 4467ec78..811fe3ab 100644 --- a/src/__tests__/native/animations.test.tsx +++ b/src/__tests__/native/animations.test.tsx @@ -133,6 +133,7 @@ test("an invalid numeric animation name is omitted without throwing", () => { expect(style()?.animationName).toBeUndefined(); }); +const variableAnimation = `.other { --motion: none; } .earlier { --motion: slide-in 1s; animation: var(--motion); }`; const clearedAnimation = { animationName: undefined, animationDelay: undefined, @@ -161,6 +162,48 @@ describe.each([undefined, false] as const)( ); }, ); + + test.each(["initial", "unset", "var(--missing)"])( + "animation: %s after a variable animation leaves no animation", + (reset) => { + registerCSS( + `${keyframes} ${variableAnimation} .reset { animation: ${reset}; }`, + { inlineVariables }, + ); + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual( + clearedAnimation, + ); + }, + ); + + test.each(["initial", "var(--missing)"])( + "animation-name: %s after a written animation name leaves no animation name", + (reset) => { + registerCSS( + `${keyframes} .earlier { animation-name: slide-in; animation-duration: 1s; } .reset { animation-name: ${reset}; }`, + { inlineVariables }, + ); + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + animationName: undefined, + animationDuration: [1000], + }); + }, + ); + + // Chromium 153, Firefox 155 and WebKit 26.6 apply a later longhand over the variable-held shorthand before it + test("a written animation-duration after a variable animation applies", () => { + registerCSS( + `${keyframes} ${variableAnimation} .reset { animation-duration: 2s; }`, + { inlineVariables }, + ); + render(); + expect(style()).toMatchObject({ + animationName: horizontal, + animationDuration: [2000], + }); + }); }, ); diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx new file mode 100644 index 00000000..17c6e5cb --- /dev/null +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -0,0 +1,258 @@ +import { render } from "@testing-library/react-native"; +import { Text } from "react-native-css/components/Text"; +import { registerCSS, testID } from "react-native-css/jest"; + +// A second definition keeps each variable from being inlined, so it resolves at runtime +const decoy = `.decoy { --c: green; --n: 9; --b: 1px solid green; --w: 9px; --s: 9px 9px green; --t: none; }`; + +function renderText(css: string, className = "my-class") { + registerCSS(`${decoy} ${css}`); + + return render().getByTestId( + testID, + ).props; +} + +// A declaration that reads a variable resolves against the target it was declared on +describe("a deferred declaration beside a nested one", () => { + test("color: var() beside a box-shadow", () => { + expect( + renderText( + `.my-class { --c: red; color: var(--c); box-shadow: 1px 1px blue; }`, + ).style, + ).toStrictEqual({ + color: "red", + boxShadow: [ + { + color: "#00f", + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + }, + ], + }); + }); + + test("color: var() beside a text-shadow", () => { + expect( + renderText( + `.my-class { --c: red; color: var(--c); text-shadow: 1px 1px 2px blue; }`, + ).style, + ).toStrictEqual({ + color: "red", + textShadowColor: "#00f", + textShadowRadius: 2, + textShadowOffset: { width: 1, height: 1 }, + }); + }); + + test("color: var() beside a prop", () => { + const props = renderText( + `.my-class { --c: red; color: var(--c); -webkit-line-clamp: 2; }`, + ); + + expect(props.style).toStrictEqual({ color: "red" }); + expect(props.numberOfLines).toBe(2); + }); + + test("a deferred prop beside a style", () => { + const props = renderText( + `.my-class { --n: 2; -webkit-line-clamp: var(--n); --c: red; color: var(--c); }`, + ); + + expect(props.style).toStrictEqual({ color: "red" }); + expect(props.numberOfLines).toBe(2); + }); + + test("a text-shadow with no colour takes the element's colour", () => { + expect( + renderText(`.my-class { color: #0000ff; text-shadow: 2px 2px; }`).style, + ).toStrictEqual({ + color: "#00f", + textShadowColor: "#00f", + textShadowRadius: 0, + textShadowOffset: { width: 2, height: 2 }, + }); + }); + + test("a box-shadow's first shadow with no colour takes the inherited colour", () => { + registerCSS( + `${decoy} .parent { color: #123456; } .my-class { box-shadow: inset 1px 1px, 2px 2px #654321; }`, + ); + + const child = render( + + + , + ).getByTestId(testID); + + expect(child.props.style).toStrictEqual({ + boxShadow: [ + { + color: "#123456", + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + inset: true, + }, + { + color: "#654321", + offsetX: 2, + offsetY: 2, + blurRadius: 0, + spreadDistance: 0, + }, + ], + }); + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6: each key belongs to the declaration that set it last, so a later longhand beats an earlier shorthand however late the shorthand resolves +describe("a deferred shorthand keeps no key a later declaration set", () => { + test.each([ + [ + "border: var() then border-color, in one rule", + ".my-class { --b: 2px solid red; border: var(--b); border-color: blue; }", + "my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "#00f" }, + ], + [ + "border-color then border: var(), in one rule", + ".my-class { border-color: blue; --b: 2px solid red; border: var(--b); }", + "my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "red" }, + ], + [ + "border: var() then border-color, across rules", + ".earlier { --b: 2px solid red; border: var(--b); } .my-class { border-color: blue; }", + "earlier my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "#00f" }, + ], + [ + "border-color then border: var(), across rules", + ".earlier { border-color: blue; } .my-class { --b: 2px solid red; border: var(--b); }", + "earlier my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "red" }, + ], + [ + "border-inline-width: var() then border-inline-start-width", + ".my-class { --w: 2px 3px; border-inline-width: var(--w); border-inline-start-width: 5px; }", + "my-class", + { borderStartWidth: 5, borderEndWidth: 3 }, + ], + [ + "border-inline-start-width then border-inline-width: var()", + ".my-class { border-inline-start-width: 5px; --w: 2px 3px; border-inline-width: var(--w); }", + "my-class", + { borderStartWidth: 2, borderEndWidth: 3 }, + ], + [ + "text-shadow: var() then text-shadow, across rules", + ".earlier { --s: 2px 2px #00f; text-shadow: var(--s); } .my-class { text-shadow: 1px 1px red; }", + "earlier my-class", + { + textShadowColor: "#f00", + textShadowRadius: 0, + textShadowOffset: { width: 1, height: 1 }, + }, + ], + [ + "text-shadow then text-shadow: var(), across rules", + ".earlier { text-shadow: 1px 1px red; } .my-class { --s: 2px 2px #00f; text-shadow: var(--s); }", + "earlier my-class", + { + textShadowColor: "#00f", + textShadowRadius: 0, + textShadowOffset: { width: 2, height: 2 }, + }, + ], + ] as const)("%s", (_name, css, className, style) => { + expect(renderText(css, className).style).toStrictEqual(style); + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 apply a transform and an individual transform property together, however the transform is written +test("a deferred transform composes with an individual transform property as a written one does", () => { + registerCSS( + `${decoy} .held { --t: translateX(10px); transform: var(--t); } .written { transform: translateX(10px); } .turn { rotate: 45deg; }`, + ); + + const screen = render( + <> + + + , + ); + + const written = screen.getByTestId("written").props.style as { + transform: unknown[]; + }; + expect(written.transform).toEqual( + expect.arrayContaining([{ translateX: 10 }, { rotateZ: "45deg" }]), + ); + expect(screen.getByTestId("held").props.style).toStrictEqual(written); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 reset a property whose var() has no value, so an earlier rule's value is gone +test("a var() with no value resets an earlier rule's value", () => { + expect( + renderText( + ".earlier { width: 10px; } .my-class { width: var(--c-missing); }", + "earlier my-class", + ).style, + ).toStrictEqual({ width: undefined }); +}); + +// CSS 2.1 §4.2: a declaration with a value the property cannot take is ignored, so the earlier rule's value stands +test("a written value the runtime cannot read leaves an earlier rule's value", () => { + expect( + renderText( + ".earlier { width: 10px; } .my-class { width: roundToNearestPixel(foo); }", + "earlier my-class", + ).style, + ).toStrictEqual({ width: 10 }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 apply an individual transform property whatever the cascade order of the transform beside it +test.each([ + [ + "before a transform", + ".earlier { rotate: 45deg; } .my-class { transform: translateX(10px); }", + ], + [ + "after a transform", + ".earlier { transform: translateX(10px); } .my-class { rotate: 45deg; }", + ], +])( + "an individual transform property declared %s composes with it", + (_order, css) => { + const { transform } = renderText(css, "earlier my-class").style as { + transform: unknown[]; + }; + + expect(transform).toHaveLength(2); + expect(transform).toEqual( + expect.arrayContaining([{ translateX: 10 }, { rotateZ: "45deg" }]), + ); + }, +); + +// Chromium 153, Firefox 155 and WebKit 26.6 apply both, each to its own property +test("an individual transform property beside a box-shadow", () => { + expect( + renderText(".my-class { rotate: 45deg; box-shadow: 1px 1px blue; }").style, + ).toStrictEqual({ + transform: [{ rotateZ: "45deg" }], + boxShadow: [ + { + color: "#00f", + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + }, + ], + }); +}); diff --git a/src/__tests__/native/keywords.test.tsx b/src/__tests__/native/keywords.test.tsx index 3e7fa58d..40ea97f5 100644 --- a/src/__tests__/native/keywords.test.tsx +++ b/src/__tests__/native/keywords.test.tsx @@ -13,8 +13,8 @@ const clearedProperties: readonly (readonly [ ["opacity", "0.5", { opacity: undefined }], ["box-shadow", "1px 1px red", { boxShadow: undefined }], ["filter", "blur(2px)", { filter: undefined }], - ["transform", "translateX(10px)", {}], - ["line-height", "30px", {}], + ["transform", "translateX(10px)", { transform: undefined }], + ["line-height", "30px", { lineHeight: undefined }], [ "text-shadow", "1px 1px red", @@ -548,26 +548,19 @@ describe.each(routes)( ).toStrictEqual({ color: "#008000" }); }); - test.each( - clearedProperties - .filter( - ([property]) => - property !== "line-height" && property !== "transform", - ) - .map( - ([property, earlier, cleared]) => - [property, earlier, cleared] as const, - ), - )("%s clears an earlier rule's value", (property, earlier, cleared) => { - registerCSS( - `.earlier { ${property}: ${earlier}; } .child { ${property}: var(--missing); }`, - options, - ); + test.each(clearedProperties)( + "%s clears an earlier rule's value", + (property, earlier, cleared) => { + registerCSS( + `.earlier { ${property}: ${earlier}; } .child { ${property}: var(--missing); }`, + options, + ); - render(); + render(); - expect(screen.getByTestId("child").props.style).toStrictEqual(cleared); - }); + expect(screen.getByTestId("child").props.style).toStrictEqual(cleared); + }, + ); test("an empty fallback as the whole value inherits the colour", () => { expect( @@ -583,20 +576,6 @@ describe.each(routes)( ).toStrictEqual({ color: "#008000" }); }); - test("transform leaves no transform", () => { - registerCSS( - `.earlier { transform: translateX(10px); } .child { transform: var(--missing); }`, - options, - ); - - render(); - - const style = screen.getByTestId("child").props.style as { - transform?: unknown[]; - }; - expect(style.transform ?? []).toStrictEqual([]); - }); - test("width: calc() of it clears an earlier rule's width", () => { registerCSS( `.earlier { width: 50px; } .child { width: calc(var(--missing) * 2); }`, diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index f9b6211a..e93d9a64 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -217,7 +217,22 @@ test.each([ , ).getByTestId(testID); - expect(component.props.style).toStrictEqual({}); + expect(component.props.style).toStrictEqual({ transform: undefined }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 ignore a written transform list with a keyword among its functions, so the earlier transform stands +test("transform: none rotate(10deg) as written is ignored", () => { + registerCSS( + `.earlier { transform: translateX(10px); } .written { transform: none rotate(10deg); }`, + ); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + transform: [{ translateX: 10 }], + }); }); // Chromium 153, Firefox 155 and WebKit 26.6 substitute each transform a variable holds, in place diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index ace34178..db91a731 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -15,7 +15,7 @@ import { } from "react-native-css/utilities"; import type { RenderGuard } from "../conditions/guards"; -import { applyValue, getDeepPath } from "../objects"; +import { applyValue } from "../objects"; import { VAR_SYMBOL, type Getter, @@ -43,10 +43,10 @@ export function calculateProps( let normal: Record | undefined; let important: Record | undefined; - const delayedStyles: (() => void)[] = []; const transformStyles: (() => void)[] = []; let publishedColor: StyleDescriptor | undefined; + // css-variables-1 §3: a var() substitutes the element's computed custom property, so every rule's variables are assigned before any declaration resolves for (const rule of rules) { if (VAR_SYMBOL in rule) { if (typeof rule[VAR_SYMBOL] === "string") { @@ -54,48 +54,44 @@ export function calculateProps( } else { Object.assign(inheritedVariables, rule); } - continue; - } - - if (rule.v) { + } else if (rule.v) { publishedColor = assignRuleVariables(inlineVariables, rule.v) ?? publishedColor; } + } - if (rule.d) { - let topLevelTarget = rule.s?.[S.Important] - ? (important ??= {}) - : (normal ??= {}); - let target = topLevelTarget; + assignPublishedColor(inlineVariables, publishedColor); - const ruleTarget = rule.target || "style"; + for (const rule of rules) { + if (VAR_SYMBOL in rule || !rule.d) { + continue; + } - if (typeof ruleTarget === "string") { - target = target[ruleTarget] ??= {}; - } else if (ruleTarget) { - for (const path of ruleTarget) { - target = target[path] ??= {}; - } - } + let topLevelTarget = rule.s?.[S.Important] + ? (important ??= {}) + : (normal ??= {}); + let target = topLevelTarget; - applyDeclarations( - get, - rule.d, - inlineVariables, - inheritedVariables, - delayedStyles, - transformStyles, - guards, - target, - topLevelTarget, - ); - } - } + const ruleTarget = rule.target || "style"; - assignPublishedColor(inlineVariables, publishedColor); + if (typeof ruleTarget === "string") { + target = target[ruleTarget] ??= {}; + } else if (ruleTarget) { + for (const path of ruleTarget) { + target = target[path] ??= {}; + } + } - for (const delayedStyle of delayedStyles) { - delayedStyle(); + applyDeclarations( + get, + rule.d, + inlineVariables, + inheritedVariables, + transformStyles, + guards, + target, + topLevelTarget, + ); } for (const transformStyle of transformStyles) { @@ -114,14 +110,13 @@ export function applyDeclarations( declarations: StyleDeclaration[], inlineVariables: InlineVariable, inheritedVariables: VariableContextValue, - delayedStyles: (() => void)[] = [], transformStyles: (() => void)[] = [], guards: RenderGuard[] = [], target: Record = {}, topLevelTarget = target, ) { for (const declaration of declarations) { - // The delayed and transform closures below run after the walk, so each must capture its own declaration's target + // The transform closures below run after the walk, so each must capture its own declaration's target let declarationTarget = target; if (!Array.isArray(declaration)) { @@ -179,48 +174,21 @@ export function applyDeclarations( prop = propPath; } - const shouldDelay = declaration[2]; - - if (shouldDelay || transformKeys.has(prop)) { - /** - * We need to delay the resolution of this value until after all - * styles have been calculated. But another style might override - * this value. So we set a placeholder value and only override - * if the placeholder is preserved - * - * This also ensures the props exist, so setValue will properly - * mutate the props object and not create a new one - */ + // An individual transform property composes into the `transform` list once every rule is walked, so it holds its place until then + if (transformKeys.has(prop)) { const originalValue = value; - // This needs to be a object with the [prop] so we can discover in transform arrays value = { [prop]: true }; - if (transformKeys.has(prop)) { - transformStyles.push(() => { - value = resolveDeclaration(originalValue, prop, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - calculateProps, - computingColor, - }); - applyValue(declarationTarget, prop, value); + transformStyles.push(() => { + value = resolveDeclaration(originalValue, prop, get, { + inlineVariables, + inheritedVariables, + renderGuards: guards, + calculateProps, + computingColor, }); - } else { - delayedStyles.push(() => { - if (getDeepPath(declarationTarget, prop) === value) { - delete declarationTarget[prop]; - value = resolveDeclaration(originalValue, prop, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - calculateProps, - computingColor, - }); - applyValue(declarationTarget, prop, value); - } - }); - } + applyValue(declarationTarget, prop, value); + }); } else { value = resolveDeclaration(value, prop, get, { inlineVariables, diff --git a/src/native/styles/line-height.ts b/src/native/styles/line-height.ts index 47d1739d..72ea4f5b 100644 --- a/src/native/styles/line-height.ts +++ b/src/native/styles/line-height.ts @@ -3,6 +3,10 @@ import type { StyleFunctionResolver } from "./resolve"; export const lineHeight: StyleFunctionResolver = (resolve, func) => { const value = resolve(func[2]); + if (value === null) { + return null; + } + if (typeof value !== "number") { return; } diff --git a/src/native/styles/shorthands/animation.ts b/src/native/styles/shorthands/animation.ts index 76fccf59..caa5ad98 100644 --- a/src/native/styles/shorthands/animation.ts +++ b/src/native/styles/shorthands/animation.ts @@ -62,7 +62,7 @@ export const animationShorthand = shorthandHandler( "tuples", ); -export const animation: StyleFunctionResolver = ( +const resolveAnimation: StyleFunctionResolver = ( resolveValue, value, get, @@ -77,9 +77,7 @@ export const animation: StyleFunctionResolver = ( if (!Array.isArray(animationShortHandTuples)) { // css-cascade-5 §7.3.3: a shorthand reset to `unset` resets every longhand it sets - return resolveValue(value[2]) === null - ? clearedLonghands(longhands) - : undefined; + return resolveValue(value[2]) === null ? null : undefined; } const nameTuple = animationShortHandTuples.find( @@ -130,12 +128,14 @@ export const animation: StyleFunctionResolver = ( return applyShorthand(animationShortHandTuples); }; -export const animationName: StyleFunctionResolver = ( - resolveValue, - value, - get, - options, -) => { - const shorthand: any = animation(resolveValue, value, get, options); - return shorthand?.animationName; +export const animation: StyleFunctionResolver = (...args) => { + const animationLonghands = resolveAnimation(...args); + return animationLonghands === null + ? clearedLonghands(longhands) + : animationLonghands; +}; + +export const animationName: StyleFunctionResolver = (...args) => { + const animationLonghands: any = resolveAnimation(...args); + return animationLonghands === null ? null : animationLonghands?.animationName; }; diff --git a/src/native/styles/shorthands/transform.ts b/src/native/styles/shorthands/transform.ts index 0d180263..8db70b77 100644 --- a/src/native/styles/shorthands/transform.ts +++ b/src/native/styles/shorthands/transform.ts @@ -18,6 +18,8 @@ export const transform: StyleFunctionResolver = ( return entries.some(resetsToNone) ? undefined : entries.filter((transform) => transform !== undefined); + } else if (resetsToNone(transforms)) { + return null; } else if (transforms) { // If it's a single transform, wrap it in an array return [transforms]; From d7446f0bdec139c448bd5b8a80e8ed34ae8dd5f5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 17 Oct 2026 04:01:19 +0300 Subject: [PATCH 28/29] fix: ignore a filter list with a function it cannot take, as written or from a var() CSS ignores a filter declaration with a function its grammar rejects, and React Native's processFilter drops the whole list when one entry is invalid, but rncss passed the entry through or dropped it alone: `blur(2px) drop-shadow(2px)` drew the blur, and `brightness(-1)` or `hue-rotate(90)` reached React Native, which then drew no filter at all. A list is now invalid when any entry fails to resolve, so an ignored written filter leaves the earlier rule's value and one from a var() leaves none. Each filter function reads its single argument by the Filter Effects 1 grammar through readers the compiler and the runtime share. The compiler reads every angle through the same reader, so a gradian or a turn reaches React Native in degrees in `hue-rotate()`, `rotate()` and `skew()` alike. A url() entry is still left out with the rest applied, since React Native has no SVG filter to reference. --- src/__tests__/compiler/compiler.test.tsx | 50 +++++++ .../native/filter-list-runtime.test.tsx | 91 +++++++++++++ src/__tests__/native/transform.test.tsx | 15 +++ src/__tests__/utilities/angle.test.ts | 29 ++++ .../utilities/filter-arguments.test.ts | 33 +++++ src/compiler/declarations.ts | 125 ++++++++++++------ src/native/styles/functions/filters.ts | 103 ++++++--------- src/native/styles/resolve.ts | 16 ++- src/native/styles/shorthands/transform.ts | 5 +- src/utilities/angle.ts | 24 ++++ src/utilities/filter-arguments.ts | 18 +++ src/utilities/index.ts | 2 + 12 files changed, 398 insertions(+), 113 deletions(-) create mode 100644 src/__tests__/utilities/angle.test.ts create mode 100644 src/__tests__/utilities/filter-arguments.test.ts create mode 100644 src/utilities/angle.ts create mode 100644 src/utilities/filter-arguments.ts diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b66f4831..807a3f26 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -1007,3 +1007,53 @@ describe("the inherited-color variable is never a lookup into itself", () => { expect(new Set(rules).size).toBe(rules.length); }); }); + +// Chromium 153, Firefox 155 and WebKit 26.6 ignore a written filter with an argument its function cannot take +describe("a written filter the property cannot take", () => { + test.each(["brightness(-1)", "blur(-1px)", "blur(2px) brightness(-0.5)"])( + "filter: %s emits no declaration", + (value) => { + expect( + compile(`.child { filter: ${value}; }`).stylesheet(), + ).toStrictEqual({}); + }, + ); + + test("CONTROL: a url() entry is left out and the filters React Native can draw still apply", () => { + expect( + compile(`.child { filter: blur(2px) url(#shadow); }`).stylesheet() + .s?.[0]?.[1]?.[0]?.d, + ).toStrictEqual([{ filter: [{ blur: 2 }] }]); + }); + + test("CONTROL: a percentage and a non-negative number are kept as written", () => { + expect( + compile(`.child { filter: brightness(150%) contrast(0); }`).stylesheet() + .s?.[0]?.[1]?.[0]?.d, + ).toStrictEqual([{ filter: [{ brightness: "150%" }, { contrast: 0 }] }]); + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 compute a gradian or a turn as the angle it is; React Native takes degrees or radians alone +describe("an angle in gradians or turns", () => { + test.each([ + ["hue-rotate(1turn)", "360deg"], + ["hue-rotate(100grad)", "90deg"], + ])("filter: %s compiles to %s", (filter, degrees) => { + const compiled = compile(`.child { filter: ${filter}; }`); + + expect(compiled.warnings()).toStrictEqual({}); + expect(compiled.stylesheet().s?.[0]?.[1]?.[0]?.d).toStrictEqual([ + { filter: [{ hueRotate: degrees }] }, + ]); + }); + + test.each(["transform: rotate(100grad)", "rotate: 0.25turn"])( + "%s compiles without a warning", + (declaration) => { + expect(compile(`.child { ${declaration}; }`).warnings()).toStrictEqual( + {}, + ); + }, + ); +}); diff --git a/src/__tests__/native/filter-list-runtime.test.tsx b/src/__tests__/native/filter-list-runtime.test.tsx index 52bfb591..8d8f4918 100644 --- a/src/__tests__/native/filter-list-runtime.test.tsx +++ b/src/__tests__/native/filter-list-runtime.test.tsx @@ -3,6 +3,7 @@ import { StyleSheet } from "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 { VariableContextProvider } from "react-native-css/native"; const firstShadow = { dropShadow: { offsetX: 0, offsetY: 1, standardDeviation: 2, color: "#000" }, @@ -107,6 +108,96 @@ describe.each([undefined, false] as const)( render(); expect(filter()).toBeUndefined(); }); + + // Chromium 153, Firefox 155 and WebKit 26.6 ignore a written filter with an entry the property cannot take, so the earlier filter stands + test.each([ + "blur(2px) drop-shadow(2px)", + "drop-shadow(2px)", + "blur(foo) brightness(2)", + "brightness(-1)", + "blur(-1px)", + "hue-rotate(90)", + "blur(2px 3px)", + "none blur(2px)", + "blur(2px) none", + "drop-shadow(1px 2px 3px 4px red)", + ])("filter: %s as written is ignored", (written) => { + registerCSS( + `.earlier { filter: blur(4px); } .written { filter: ${written}; }`, + { inlineVariables }, + ); + render(); + expect(filter()).toEqual([{ blur: 4 }]); + }); + + // Chromium 153, Firefox 155 and WebKit 26.6 leave no filter where a variable holds one, as the declaration is invalid at computed-value time + test.each([ + "blur(foo)", + "blur(2px) drop-shadow(2px)", + "brightness(-1)", + "blur(2px 3px)", + "drop-shadow(1px 2px 3px 4px red)", + ])("filter: var(--effect) with --effect: %s leaves no filter", (held) => { + registerCSS( + `.other { --effect: blur(1px); } .earlier { filter: blur(4px); } .subject { --effect: ${held}; filter: var(--effect); }`, + { inlineVariables }, + ); + render(); + expect(filter()).toBeUndefined(); + }); + + test("a rejected written filter hands back to the variable filter it followed", () => { + registerCSS( + `.other { --effect: blur(1px); } .earlier { filter: blur(4px); } .middle { --effect: brightness(2); filter: var(--effect); } .written { filter: blur(foo); }`, + { inlineVariables }, + ); + render(); + expect(filter()).toEqual([{ brightness: 2 }]); + }); + + // Chromium 153, Firefox 155 and WebKit 26.6 compute hue-rotate(360deg) and hue-rotate(90deg), and React Native takes degrees or radians alone + test.each([ + ["1turn", "360deg"], + ["100grad", "90deg"], + ["-90DEG", "-90deg"], + ])( + "hue-rotate(%s) supplied at render reaches React Native as %s", + (angle, degrees) => { + registerCSS(`.subject { filter: hue-rotate(var(--angle)); }`, { + inlineVariables, + }); + render( + + + , + ); + expect(filter()).toEqual([{ hueRotate: degrees }]); + }, + ); + + test.each([ + ["1turn", "360deg"], + ["100grad", "90deg"], + ])( + "hue-rotate(%s) a stylesheet variable holds reaches React Native as %s", + (angle, degrees) => { + registerCSS( + `.other { --effect: blur(1px); } .subject { --effect: hue-rotate(${angle}); filter: var(--effect); }`, + { inlineVariables }, + ); + render(); + expect(filter()).toEqual([{ hueRotate: degrees }]); + }, + ); + + test("CONTROL: a variable holding filters the property takes keeps them all", () => { + registerCSS( + `.other { --effect: blur(1px); } .subject { --effect: blur(2px) brightness(150%); filter: var(--effect); }`, + { inlineVariables }, + ); + render(); + expect(filter()).toEqual([{ blur: 2 }, { brightness: "150%" }]); + }); }, ); diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index e93d9a64..25db8c9e 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -249,3 +249,18 @@ test("a var() holding several transforms substitutes each of them", () => { transform: [{ translateX: 1 }, { rotate: "45deg" }, { scale: 2 }], }); }); + +// Chromium 153, Firefox 155 and WebKit 26.6 compute a gradian or a turn as the angle it is; React Native takes degrees or radians alone +test.each([ + ["transform: rotate(100grad)", { transform: [{ rotate: "90deg" }] }], + ["rotate: 0.25turn", { transform: [{ rotateZ: "90deg" }] }], + ["transform: skewX(50grad)", { transform: [{ skewX: "45deg" }] }], +])("%s reaches React Native in degrees", (declaration, style) => { + registerCSS(`.my-class { ${declaration}; }`); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual(style); +}); diff --git a/src/__tests__/utilities/angle.test.ts b/src/__tests__/utilities/angle.test.ts new file mode 100644 index 00000000..4aa512fa --- /dev/null +++ b/src/__tests__/utilities/angle.test.ts @@ -0,0 +1,29 @@ +import { readAngle } from "react-native-css/utilities"; + +describe("readAngle", () => { + test.each([ + [0, 0], + ["90deg", "90deg"], + ["-90DEG", "-90deg"], + ["1rad", "1rad"], + ["1turn", "360deg"], + ["-0.5turn", "-180deg"], + ["100grad", "90deg"], + ["1e-1turn", "36deg"], + [".5rad", ".5rad"], + ])("%s reaches React Native as %s", (angle, expected) => { + expect(readAngle(angle)).toBe(expected); + }); + + test.each([90, "90", "90px", "deg", "foo", null, undefined])( + "%s is no angle", + (argument) => { + expect(readAngle(argument)).toBeUndefined(); + }, + ); + + test("the reading is idempotent", () => { + const once = readAngle("1turn"); + expect(readAngle(once)).toBe(once); + }); +}); diff --git a/src/__tests__/utilities/filter-arguments.test.ts b/src/__tests__/utilities/filter-arguments.test.ts new file mode 100644 index 00000000..d5397ef8 --- /dev/null +++ b/src/__tests__/utilities/filter-arguments.test.ts @@ -0,0 +1,33 @@ +import { + readNonNegativeAmount, + readNonNegativeLength, +} from "react-native-css/utilities"; + +describe("readNonNegativeLength", () => { + test.each([0, 2, 0.5])("%s is a length blur takes", (length) => { + expect(readNonNegativeLength(length)).toBe(length); + }); + + test.each([-1, "2px", "50%", "foo", null, undefined])( + "%s is no length blur takes", + (argument) => { + expect(readNonNegativeLength(argument)).toBeUndefined(); + }, + ); +}); + +describe("readNonNegativeAmount", () => { + test.each([0, 1.5, "0%", "150%", ".5%", "+50%", "1e2%"])( + "%s is an amount a filter takes", + (amount) => { + expect(readNonNegativeAmount(amount)).toBe(amount); + }, + ); + + test.each([-0.5, "-10%", "-1e2%", "2px", "foo", "%", "5.%", null, undefined])( + "%s is no amount a filter takes", + (argument) => { + expect(readNonNegativeAmount(argument)).toBeUndefined(); + }, + ); +}); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index ef863d21..05782b7c 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -11,6 +11,7 @@ import type { Declaration, DimensionPercentageFor_LengthValue, EnvironmentVariable, + Filter, FontSize, FontStyle, FontVariantCaps, @@ -44,6 +45,9 @@ import { inheritedValue, isInheritedColorLookup, isStyleFunction, + readAngle, + readNonNegativeAmount, + readNonNegativeLength, } from "../utilities"; import { isMappingProperty } from "./atRules"; import type { @@ -1793,14 +1797,13 @@ export function parseAngle(angle: Angle | number, builder: StylesheetBuilder) { return `${angle}deg`; } - switch (angle.type) { - case "deg": - case "rad": - return `${angle.value}${angle.type}`; - default: - builder.addWarning("value", `${angle.value} ${angle.type}`); - return undefined; + const reactNativeAngle = readAngle(`${angle.value}${angle.type}`); + + if (reactNativeAngle === undefined) { + builder.addWarning("value", `${angle.value} ${angle.type}`); } + + return reactNativeAngle; } export function parseSizeDeclaration( @@ -3621,40 +3624,80 @@ function parseFilter( return "unset"; } - return declaration.value.value - .map((value) => { - switch (value.type) { - case "opacity": - case "blur": - case "brightness": - case "contrast": - case "grayscale": - case "invert": - case "saturate": - case "sepia": - return { - [value.type]: parseLength(value.value, builder), - } as unknown as StyleDescriptor; - case "hue-rotate": - return { - [toRNProperty(value.type)]: parseAngle(value.value, builder), - } as unknown as StyleDescriptor; - case "drop-shadow": - return [ - {}, - toRNProperty(value.type), - [ - parseLength(value.value.xOffset, builder), - parseLength(value.value.yOffset, builder), - parseLength(value.value.blur, builder), - parseColor(value.value.color, builder), - ], - ] as unknown as StyleDescriptor; - case "url": - return; - } - }) - .filter((value) => value !== undefined); + const filters: StyleDescriptor[] = []; + + for (const value of declaration.value.value) { + // React Native has no SVG filter reference, so a url() entry is left out and the rest still apply + if (value.type === "url") { + continue; + } + + const filter = parseFilterFunction(value, builder); + + // A filter function the property cannot take leaves the declaration ignored, as the runtime does + if (filter === undefined) { + return; + } + + filters.push(filter); + } + + return filters; +} + +function parseFilterFunction( + value: Exclude, + builder: StylesheetBuilder, +): StyleDescriptor | undefined { + switch (value.type) { + case "blur": + return parseFilterArgument( + value.type, + parseLength(value.value, builder), + readNonNegativeLength, + ); + case "opacity": + case "brightness": + case "contrast": + case "grayscale": + case "invert": + case "saturate": + case "sepia": + return parseFilterArgument( + value.type, + parseLength(value.value, builder), + readNonNegativeAmount, + ); + case "hue-rotate": + return parseFilterArgument( + toRNProperty(value.type), + parseAngle(value.value, builder), + readAngle, + ); + case "drop-shadow": + return [ + {}, + toRNProperty(value.type), + [ + parseLength(value.value.xOffset, builder), + parseLength(value.value.yOffset, builder), + parseLength(value.value.blur, builder), + parseColor(value.value.color, builder), + ], + ] as unknown as StyleDescriptor; + } +} + +// A unit resolved at runtime is read there, by the same reader +function parseFilterArgument( + name: string, + argument: StyleDescriptor | undefined, + read: (argument: unknown) => unknown, +): StyleDescriptor | undefined { + const value = isStyleFunction(argument) ? argument : read(argument); + return value === undefined + ? undefined + : ({ [name]: value } as unknown as StyleDescriptor); } const namedColors = new Set([ diff --git a/src/native/styles/functions/filters.ts b/src/native/styles/functions/filters.ts index 69012cc2..b3634118 100644 --- a/src/native/styles/functions/filters.ts +++ b/src/native/styles/functions/filters.ts @@ -1,71 +1,44 @@ -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + isStyleDescriptorArray, + readAngle, + readNonNegativeAmount, + readNonNegativeLength, +} from "react-native-css/utilities"; import { resetsToNone } from "../resets-to-none"; import type { StyleFunctionResolver } from "../resolve"; import { shorthandHandler } from "../shorthands/_handler"; -export const blur: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - blur: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; - -export const brightness: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - brightness: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; - -export const contrast: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - contrast: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; - -export const grayscale: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - grayscale: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; +type FilterArgumentReader = (argument: unknown) => unknown; -export const hueRotate: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - hueRotate: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; +// Filter Effects 1: a filter function takes one argument, so one given several is invalid +function readFilterArgument(args: unknown): unknown { + if (!Array.isArray(args)) { + return args; + } -export const invert: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - invert: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; + return args.length === 1 ? args[0] : undefined; +} -export const sepia: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - sepia: (Array.isArray(args) ? args[0] : args) as unknown, +function filterFunction( + name: string, + readArgument: FilterArgumentReader, +): StyleFunctionResolver { + return (resolveValue, value) => { + const argument = readArgument(readFilterArgument(resolveValue(value[2]))); + return argument === undefined ? undefined : { [name]: argument }; }; -}; +} -export const saturate: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - saturate: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; - -export const opacity: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - opacity: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; +export const blur = filterFunction("blur", readNonNegativeLength); +export const brightness = filterFunction("brightness", readNonNegativeAmount); +export const contrast = filterFunction("contrast", readNonNegativeAmount); +export const grayscale = filterFunction("grayscale", readNonNegativeAmount); +export const hueRotate = filterFunction("hueRotate", readAngle); +export const invert = filterFunction("invert", readNonNegativeAmount); +export const sepia = filterFunction("sepia", readNonNegativeAmount); +export const saturate = filterFunction("saturate", readNonNegativeAmount); +export const opacity = filterFunction("opacity", readNonNegativeAmount); const color = ["color", "color"] as const; const offsetX = ["offsetX", "number"] as const; @@ -91,12 +64,12 @@ export const dropShadow: StyleFunctionResolver = ( options, ) => { const args = resolveValue(func[2]); + if (!isStyleDescriptorArray(args)) { + return undefined; + } - return isStyleDescriptorArray(args) - ? { - dropShadow: handler(resolveValue, args, get, options), - } - : undefined; + const shadow: unknown = handler(resolveValue, args, get, options); + return shadow === undefined ? undefined : { dropShadow: shadow }; }; /** CSS variables may expand to multiple filters; native filter arrays must be flat. */ @@ -104,8 +77,8 @@ export const filter: StyleFunctionResolver = (resolveValue, descriptor) => { const value: unknown = resolveValue(descriptor[2]); if (Array.isArray(value)) { const filters: unknown[] = value.flat(Infinity); - if (filters.some(resetsToNone)) return null; - return filters.filter((entry) => entry !== undefined); + // A keyword among other filters leaves the list invalid; one a var() substitutes makes it invalid at computed-value time, which resolves to `unset` + return filters.some(resetsToNone) ? undefined : filters; } if (value === undefined) return; if (resetsToNone(value)) return null; diff --git a/src/native/styles/resolve.ts b/src/native/styles/resolve.ts index e1fb2fc1..fc5db5e9 100644 --- a/src/native/styles/resolve.ts +++ b/src/native/styles/resolve.ts @@ -108,16 +108,24 @@ export function resolveValue( if (isStyleDescriptorArray(value)) { let substitutedNothing = value.length > 0; - const entries = value.flatMap((entry) => { + const entries: unknown[] = []; + + for (const entry of value) { const resolved: unknown = resolveValue(entry, get, options); if (isEmptySubstitution(entry, resolved)) { - return []; + continue; } substitutedNothing = false; - return resolved === undefined ? [] : [resolved]; - }); + + if (resolved !== undefined) { + entries.push(resolved); + } else if (entry !== undefined) { + // A component the property cannot take leaves the whole value invalid, never a shorter one + return; + } + } if (substitutedNothing && options.substitution) { options.substitution.emptied = true; diff --git a/src/native/styles/shorthands/transform.ts b/src/native/styles/shorthands/transform.ts index 8db70b77..4d39e332 100644 --- a/src/native/styles/shorthands/transform.ts +++ b/src/native/styles/shorthands/transform.ts @@ -15,9 +15,8 @@ export const transform: StyleFunctionResolver = ( // A var() holding several transforms substitutes each of them into the list const entries: unknown[] = transforms.flat(); - return entries.some(resetsToNone) - ? undefined - : entries.filter((transform) => transform !== undefined); + // A keyword among other transforms leaves the list invalid; one a var() substitutes makes it invalid at computed-value time, which resolves to `unset` + return entries.some(resetsToNone) ? undefined : entries; } else if (resetsToNone(transforms)) { return null; } else if (transforms) { diff --git a/src/utilities/angle.ts b/src/utilities/angle.ts new file mode 100644 index 00000000..24a53ae8 --- /dev/null +++ b/src/utilities/angle.ts @@ -0,0 +1,24 @@ +const ANGLE = /^([+-]?(?:\d*\.)?\d+(?:e[+-]?\d+)?)(deg|grad|rad|turn)$/i; +const DEGREES_PER_ANGLE_UNIT: Readonly> = { + deg: 1, + grad: 0.9, + turn: 360, +}; + +// React Native takes an angle in degrees or radians alone, so a gradian or a turn arrives in degrees +export function readAngle(argument: unknown): number | string | undefined { + if (argument === 0) { + return 0; + } + + const match = typeof argument === "string" ? ANGLE.exec(argument) : null; + if (!match) { + return undefined; + } + + const [, amount = "", unit = ""] = match; + const degreesPerUnit = DEGREES_PER_ANGLE_UNIT[unit.toLowerCase()]; + return degreesPerUnit === undefined + ? `${amount}rad` + : `${Number(amount) * degreesPerUnit}deg`; +} diff --git a/src/utilities/filter-arguments.ts b/src/utilities/filter-arguments.ts new file mode 100644 index 00000000..f9aee3f6 --- /dev/null +++ b/src/utilities/filter-arguments.ts @@ -0,0 +1,18 @@ +const NON_NEGATIVE_PERCENTAGE = /^\+?(?:\d*\.)?\d+(?:e[+-]?\d+)?%$/i; + +// Filter Effects 1: blur takes a non-negative length, hue-rotate an angle or unitless zero, and every other function a non-negative number or percentage +export function readNonNegativeLength(argument: unknown): number | undefined { + return typeof argument === "number" && argument >= 0 ? argument : undefined; +} + +export function readNonNegativeAmount( + argument: unknown, +): number | string | undefined { + if (typeof argument === "number") { + return argument >= 0 ? argument : undefined; + } + + return typeof argument === "string" && NON_NEGATIVE_PERCENTAGE.test(argument) + ? argument + : undefined; +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index b53d57e3..cbabf574 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -3,3 +3,5 @@ export * from "./style-descriptor"; export * from "./dot-notation.types"; export * from "./inherited-color"; export * from "./custom-property-keywords"; +export * from "./angle"; +export * from "./filter-arguments"; From 4584e27d90d104e319255dd991f0a2a0b3a17089 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 17 Oct 2026 05:05:55 +0300 Subject: [PATCH 29/29] fix(native): a CSS-wide keyword or a var() without a value always resets the property With every declaration resolved in cascade order, a declaration that resolves to nothing is ignored, so a reset whose parser has no answer for `unset` left the earlier rule's value in place. A CSS-wide keyword the compiler hands a runtime parser, and a declaration a var() leaves invalid at computed-value time, now remove the property whatever the parser answers, which the transform, line-height and animation-name parsers no longer need to say each. --- src/native/styles/calculate-props.ts | 18 ++++++++++++----- src/native/styles/line-height.ts | 4 ---- src/native/styles/shorthands/animation.ts | 24 +++++++++++------------ src/native/styles/shorthands/transform.ts | 2 -- 4 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index db91a731..2a133a7f 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -215,16 +215,24 @@ function resolveDeclaration( const value = resolveValue(descriptor, get, { ...options, substitution }); if (!isInvalidAtComputedValueTime(descriptor, value, substitution)) { - return value; + return value === undefined && isUnsetDescriptor(descriptor) ? null : value; } - return resolveValue( - unsetDescriptor(descriptor, prop, options.computingColor), - get, - options, + // css-cascade-5 §7.3.3: `unset` resets the property even where its parser has no reset of its own + return ( + resolveValue( + unsetDescriptor(descriptor, prop, options.computingColor), + get, + options, + ) ?? null ); } +// The compiler writes a CSS-wide keyword on a property the runtime parses as that parser's `unset` +function isUnsetDescriptor(descriptor: StyleDescriptor): boolean { + return isStyleFunction(descriptor) && descriptor[2] === "unset"; +} + // The `unset` the compiler writes for the same property: the inherited colour for `color`, the runtime parser's own reset for a property it parses, and the clearing literal otherwise function unsetDescriptor( descriptor: StyleDescriptor, diff --git a/src/native/styles/line-height.ts b/src/native/styles/line-height.ts index 72ea4f5b..47d1739d 100644 --- a/src/native/styles/line-height.ts +++ b/src/native/styles/line-height.ts @@ -3,10 +3,6 @@ import type { StyleFunctionResolver } from "./resolve"; export const lineHeight: StyleFunctionResolver = (resolve, func) => { const value = resolve(func[2]); - if (value === null) { - return null; - } - if (typeof value !== "number") { return; } diff --git a/src/native/styles/shorthands/animation.ts b/src/native/styles/shorthands/animation.ts index caa5ad98..76fccf59 100644 --- a/src/native/styles/shorthands/animation.ts +++ b/src/native/styles/shorthands/animation.ts @@ -62,7 +62,7 @@ export const animationShorthand = shorthandHandler( "tuples", ); -const resolveAnimation: StyleFunctionResolver = ( +export const animation: StyleFunctionResolver = ( resolveValue, value, get, @@ -77,7 +77,9 @@ const resolveAnimation: StyleFunctionResolver = ( if (!Array.isArray(animationShortHandTuples)) { // css-cascade-5 §7.3.3: a shorthand reset to `unset` resets every longhand it sets - return resolveValue(value[2]) === null ? null : undefined; + return resolveValue(value[2]) === null + ? clearedLonghands(longhands) + : undefined; } const nameTuple = animationShortHandTuples.find( @@ -128,14 +130,12 @@ const resolveAnimation: StyleFunctionResolver = ( return applyShorthand(animationShortHandTuples); }; -export const animation: StyleFunctionResolver = (...args) => { - const animationLonghands = resolveAnimation(...args); - return animationLonghands === null - ? clearedLonghands(longhands) - : animationLonghands; -}; - -export const animationName: StyleFunctionResolver = (...args) => { - const animationLonghands: any = resolveAnimation(...args); - return animationLonghands === null ? null : animationLonghands?.animationName; +export const animationName: StyleFunctionResolver = ( + resolveValue, + value, + get, + options, +) => { + const shorthand: any = animation(resolveValue, value, get, options); + return shorthand?.animationName; }; diff --git a/src/native/styles/shorthands/transform.ts b/src/native/styles/shorthands/transform.ts index 4d39e332..ac276f77 100644 --- a/src/native/styles/shorthands/transform.ts +++ b/src/native/styles/shorthands/transform.ts @@ -17,8 +17,6 @@ export const transform: StyleFunctionResolver = ( // A keyword among other transforms leaves the list invalid; one a var() substitutes makes it invalid at computed-value time, which resolves to `unset` return entries.some(resetsToNone) ? undefined : entries; - } else if (resetsToNone(transforms)) { - return null; } else if (transforms) { // If it's a single transform, wrap it in an array return [transforms];