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

/**
* `inline-variables.ts` folds a custom property that the stylesheet declares
* exactly once into the declaration that reads it, and drops the variable.
*
* That is a compile-time optimisation with a runtime consequence worth pinning:
* a folded property performs no `var()` lookup on device, so any test that
* asserts how two declarations of the same name are ORDERED passes without ever
* reaching the code that orders them. The precedence tests in
* `src/__tests__/native/variables.test.tsx` and `vars.test.tsx` all declare the
* name more than once for this reason, and this file is what makes that
* requirement visible instead of folklore: change the folding rule and these go
* red, rather than the runtime suites going quietly vacuous.
*/

/** The compiled rules for one class, or a throw naming the class that is missing. */
const rulesFor = (css: string, className: string) => {
const stylesheet = compileWithAutoDebug(css).stylesheet();
const rules = stylesheet.s?.find((rule) => rule[0] === className)?.[1];

if (!rules) {
throw new Error(`No rule found for .${className}`);
}

return rules;
};

/** The names a rule declares for itself, ignoring the compiler's own channels. */
const authoredVariableNames = (css: string, className: string) =>
rulesFor(css, className)
.flatMap((rule) => (typeof rule === "object" ? (rule.v ?? []) : []))
.map(([name]) => name)
.filter((name) => !name.startsWith("__rn-css-"));

/** True when the rule still has to resolve a `var()` at runtime. */
const readsAVariableAtRuntime = (css: string, className: string) =>
rulesFor(css, className).some(
(rule) => typeof rule === "object" && rule.dv === 1,
);

const CONSUMER = `.consumer { color: var(--my-var); }`;
const OWN = `.own { --my-var: blue; color: var(--my-var); }`;
const SECOND_DEFINITION = `.elsewhere { --my-var: seed; }`;

test("a singly-declared custom property is folded into the declaration that reads it", () => {
// The whole rule, so the fold is visible as a fact rather than an inference:
// `color` holds the computed value and the rule declares no `--my-var` to
// resolve. Nothing here reaches `varResolver` on device.
expect(rulesFor(OWN, "own")).toStrictEqual([
{ s: [1, 1], d: [{ color: "#00f" }], v: [["__rn-css-color", "#00f"]] },
]);
expect(authoredVariableNames(OWN, "own")).toStrictEqual([]);
expect(readsAVariableAtRuntime(OWN, "own")).toBe(false);
});

test("a second declaration anywhere in the sheet defeats the fold", () => {
const css = `${SECOND_DEFINITION} ${OWN}`;

// Same rule, same authored CSS — and now the element carries its own
// `--my-var` and a `var()` the runtime has to resolve against it. This is the
// shape every precedence test needs.
expect(authoredVariableNames(css, "own")).toStrictEqual(["my-var"]);
expect(readsAVariableAtRuntime(css, "own")).toBe(true);
});

test("a rule that only reads a custom property declares none of its own", () => {
// The other side of the cascade: this element has no declared value, so the
// inherited one is the only candidate and the runtime must go looking for it.
expect(authoredVariableNames(CONSUMER, "consumer")).toStrictEqual([]);
expect(readsAVariableAtRuntime(CONSUMER, "consumer")).toBe(true);
});

test("declaring and reading in separate rules leaves both halves in the sheet", () => {
// How the multi-rule form encodes, since `variables.test.tsx` composes its
// trees this way: the declaration rides the declaring rule and the `var()`
// rides the reading one.
const css = `${SECOND_DEFINITION} .declares { --my-var: blue; } ${CONSUMER}`;

expect(authoredVariableNames(css, "declares")).toStrictEqual(["my-var"]);
expect(readsAVariableAtRuntime(css, "declares")).toBe(false);
expect(authoredVariableNames(css, "consumer")).toStrictEqual([]);
expect(readsAVariableAtRuntime(css, "consumer")).toBe(true);
});
117 changes: 117 additions & 0 deletions src/__tests__/native/variables.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -271,3 +271,120 @@ test("variable overriding with classes", () => {
const component = screen.getByTestId(testID);
expect(component.props.style).toStrictEqual({ color: "#f00" });
});

