Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 202 additions & 1 deletion src/__tests__/native/variables.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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; }`);
Expand Down Expand Up @@ -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<string, unknown>,
][] = [
[
"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(
<View className="parent">
<View className="mid">
<View testID={testID} className="child" />
</View>
</View>,
);

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(
<View className="parent">
<View testID={testID} className="child" />
</View>,
);

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(
<View className="parent">
<View testID={testID} className="child" />
</View>,
);

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(
<View className="parent">
<View className="mid">
<View testID={testID} className="child" />
</View>
</View>,
);

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 <View testID={testID} />;
}

render(
<View className="parent">
<View className="mid">
<Probe />
</View>
</View>,
);

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(
<VariableContextProvider
value={{ "--a": [{}, "var", "a"], "--unrelated": 0.5 }}
>
<View testID={testID} className="child" />
</VariableContextProvider>,
);

expect(screen.getByTestId(testID).props.style).toStrictEqual({
opacity: 0.5,
});
});
});
10 changes: 10 additions & 0 deletions src/compiler/inline-variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,16 @@ function flattenVar(
vars: Map<string, UniqueVarInfo>,
seen = new Set<string>(),
) {
/**
* 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);
}
Expand Down
3 changes: 2 additions & 1 deletion src/native/styles/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export type ResolveValueOptions = {
inheritedVariables?: VariableContextValue;
inlineVariables?: InlineVariable | undefined;
renderGuards?: RenderGuard[];
variableHistory?: Set<string>;
/** The variable names whose resolution is currently in progress. */
namesBeingResolved?: Set<string>;
/** Pass down to perform recursive calculations and avoid circular dependencies */
calculateProps?: typeof calculateProps;
};
Expand Down
6 changes: 6 additions & 0 deletions src/native/styles/shorthands/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
113 changes: 79 additions & 34 deletions src/native/styles/variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export function varResolver(
renderGuards,
inheritedVariables: variables = { [VAR_SYMBOL]: true },
inlineVariables,
variableHistory = new Set(),
} = options;

const args = fn[2];
Expand All @@ -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<string>());

/**
* 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);
}