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
61 changes: 61 additions & 0 deletions src/__tests__/compiler/variable-cycles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { compile } from "react-native-css/compiler";

/**
* Substitution follows the same references the runtime resolver does, so a
* self-referential value has no base case at compile time either. The runtime
* suite exercises the resolver's own guard; this covers the compiler's, which
* runs first and would otherwise never hand it anything.
*/
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" }]);
});
});
233 changes: 232 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,228 @@ 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,
});
});

// A name with ONE definition is inlined at compile time, so each of these carries a second the
// element never inherits — that is what leaves the lookup to the runtime at all.
test("a universal variable resolves when no scope above defines the name", () => {
registerCSS(`
* { --universal: 7px; }
.elsewhere { --universal: 99px; }
.uses-universal { width: var(--universal); }
`);

render(<View testID={testID} className="uses-universal" />);

expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 7 });
});

test("an inherited definition outranks the universal one", () => {
registerCSS(`
* { --universal: 7px; }
.defines { --universal: 11px; }
.uses-universal { width: var(--universal); }
`);

render(
<View className="defines">
<View testID={testID} className="uses-universal" />
</View>,
);

expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 11 });
});
});
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
Loading