From 756e3209c68fb2cb478f6e8bbad1d9461c034c9a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:22:13 +0300 Subject: [PATCH 1/2] fix(native): cut circular variable resolution instead of blowing the stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A variable is handed to a descendant as an UNRESOLVED descriptor, so a value that names its own variable resolves back into itself. Each of these takes the render down with `RangeError: Maximum call stack size exceeded`: .parent { --a: red } .mid { --a: var(--a) } .child { color: var(--a) } .parent { --a: red } .mid { --a: var(--nope, var(--a)) } .child { color: var(--a) } .parent { --a: red } .mid { --a: var(--b); --b: var(--a) } .child { color: var(--a) } `varResolver` carried a `variableHistory` guard, but it could never fire. The set was destructured out of `options` with a `new Set()` default and never written back, so every invocation built its own empty one and the recursion never shared a history. The registration also sat AFTER the `if (name in variables)` early return — which is the branch a descendant takes, and therefore the branch the recursion runs through. The set now lives on `options`, so every nested resolve sees it, and a name is registered before any of its values are resolved. It is released in a `finally` once they are, which makes it a resolution STACK rather than a visited set: a genuine cycle is cut on re-entry, while a name read twice in one declaration (`box-shadow: var(--c) 1px 1px, var(--c) 2px 2px`) still resolves both times. --- src/__tests__/native/variables.test.tsx | 93 +++++++++++++++++++++++++ src/native/styles/variables.ts | 84 +++++++++++++--------- 2 files changed, 143 insertions(+), 34 deletions(-) diff --git a/src/__tests__/native/variables.test.tsx b/src/__tests__/native/variables.test.tsx index e61340b5..2021fec2 100644 --- a/src/__tests__/native/variables.test.tsx +++ b/src/__tests__/native/variables.test.tsx @@ -271,3 +271,96 @@ test("variable overriding with classes", () => { const component = screen.getByTestId(testID); expect(component.props.style).toStrictEqual({ color: "#f00" }); }); + +/** + * A variable is handed to a descendant as an UNRESOLVED descriptor, so a value + * that names its own variable resolves back into itself. Without a cycle guard + * that survives the recursion, the descendant blows the stack instead of + * rendering. + */ +describe("circular variables", () => { + const circularStylesheets: [name: string, css: string][] = [ + [ + "a variable whose value is itself", + `.parent { --a: red } .mid { --a: var(--a) } .child { color: var(--a) }`, + ], + [ + "a variable reached again through a fallback", + `.parent { --a: red } .mid { --a: var(--nope, var(--a)) } .child { color: var(--a) }`, + ], + [ + "two variables that name each other", + `.parent { --a: red } .mid { --a: var(--b); --b: var(--a) } .child { color: var(--a) }`, + ], + ]; + + test("the census is not empty", () => { + expect(circularStylesheets.length).toBeGreaterThan(0); + }); + + test.each(circularStylesheets)("%s renders", (_name, css) => { + registerCSS(css); + + render( + + + + + , + ); + + // The cycle has no value, so the declaration reading it resolves to nothing. + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); + }); + + test("a variable read twice in ONE declaration is not mistaken for a cycle", () => { + // Both reads share one resolution pass, so the guard has to track names + // whose resolution is IN PROGRESS rather than names already seen. + // `inlineVariables` is off so the reads survive to runtime instead of being + // folded at compile time, as a provider or :root variable does. + registerCSS( + ` + .parent { --shadow-color: red } + .child { + box-shadow: + var(--shadow-color) 1px 1px, + var(--shadow-color) 2px 2px; + } + `, + { inlineVariables: false }, + ); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + boxShadow: [ + { color: "red", offsetX: 1, offsetY: 1 }, + { color: "red", offsetX: 2, offsetY: 2 }, + ], + }); + }); + + test("a long non-circular chain still resolves", () => { + registerCSS( + ` + .parent { --a: var(--b); --b: var(--c); --c: var(--d); --d: red } + .child { color: var(--a) } + `, + { inlineVariables: false }, + ); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "red", + }); + }); +}); diff --git a/src/native/styles/variables.ts b/src/native/styles/variables.ts index af3d6ee2..e379131f 100644 --- a/src/native/styles/variables.ts +++ b/src/native/styles/variables.ts @@ -18,7 +18,6 @@ export function varResolver( renderGuards, inheritedVariables: variables = { [VAR_SYMBOL]: true }, inlineVariables, - variableHistory = new Set(), } = options; const args = fn[2]; @@ -41,48 +40,65 @@ export function varResolver( return; } - // If this recurses back to the same variable, we need to stop - if (variableHistory.has(name)) { + /** + * The names whose resolution is currently in progress, shared through + * `options` so every nested resolve below sees the same set. + * + * A variable's value can name the variable again — directly + * (`--a: var(--a)`), through a fallback, or around a longer chain — and a + * variable is handed to a descendant UNRESOLVED, so resolving it re-enters + * here with the same name and no base case. A name is registered before any + * of its values are resolved and removed once they are, which makes this a + * resolution STACK rather than a visited set: a genuine cycle is cut on + * re-entry, while a name read twice in sequence resolves both times. + */ + const namesBeingResolved = (options.variableHistory ??= new Set()); + + if (namesBeingResolved.has(name)) { return; } - if (name in variables) { - renderGuards?.push(["v", name, variables[name]]); - return resolve(variables[name]); - } + namesBeingResolved.add(name); - variableHistory.add(name); + try { + if (name in variables) { + renderGuards?.push(["v", name, variables[name]]); + return resolve(variables[name]); + } - let value = resolve(inlineVariables?.[name] as StyleDescriptor); - if (value !== undefined) { - options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; - options.inlineVariables[name] = value; + let value = resolve(inlineVariables?.[name] as StyleDescriptor); + if (value !== undefined) { + options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; + options.inlineVariables[name] = value; - return value; - } + return value; + } - value = resolve(variables[name]); - if (value !== undefined) { - renderGuards?.push(["v", name, value]); - options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; - options.inlineVariables[name] = value; + value = resolve(variables[name]); + if (value !== undefined) { + renderGuards?.push(["v", name, value]); + options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; + options.inlineVariables[name] = value; - return value; - } + return value; + } - value = resolve(get(universalVariables(name))); - if (value !== undefined) { - options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; - options.inlineVariables[name] = value; - return value; - } + value = resolve(get(universalVariables(name))); + if (value !== undefined) { + options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; + options.inlineVariables[name] = value; + return value; + } - value = resolve(get(rootVariables(name))); - if (value !== undefined) { - options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; - options.inlineVariables[name] = value; - return value; - } + value = resolve(get(rootVariables(name))); + if (value !== undefined) { + options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; + options.inlineVariables[name] = value; + return value; + } - return resolve(fallback); + return resolve(fallback); + } finally { + namesBeingResolved.delete(name); + } } From 9f039c888e0b9a7495d7b46a15bc57234f9a45d4 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 16 Aug 2026 12:30:49 +0300 Subject: [PATCH 2/2] test(native): make the circular-variable census falsifiable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every row in the census asserted `{}`, which is what the cycle guard produces AND what a dead variable resolver produces. Making `varResolver` return `undefined` unconditionally — every `var()` in the library dead — reddens 143 tests across the suite and left all three rows green. Each row now reads a non-cyclic `--unrelated` beside the cycle, so a row can only pass while resolution still works. The rows also did not compile to the shapes they described. A variable declared exactly once is substituted into its readers, so `.mid { --a: var(--b); --b: var(--a) }` folded to `.mid { --a: var(--a) }` and compiled to the same stylesheet as the first row — the census advertised three shapes and delivered two. Every name in a cycle is now declared twice, which is what makes the two-node row a two-node cycle. Two mutations of the guard survived the census and no longer do: - Returning the re-entering reference's fallback from the cut, against the spec sentence the guard quotes. The fallback sat on the OUTER `var()`, so the cut had none to return and the mutation was a no-op; it now sits on the reference that re-enters. - Emptying the whole stack in the `finally` rather than popping one frame. A name re-entered from two branches of ONE value separates those, and no test had that shape: `--a: var(--b) var(--c)` where both name `--a` recurses forever under `clear()`. Removal is now pinned from both sides — removing too little reddens the two reads in one `box-shadow`, too much reddens the diamond. Both public entry points that recurse without the guard get a test — `useUnstableNativeVariable` and `VariableContextProvider`, whose value type admits a `var()` reference. So does the compiler's own cycle guard, which nothing covered: disabling `flattenVar`'s `seen` set leaves the suite at the exact baseline while `.solo { --z: var(--z) }` recurses at compile time. `ResolveValueOptions.variableHistory` becomes `namesBeingResolved`, matching the local it feeds and what it holds — the names whose resolution is in progress, not the names already seen. The type is internal to `native/styles/` and is re-exported from no entry point. The comment on the `finally` had `options` threaded through the whole style calculation, which would refuse a variable read by a second declaration. `applyDeclarations` builds a fresh options object per declaration, so two declarations never share a stack; removing the `finally` reddens exactly one test in the suite, the two reads in one `box-shadow`. The comments now also record what the cut produces — the property loses its value, or keeps a truncated one where the cycle is part of a larger value — that an inherited name resolving to nothing swallows a reader's fallback, and that the guard bounds cycles only: a long enough non-circular chain still exhausts the stack, at a depth that varies with how deep it already is. --- src/__tests__/native/variables.test.tsx | 124 ++++++++++++++++++++-- src/compiler/inline-variables.ts | 10 ++ src/native/styles/resolve.ts | 3 +- src/native/styles/shorthands/animation.ts | 6 ++ src/native/styles/variables.ts | 41 +++++-- 5 files changed, 169 insertions(+), 15 deletions(-) diff --git a/src/__tests__/native/variables.test.tsx b/src/__tests__/native/variables.test.tsx index 2021fec2..a7a2ccb1 100644 --- a/src/__tests__/native/variables.test.tsx +++ b/src/__tests__/native/variables.test.tsx @@ -2,9 +2,15 @@ import { memo, useEffect } from "react"; import type { ViewProps } from "react-native"; import { render, screen } from "@testing-library/react-native"; -import { styled, VariableContextProvider } from "react-native-css"; +import { styled } from "react-native-css"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; +// The native entry, so the provider's value type is the native +// `StyleDescriptor` one rather than web's `string | number`. +import { + useUnstableNativeVariable, + VariableContextProvider, +} from "react-native-css/native"; test("inline variable", () => { registerCSS(`.my-class { width: var(--my-var); --my-var: 10px; }`); @@ -279,18 +285,59 @@ test("variable overriding with classes", () => { * rendering. */ describe("circular variables", () => { - const circularStylesheets: [name: string, css: string][] = [ + /** + * Every row closes a cycle on `--a` and reads it from `.child`, beside a + * NON-cyclic `--unrelated` that has to keep resolving. The second read is + * what makes the assertion falsifiable: a row that only asserts the cycle + * produced nothing passes just as green when variable resolution is dead + * altogether. + * + * Every name is declared TWICE because the compiler substitutes a variable + * declared exactly once directly into its readers. A single declaration + * folds the cycle away before the runtime resolver these rows exist to + * exercise ever sees it, leaving a row that reads as one shape and compiles + * to another. + */ + const circularStylesheets: [ + name: string, + css: string, + style: Record, + ][] = [ [ "a variable whose value is itself", - `.parent { --a: red } .mid { --a: var(--a) } .child { color: var(--a) }`, + `.parent { --a: red; --unrelated: 1 } + .mid { --a: var(--a); --unrelated: 0.5 } + .child { color: var(--a); opacity: var(--unrelated) }`, + { opacity: 0.5 }, ], [ + // The inner `blue` is the point: a cycle is invalid at computed-value + // time, so the cut yields nothing rather than the fallback of the + // reference that re-entered it. "a variable reached again through a fallback", - `.parent { --a: red } .mid { --a: var(--nope, var(--a)) } .child { color: var(--a) }`, + `.parent { --a: red; --unrelated: 1 } + .mid { --a: var(--nope, var(--a, blue)); --unrelated: 0.5 } + .child { color: var(--a); opacity: var(--unrelated) }`, + { opacity: 0.5 }, ], [ "two variables that name each other", - `.parent { --a: red } .mid { --a: var(--b); --b: var(--a) } .child { color: var(--a) }`, + `.parent { --a: red; --b: blue; --unrelated: 1 } + .mid { --a: var(--b); --b: var(--a); --unrelated: 0.5 } + .child { color: var(--a); opacity: var(--unrelated) }`, + { opacity: 0.5 }, + ], + [ + // Two branches of ONE value re-enter the same name. Cutting the first + // branch has to pop only its own frame — a guard that empties the whole + // stack lets the second branch start over and recurse forever. + "one variable re-entered from two branches of one value", + `.parent { --a: red; --b: blue; --c: green; --unrelated: 1 } + .mid { --a: var(--b) var(--c); --b: var(--a); --c: var(--a); --unrelated: 0.5 } + .child { color: var(--a); opacity: var(--unrelated) }`, + // The cycle is only part of `--a`, so `color` keeps the surviving + // siblings rather than losing the declaration. + { color: [], opacity: 0.5 }, ], ]; @@ -298,7 +345,7 @@ describe("circular variables", () => { expect(circularStylesheets.length).toBeGreaterThan(0); }); - test.each(circularStylesheets)("%s renders", (_name, css) => { + test.each(circularStylesheets)("%s renders", (_name, css, style) => { registerCSS(css); render( @@ -309,8 +356,7 @@ describe("circular variables", () => { , ); - // The cycle has no value, so the declaration reading it resolves to nothing. - expect(screen.getByTestId(testID).props.style).toStrictEqual({}); + expect(screen.getByTestId(testID).props.style).toStrictEqual(style); }); test("a variable read twice in ONE declaration is not mistaken for a cycle", () => { @@ -363,4 +409,66 @@ describe("circular variables", () => { color: "red", }); }); + + test("a cycle in a variable declared ONCE is cut at compile time", () => { + // A variable declared once is substituted into its readers, so this cycle + // is closed by the compiler's own guard in `inline-variables.ts` and never + // reaches the resolution stack above. + registerCSS( + `.parent { --unrelated: 1 } + .mid { --unrelated: 0.5 } + .child { --z: var(--z); width: var(--z); opacity: var(--unrelated) }`, + ); + + render( + + + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + opacity: 0.5, + }); + }); + + test("useUnstableNativeVariable reads a cycle without recursing", () => { + registerCSS(`.parent { --a: red } .mid { --a: var(--a) }`); + + let read: unknown = "not read"; + + function Probe() { + read = useUnstableNativeVariable("--a"); + return ; + } + + render( + + + + + , + ); + + expect(read).toBeUndefined(); + }); + + test("VariableContextProvider accepts a self-referential value", () => { + // The provider's value type admits a `var()` reference, so a caller can + // hand it a variable that names itself. + registerCSS(`.child { color: var(--a); opacity: var(--unrelated) }`); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + opacity: 0.5, + }); + }); }); diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts index 609760c1..cf14e851 100644 --- a/src/compiler/inline-variables.ts +++ b/src/compiler/inline-variables.ts @@ -161,6 +161,16 @@ function flattenVar( vars: Map, seen = new Set(), ) { + /** + * The compile-time counterpart of the resolution stack in + * `native/styles/variables.ts`. Substituting a variable into its readers + * follows the same references, so a self-referential value recurses here + * with no base case — this drops the name instead, which leaves the + * reference unsubstituted and hands the cycle to the runtime guard. + * + * A variable declared more than once is never inlined, so it reaches the + * runtime guard without passing through here at all. + */ if (seen.has(name)) { vars.delete(name); } diff --git a/src/native/styles/resolve.ts b/src/native/styles/resolve.ts index 8465e9b1..fdb6a10b 100644 --- a/src/native/styles/resolve.ts +++ b/src/native/styles/resolve.ts @@ -50,7 +50,8 @@ export type ResolveValueOptions = { inheritedVariables?: VariableContextValue; inlineVariables?: InlineVariable | undefined; renderGuards?: RenderGuard[]; - variableHistory?: Set; + /** The variable names whose resolution is currently in progress. */ + namesBeingResolved?: Set; /** Pass down to perform recursive calculations and avoid circular dependencies */ calculateProps?: typeof calculateProps; }; diff --git a/src/native/styles/shorthands/animation.ts b/src/native/styles/shorthands/animation.ts index 9b5cc39a..cbc86ef2 100644 --- a/src/native/styles/shorthands/animation.ts +++ b/src/native/styles/shorthands/animation.ts @@ -92,6 +92,12 @@ export const animation: StyleFunctionResolver = ( for (const [progress, declarations] of keyframes) { animation[progress] ??= {}; + /** + * Keyframe declarations resolve through a fresh options object, so the + * resolution stack in `../variables.ts` does not cross this boundary. The + * animation name is resolved and its frame popped before this runs, which + * leaves no live frame for a keyframe to re-enter. + */ const props = options.calculateProps?.( get, // Cast this into a StyleRule[] diff --git a/src/native/styles/variables.ts b/src/native/styles/variables.ts index e379131f..fdfa019a 100644 --- a/src/native/styles/variables.ts +++ b/src/native/styles/variables.ts @@ -42,18 +42,41 @@ export function varResolver( /** * The names whose resolution is currently in progress, shared through - * `options` so every nested resolve below sees the same set. + * `options` so every nested resolve below sees the same stack. * * A variable's value can name the variable again — directly * (`--a: var(--a)`), through a fallback, or around a longer chain — and a * variable is handed to a descendant UNRESOLVED, so resolving it re-enters - * here with the same name and no base case. A name is registered before any - * of its values are resolved and removed once they are, which makes this a - * resolution STACK rather than a visited set: a genuine cycle is cut on - * re-entry, while a name read twice in sequence resolves both times. + * here with the same name and no base case. + * + * A name is registered before any of its values are resolved and removed + * once they are, which makes this a resolution STACK rather than a visited + * set. Both halves are load-bearing: + * + * - Registering cuts a genuine cycle on re-entry. + * - Removing per frame keeps a name readable again once its own resolution + * has finished, which a name read twice within ONE declaration needs + * (`box-shadow: var(--c) 1px 1px, var(--c) 2px 2px`). Only the + * within-one-declaration case depends on it — `applyDeclarations` builds + * a fresh options object per declaration, so two declarations never share + * a stack. Emptying the whole stack instead would let a second branch of + * one value start the cycle over: `--a: var(--b) var(--c)` where both + * name `--a` recurses forever. + * + * This bounds CYCLES only. A non-circular chain long enough to exhaust the + * JS stack still throws, at a depth that varies with how deep the stack + * already is when resolution starts. */ - const namesBeingResolved = (options.variableHistory ??= new Set()); + const namesBeingResolved = (options.namesBeingResolved ??= new Set()); + /** + * A variable in a cycle is invalid at computed-value time, so the cut yields + * nothing rather than the fallback of the reference that re-entered it. + * + * The property reading it loses its value, or keeps a TRUNCATED one where + * the cycle is only part of a larger value — `resolveValue` filters the + * missing piece out of a descriptor array and keeps the surviving siblings. + */ if (namesBeingResolved.has(name)) { return; } @@ -61,6 +84,12 @@ export function varResolver( namesBeingResolved.add(name); try { + /** + * A name present in the inherited variables resolves to whatever that + * value gives, `fallback` included: an inherited name that resolves to + * nothing swallows `var(--name, blue)`'s fallback rather than using it. + * That holds for any unresolvable inherited value, not just a cyclic one. + */ if (name in variables) { renderGuards?.push(["v", name, variables[name]]); return resolve(variables[name]);