From f5913aefcf74ee81bac54cb8e741848a258830e2 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 02:07:58 +0300 Subject: [PATCH] test: cover variable cycles that close across elements, and the inliner's guard #451 cuts a custom-property cycle at runtime and in the compile-time inliner, and tests cycles declared on one element. A variable reaches a descendant unresolved, so a cycle can also close on the element that reads it: a variable that is itself, one reached again through a fallback, two that name each other, and one reached from two branches of one value. Each drops the declaration that reads it, as browsers do, while an unrelated variable still resolves. The other cases pin what must not read as a cycle: a variable read twice in one declaration, and a long inherited chain. The compiler cases pin that the inliner leaves a cycle for the runtime and still folds a chain. --- .../compiler/variable-cycles.test.ts | 55 +++++++++++ src/__tests__/native/variable-cycles.test.tsx | 99 +++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 src/__tests__/compiler/variable-cycles.test.ts diff --git a/src/__tests__/compiler/variable-cycles.test.ts b/src/__tests__/compiler/variable-cycles.test.ts new file mode 100644 index 00000000..89dad8fc --- /dev/null +++ b/src/__tests__/compiler/variable-cycles.test.ts @@ -0,0 +1,55 @@ +import { compile } from "react-native-css/compiler"; + +function firstDeclaration(css: string, className: string): unknown { + const entry = (compile(css).stylesheet().s ?? []).find( + ([name]) => name === className, + ); + if (!entry) { + throw new Error(`the compiler emitted no rule for .${className}`); + } + return entry[1][0]?.d; +} + +const CYCLES = [ + [ + "a variable referencing itself", + `.t { --x: var(--x); color: var(--x) }`, + "x", + ], + [ + "two variables referencing each other", + `.t { --p: var(--q); --q: var(--p); color: var(--p) }`, + "p", + ], + [ + "a three-step cycle", + `.t { --a: var(--b); --b: var(--c); --c: var(--a); color: var(--a) }`, + "a", + ], +] as const; + +describe("a variable cycle is left for the runtime rather than inlined", () => { + test("the census is not empty, so the cases below are not vacuous", () => { + expect(CYCLES.length).toBeGreaterThan(0); + }); + + test.each(CYCLES)("%s", (_label, css, variableName) => { + expect(firstDeclaration(css, "t")).toStrictEqual([ + [[{}, "var", variableName, 1], "color", 1], + ]); + }); +}); + +describe("a variable with no cycle is still inlined", () => { + test("a direct value substitutes", () => { + expect( + firstDeclaration(`.t { --n: red; color: var(--n) }`, "t"), + ).toStrictEqual([{ color: "#f00" }]); + }); + + test("a chain substitutes through every step", () => { + expect( + firstDeclaration(`.t { --a: red; --b: var(--a); color: var(--b) }`, "t"), + ).toStrictEqual([{ color: "#f00" }]); + }); +}); diff --git a/src/__tests__/native/variable-cycles.test.tsx b/src/__tests__/native/variable-cycles.test.tsx index 0bcf5ef7..04f96cc9 100644 --- a/src/__tests__/native/variable-cycles.test.tsx +++ b/src/__tests__/native/variable-cycles.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from "@testing-library/react-native"; import { View } from "react-native-css/components/View"; import { registerCSS } from "react-native-css/jest"; +import { useUnstableNativeVariable } from "react-native-css/native"; // Current CSS substitution rules ignore unused fallback cycles. // https://github.com/w3c/csswg-drafts/issues/11500 @@ -47,3 +48,101 @@ test("repeated sibling variable references are not cycles", () => { render(); expect(screen.getByTestId("sample").props.style).toStrictEqual({ width: 40 }); }); + +const nested = ( + + + + + +); + +// A variable reaches a descendant unresolved, so these cycles close on the element that reads them +test.each([ + ["a variable whose value is itself", ".mid { --a: var(--a); }"], + [ + "a variable reached again through a fallback", + ".mid { --a: var(--nope, var(--a, blue)); }", + ], + [ + "two variables that name each other", + ".mid { --a: var(--b); --b: var(--a); }", + ], + [ + "one variable reached again from two branches of one value", + ".mid { --a: var(--b) var(--c); --b: var(--a); --c: var(--a); }", + ], +])("%s, inherited, drops the reading declaration", (_name, cycle) => { + registerCSS( + `.parent { --a: red; --b: blue; --c: green; --unrelated: 1; } + ${cycle} + .mid { --unrelated: 0.5; } + .child { color: var(--a); opacity: var(--unrelated); }`, + { inlineVariables: false }, + ); + render(nested); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + opacity: 0.5, + }); +}); + +test("an inherited variable read twice in one declaration is not a cycle", () => { + registerCSS( + `.parent { --shadow-color: red; } + .child { box-shadow: var(--shadow-color) 1px 1px, var(--shadow-color) 2px 2px; }`, + { inlineVariables: false }, + ); + render(nested); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + boxShadow: [ + { color: "red", offsetX: 1, offsetY: 1 }, + { color: "red", offsetX: 2, offsetY: 2 }, + ], + }); +}); + +test("an inherited non-circular chain resolves through every step", () => { + registerCSS( + `.parent { --a: var(--b); --b: var(--c); --c: var(--d); --d: red; } + .child { color: var(--a); }`, + { inlineVariables: false }, + ); + render(nested); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "red", + }); +}); + +test("a cycle in a variable declared once is cut by the compiler", () => { + registerCSS( + `.mid { --unrelated: 0.5; } + .child { --z: var(--z); width: var(--z); opacity: var(--unrelated); }`, + ); + render(nested); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + opacity: 0.5, + }); +}); + +test("useUnstableNativeVariable reads a cycle as no value", () => { + registerCSS(`.parent { --a: red; } .mid { --a: var(--a); }`, { + inlineVariables: false, + }); + + let read: unknown = "not read"; + + function Probe() { + read = useUnstableNativeVariable("--a"); + return ; + } + + render( + + + + + , + ); + + expect(read).toBeUndefined(); +});