diff --git a/src/__tests__/native/variables.test.tsx b/src/__tests__/native/variables.test.tsx index e61340b5..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; }`); @@ -271,3 +277,198 @@ 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", () => { + /** + * 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; --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; --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; --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 }, + ], + ]; + + test("the census is not empty", () => { + expect(circularStylesheets.length).toBeGreaterThan(0); + }); + + test.each(circularStylesheets)("%s renders", (_name, css, style) => { + registerCSS(css); + + render( + + + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual(style); + }); + + 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", + }); + }); + + 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 af3d6ee2..fdfa019a 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,94 @@ 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 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. 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.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; } - if (name in variables) { - renderGuards?.push(["v", name, variables[name]]); - return resolve(variables[name]); - } + 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]); + } - variableHistory.add(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); + } finally { + namesBeingResolved.delete(name); } - - return resolve(fallback); }