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)));