/**
* css-cascade-4 §7.2 makes inheritance a DEFAULTING step: an element inherits a
* property only when the cascade produces no declared value for it. A custom
* property is an ordinary property (css-variables-1 §2), so an element that
* declares `--x` in one of its own matched rules uses that value, whatever any
* ancestor declares.
*
* These cases use no `vars()`. A rule's `v` entries and a `vars()` object land
* in the same runtime bucket (`calculate-props.ts`), so the ordering is one
* behaviour — but plain stylesheet CSS is the half every consumer writes, and
* it is the half that had no coverage.
*
* `--my-var` is declared more than once in every sheet below on purpose:
* `inline-variables.ts` folds a singly-declared custom property into the
* declaration that reads it, and a folded property performs no runtime lookup
* at all — so the one-definition form of each of these passes without reaching
* the code under test. `src/__tests__/compiler/inline-variables.test.ts` pins
* that fold, so this requirement is checkable rather than remembered.
*/
const DEFEAT_INLINING = `.elsewhere { --my-var: seed; }`;

test("an element's own rule outranks an ancestor's rule", () => {
registerCSS(`
${DEFEAT_INLINING}
.ancestor { --my-var: red; }
.own { --my-var: blue; color: var(--my-var); }
`);

render(
<View className="ancestor">
<View testID={testID} className="own" />
</View>,
);

expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "blue",
});
});

test("an element's own rule outranks a VariableContextProvider", () => {
registerCSS(`
${DEFEAT_INLINING}
.own { --my-var: blue; color: var(--my-var); }
`);

render(
<VariableContextProvider value={{ "--my-var": "red" }}>
<View testID={testID} className="own" />
</VariableContextProvider>,
);

expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "blue",
});
});

test("an element's own rule outranks :root", () => {
registerCSS(`
:root { --my-var: red; }
${DEFEAT_INLINING}
.own { --my-var: blue; color: var(--my-var); }
`);

render(<View testID={testID} className="own" />);

expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "blue",
});
});

test("a changed inherited value does not displace the element's own declaration", () => {
registerCSS(`
${DEFEAT_INLINING}
.own { --my-var: blue; color: var(--my-var); }
`);

const tree = (inherited: string) => (
<VariableContextProvider value={{ "--my-var": inherited }}>
<View testID={testID} className="own" />
</VariableContextProvider>
);

render(tree("red"));
const component = screen.getByTestId(testID);
expect(component.props.style).toStrictEqual({ color: "blue" });

// The re-render is the part worth having: the inherited value is what the
// render guard is keyed on, so a context change re-resolves the element even
// though its answer must not move.
screen.rerender(tree("green"));
expect(component.props.style).toStrictEqual({ color: "blue" });
});

test("a declaring element's value still reaches its descendants", () => {
registerCSS(`
${DEFEAT_INLINING}
.own { --my-var: blue; color: var(--my-var); }
.reader { color: var(--my-var); }
`);

render(
<View testID="parent" className="own">
<View testID="child" className="reader" />
</View>,
);

// Consulting the element's own record first must not stop it publishing that
// record downwards — the declaration is both its own value and the one its
// descendants inherit.
expect(screen.getByTestId("parent").props.style).toStrictEqual({
color: "blue",
});
expect(screen.getByTestId("child").props.style).toStrictEqual({
color: "blue",
});
});
123 changes: 122 additions & 1 deletion src/__tests__/native/vars.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { render, screen } from "@testing-library/react-native";
import { View } from "react-native-css/components/View";
import { registerCSS, testID } from "react-native-css/jest";
import { vars } from "react-native-css/runtime";
import { VariableContextProvider, vars } from "react-native-css/runtime";

