From be200603b126e6af6aa40a12849e26fec47fcbad Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 7 Sep 2026 19:00:29 +0300 Subject: [PATCH 1/3] fix(native): let an element's own custom property outrank an inherited one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `varResolver` read the inherited variable context before the element's own record and returned from it, so a `vars()` declaration on the element was used only when no ancestor declared the same name. css-cascade-4 §7.2 makes inheritance a defaulting step: an element inherits a property only when the cascade yields no declared value for it. A custom property is an ordinary property (css-variables-1 §2), so an element declaring `--x` uses its own value rather than its ancestor's. Measured against a provider declaring `--my-var: red` around an element whose `vars()` declares `--my-var: blue`, with the class reading `color: var(--my-var)`: expected { color: "blue" } actual { color: "red" } Web is unaffected and already correct — it implements no precedence of its own. `vars()` returns a plain `{"--x": v}` style object and `VariableContextProvider` renders a `display: contents` div carrying the property, so the browser's cascade decides. The inherited read moves below the element's own record and keeps pushing the RAW descriptor, which is what `testGuards` compares against the next render's context. The second read of `variables[name]` that followed it is deleted: it ran only when `name` was absent from `variables`, so it resolved `undefined` and its guard never fired. --- src/__tests__/native/vars.test.tsx | 48 +++++++++++++++++++++++++++++- src/native/styles/variables.ts | 18 ++++------- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/src/__tests__/native/vars.test.tsx b/src/__tests__/native/vars.test.tsx index d87eb7c4..66586fbd 100644 --- a/src/__tests__/native/vars.test.tsx +++ b/src/__tests__/native/vars.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react-native"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; -import { vars } from "react-native-css/runtime"; +import { VariableContextProvider, vars } from "react-native-css/runtime"; test("vars", () => { registerCSS( @@ -36,3 +36,49 @@ test("vars", () => { color: "blue", }); }); + +test("an element's own vars() outranks an inherited value", () => { + // `--my-var` is defined twice so `inline-variables.ts` cannot fold it into + // the consuming declaration; a single definition performs no runtime var() + // read and would pass without exercising precedence at all. + registerCSS( + `.decoy { --my-var: seed; } + .other-decoy { --my-var: seed2; } + .my-class { color: var(--my-var); }`, + ); + + render( + + + , + ); + + // css-cascade-4 §7.2: inheritance is a defaulting step, reached only when the + // cascade yields no declared value for the element. The element declares + // `--my-var`, so the ancestor's `red` never applies to it. + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "blue", + }); +}); + +test("an inherited value still applies when the element declares nothing", () => { + registerCSS( + `.decoy { --my-var: seed; } + .other-decoy { --my-var: seed2; } + .my-class { color: var(--my-var); }`, + ); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "red", + }); +}); diff --git a/src/native/styles/variables.ts b/src/native/styles/variables.ts index af3d6ee2..d6361b97 100644 --- a/src/native/styles/variables.ts +++ b/src/native/styles/variables.ts @@ -46,11 +46,6 @@ export function varResolver( return; } - if (name in variables) { - renderGuards?.push(["v", name, variables[name]]); - return resolve(variables[name]); - } - variableHistory.add(name); let value = resolve(inlineVariables?.[name] as StyleDescriptor); @@ -61,13 +56,12 @@ export function varResolver( return value; } - value = resolve(variables[name]); - if (value !== undefined) { - renderGuards?.push(["v", name, value]); - options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; - options.inlineVariables[name] = value; - - return value; + if (name in variables) { + // The RAW inherited descriptor, not the resolved value: `testGuards` + // compares this against the next render's context, and a resolved value + // would never match. + renderGuards?.push(["v", name, variables[name]]); + return resolve(variables[name]); } value = resolve(get(universalVariables(name))); From afee9e76fb6419dd47ae44c25d3f57091cc0cdaf Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 9 Sep 2026 14:06:38 +0300 Subject: [PATCH 2/3] test(compiler): pin that a singly-declared custom property is folded `inline-variables.ts` folds a custom property the sheet declares exactly once into the declaration that reads it, so the compiled rule carries no variable and the runtime performs no `var()` lookup. That is a precondition every precedence test depends on and nothing stated: `.own { --my-var: blue; color: var(--my-var) }` alone compiles to `d: [{ color: "#00f" }]`, so a test written that way passes whatever the resolver does. Pinning the fold means a change to it goes red here instead of making those suites quietly vacuous. --- .../compiler/inline-variables.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/__tests__/compiler/inline-variables.test.ts diff --git a/src/__tests__/compiler/inline-variables.test.ts b/src/__tests__/compiler/inline-variables.test.ts new file mode 100644 index 00000000..a5c5d2b0 --- /dev/null +++ b/src/__tests__/compiler/inline-variables.test.ts @@ -0,0 +1,84 @@ +import { compileWithAutoDebug } from "react-native-css/jest"; + +/** + * `inline-variables.ts` folds a custom property that the stylesheet declares + * exactly once into the declaration that reads it, and drops the variable. + * + * That is a compile-time optimisation with a runtime consequence worth pinning: + * a folded property performs no `var()` lookup on device, so any test that + * asserts how two declarations of the same name are ORDERED passes without ever + * reaching the code that orders them. The precedence tests in + * `src/__tests__/native/variables.test.tsx` and `vars.test.tsx` all declare the + * name more than once for this reason, and this file is what makes that + * requirement visible instead of folklore: change the folding rule and these go + * red, rather than the runtime suites going quietly vacuous. + */ + +/** The compiled rules for one class, or a throw naming the class that is missing. */ +const rulesFor = (css: string, className: string) => { + const stylesheet = compileWithAutoDebug(css).stylesheet(); + const rules = stylesheet.s?.find((rule) => rule[0] === className)?.[1]; + + if (!rules) { + throw new Error(`No rule found for .${className}`); + } + + return rules; +}; + +/** The names a rule declares for itself, ignoring the compiler's own channels. */ +const authoredVariableNames = (css: string, className: string) => + rulesFor(css, className) + .flatMap((rule) => (typeof rule === "object" ? (rule.v ?? []) : [])) + .map(([name]) => name) + .filter((name) => !name.startsWith("__rn-css-")); + +/** True when the rule still has to resolve a `var()` at runtime. */ +const readsAVariableAtRuntime = (css: string, className: string) => + rulesFor(css, className).some( + (rule) => typeof rule === "object" && rule.dv === 1, + ); + +const CONSUMER = `.consumer { color: var(--my-var); }`; +const OWN = `.own { --my-var: blue; color: var(--my-var); }`; +const SECOND_DEFINITION = `.elsewhere { --my-var: seed; }`; + +test("a singly-declared custom property is folded into the declaration that reads it", () => { + // The whole rule, so the fold is visible as a fact rather than an inference: + // `color` holds the computed value and the rule declares no `--my-var` to + // resolve. Nothing here reaches `varResolver` on device. + expect(rulesFor(OWN, "own")).toStrictEqual([ + { s: [1, 1], d: [{ color: "#00f" }], v: [["__rn-css-color", "#00f"]] }, + ]); + expect(authoredVariableNames(OWN, "own")).toStrictEqual([]); + expect(readsAVariableAtRuntime(OWN, "own")).toBe(false); +}); + +test("a second declaration anywhere in the sheet defeats the fold", () => { + const css = `${SECOND_DEFINITION} ${OWN}`; + + // Same rule, same authored CSS — and now the element carries its own + // `--my-var` and a `var()` the runtime has to resolve against it. This is the + // shape every precedence test needs. + expect(authoredVariableNames(css, "own")).toStrictEqual(["my-var"]); + expect(readsAVariableAtRuntime(css, "own")).toBe(true); +}); + +test("a rule that only reads a custom property declares none of its own", () => { + // The other side of the cascade: this element has no declared value, so the + // inherited one is the only candidate and the runtime must go looking for it. + expect(authoredVariableNames(CONSUMER, "consumer")).toStrictEqual([]); + expect(readsAVariableAtRuntime(CONSUMER, "consumer")).toBe(true); +}); + +test("declaring and reading in separate rules leaves both halves in the sheet", () => { + // How the multi-rule form encodes, since `variables.test.tsx` composes its + // trees this way: the declaration rides the declaring rule and the `var()` + // rides the reading one. + const css = `${SECOND_DEFINITION} .declares { --my-var: blue; } ${CONSUMER}`; + + expect(authoredVariableNames(css, "declares")).toStrictEqual(["my-var"]); + expect(readsAVariableAtRuntime(css, "declares")).toBe(false); + expect(authoredVariableNames(css, "consumer")).toStrictEqual([]); + expect(readsAVariableAtRuntime(css, "consumer")).toBe(true); +}); From 9d94827d84d2c1c786c1a492b4cb635b7f4dceba Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 9 Sep 2026 14:06:54 +0300 Subject: [PATCH 3/3] test(native): cover the stylesheet half of custom-property precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rule's `v` entries and a `vars()` object land in the same runtime record, so the resolution order governs plain stylesheet CSS too — and that half had no coverage. Five cases in `variables.test.tsx` and three more in `vars.test.tsx`. Six are red on main: an own rule against an ancestor's rule, against a provider, and across a re-render that changes the provider's value; `vars()` against the element's own class declaration; and two siblings under one provider. The rest are the regression half — an own rule still outranks `:root`, a declaring element still publishes to its descendants, and an inherited value still applies where the element declares nothing. Every sheet declares the name more than once, or the compiler folds it and the case measures nothing. --- src/__tests__/native/variables.test.tsx | 117 ++++++++++++++++++++++++ src/__tests__/native/vars.test.tsx | 75 +++++++++++++++ 2 files changed, 192 insertions(+) diff --git a/src/__tests__/native/variables.test.tsx b/src/__tests__/native/variables.test.tsx index e61340b5..5947b03d 100644 --- a/src/__tests__/native/variables.test.tsx +++ b/src/__tests__/native/variables.test.tsx @@ -271,3 +271,120 @@ test("variable overriding with classes", () => { const component = screen.getByTestId(testID); expect(component.props.style).toStrictEqual({ color: "#f00" }); }); + +/** + * css-cascade-4 §7.2 makes inheritance a DEFAULTING step: an element inherits a + * property only when the cascade produces no declared value for it. A custom + * property is an ordinary property (css-variables-1 §2), so an element that + * declares `--x` in one of its own matched rules uses that value, whatever any + * ancestor declares. + * + * These cases use no `vars()`. A rule's `v` entries and a `vars()` object land + * in the same runtime bucket (`calculate-props.ts`), so the ordering is one + * behaviour — but plain stylesheet CSS is the half every consumer writes, and + * it is the half that had no coverage. + * + * `--my-var` is declared more than once in every sheet below on purpose: + * `inline-variables.ts` folds a singly-declared custom property into the + * declaration that reads it, and a folded property performs no runtime lookup + * at all — so the one-definition form of each of these passes without reaching + * the code under test. `src/__tests__/compiler/inline-variables.test.ts` pins + * that fold, so this requirement is checkable rather than remembered. + */ +const DEFEAT_INLINING = `.elsewhere { --my-var: seed; }`; + +test("an element's own rule outranks an ancestor's rule", () => { + registerCSS(` + ${DEFEAT_INLINING} + .ancestor { --my-var: red; } + .own { --my-var: blue; color: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "blue", + }); +}); + +test("an element's own rule outranks a VariableContextProvider", () => { + registerCSS(` + ${DEFEAT_INLINING} + .own { --my-var: blue; color: var(--my-var); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "blue", + }); +}); + +test("an element's own rule outranks :root", () => { + registerCSS(` + :root { --my-var: red; } + ${DEFEAT_INLINING} + .own { --my-var: blue; color: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "blue", + }); +}); + +test("a changed inherited value does not displace the element's own declaration", () => { + registerCSS(` + ${DEFEAT_INLINING} + .own { --my-var: blue; color: var(--my-var); } + `); + + const tree = (inherited: string) => ( + + + + ); + + render(tree("red")); + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: "blue" }); + + // The re-render is the part worth having: the inherited value is what the + // render guard is keyed on, so a context change re-resolves the element even + // though its answer must not move. + screen.rerender(tree("green")); + expect(component.props.style).toStrictEqual({ color: "blue" }); +}); + +test("a declaring element's value still reaches its descendants", () => { + registerCSS(` + ${DEFEAT_INLINING} + .own { --my-var: blue; color: var(--my-var); } + .reader { color: var(--my-var); } + `); + + render( + + + , + ); + + // Consulting the element's own record first must not stop it publishing that + // record downwards — the declaration is both its own value and the one its + // descendants inherit. + expect(screen.getByTestId("parent").props.style).toStrictEqual({ + color: "blue", + }); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "blue", + }); +}); diff --git a/src/__tests__/native/vars.test.tsx b/src/__tests__/native/vars.test.tsx index 66586fbd..86b456fc 100644 --- a/src/__tests__/native/vars.test.tsx +++ b/src/__tests__/native/vars.test.tsx @@ -82,3 +82,78 @@ test("an inherited value still applies when the element declares nothing", () => color: "red", }); }); + +test("vars() outranks the element's own class declaration", () => { + // Both are the element's OWN value, so the cascade decides between them by + // origin rather than by inheritance: `vars()` reaches the style prop, which + // is the inline half. The stylesheet-rule half is covered in + // `variables.test.tsx`; this is the one case where the two meet. + registerCSS( + `.decoy { --my-var: seed; } + .my-class { --my-var: blue; color: var(--my-var); }`, + ); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "green", + }); +}); + +test("siblings under one provider each resolve against their own declaration", () => { + // The element's record is per-element, so consulting it first must not make + // one sibling's declaration reach the other, nor stop the sibling that + // declares nothing from inheriting. + registerCSS( + `.decoy { --my-var: seed; } + .other-decoy { --my-var: seed2; } + .my-class { color: var(--my-var); }`, + ); + + render( + + + + , + ); + + expect(screen.getByTestId("inherits").props.style).toStrictEqual({ + color: "red", + }); + expect(screen.getByTestId("declares").props.style).toStrictEqual({ + color: "blue", + }); +}); + +test("vars() on a parent still reaches a descendant", () => { + // `vars()` publishes downwards as well as declaring for the element itself, + // so it is both channels at once. Reordering the lookup must leave the + // inherited half intact. + registerCSS( + `.decoy { --my-var: seed; } + .other-decoy { --my-var: seed2; } + .my-class { color: var(--my-var); }`, + ); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "blue", + }); +});