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