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); +}); 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 d87eb7c4..86b456fc 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,124 @@ 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", + }); +}); + +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", + }); +}); 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)));