test("vars", () => {
registerCSS(
Expand Down Expand Up @@ -36,3 +36,124 @@ test("vars", () => {
color: "blue",
});
});

test("an element's own vars() outranks an inherited value", () => {
// `--my-var` is defined twice so `inline-variables.ts` cannot fold it into
// the consuming declaration; a single definition performs no runtime var()
// read and would pass without exercising precedence at all.
registerCSS(
`.decoy { --my-var: seed; }
.other-decoy { --my-var: seed2; }
.my-class { color: var(--my-var); }`,
);

render(
<VariableContextProvider value={{ "--my-var": "red" }}>
<View
testID={testID}
className="my-class"
style={vars({ "--my-var": "blue" })}
/>
</VariableContextProvider>,
);

// css-cascade-4 §7.2: inheritance is a defaulting step, reached only when the
// cascade yields no declared value for the element. The element declares
// `--my-var`, so the ancestor's `red` never applies to it.
expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "blue",
});
});

test("an inherited value still applies when the element declares nothing", () => {
registerCSS(
`.decoy { --my-var: seed; }
.other-decoy { --my-var: seed2; }
.my-class { color: var(--my-var); }`,
);

render(
<VariableContextProvider value={{ "--my-var": "red" }}>
<View testID={testID} className="my-class" />
</VariableContextProvider>,
);

expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "red",
});
});

test("vars() outranks the element's own class declaration", () => {
// Both are the element's OWN value, so the cascade decides between them by
// origin rather than by inheritance: `vars()` reaches the style prop, which
// is the inline half. The stylesheet-rule half is covered in
// `variables.test.tsx`; this is the one case where the two meet.
registerCSS(
`.decoy { --my-var: seed; }
.my-class { --my-var: blue; color: var(--my-var); }`,
);

render(
<VariableContextProvider value={{ "--my-var": "red" }}>
<View
testID={testID}
className="my-class"
style={vars({ "--my-var": "green" })}
/>
</VariableContextProvider>,
);

expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "green",
});
});

test("siblings under one provider each resolve against their own declaration", () => {
// The element's record is per-element, so consulting it first must not make
// one sibling's declaration reach the other, nor stop the sibling that
// declares nothing from inheriting.
registerCSS(
`.decoy { --my-var: seed; }
.other-decoy { --my-var: seed2; }
.my-class { color: var(--my-var); }`,
);

render(
<VariableContextProvider value={{ "--my-var": "red" }}>
<View testID="inherits" className="my-class" />
<View
testID="declares"
className="my-class"
style={vars({ "--my-var": "blue" })}
/>
</VariableContextProvider>,
);

expect(screen.getByTestId("inherits").props.style).toStrictEqual({
color: "red",
});
expect(screen.getByTestId("declares").props.style).toStrictEqual({
color: "blue",
});
});

test("vars() on a parent still reaches a descendant", () => {
// `vars()` publishes downwards as well as declaring for the element itself,
// so it is both channels at once. Reordering the lookup must leave the
// inherited half intact.
registerCSS(
`.decoy { --my-var: seed; }
.other-decoy { --my-var: seed2; }
.my-class { color: var(--my-var); }`,
);

render(
<View className="my-class" style={vars({ "--my-var": "blue" })}>
<View testID={testID} className="my-class" />
</View>,
);

expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "blue",
});
});
18 changes: 6 additions & 12 deletions src/native/styles/variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,6 @@ export function varResolver(
return;
}

if (name in variables) {
renderGuards?.push(["v", name, variables[name]]);
return resolve(variables[name]);
}

variableHistory.add(name);

let value = resolve(inlineVariables?.[name] as StyleDescriptor);
Expand All @@ -61,13 +56,12 @@ export function varResolver(
return value;
}

value = resolve(variables[name]);
if (value !== undefined) {
renderGuards?.push(["v", name, value]);
options.inlineVariables ??= { [VAR_SYMBOL]: "inline" };
options.inlineVariables[name] = value;

return value;
if (name in variables) {
// The RAW inherited descriptor, not the resolved value: `testGuards`
// compares this against the next render's context, and a resolved value
// would never match.
renderGuards?.push(["v", name, variables[name]]);
return resolve(variables[name]);
}

value = resolve(get(universalVariables(name)));
Expand Down