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(); +});