From f910b31062f72487abe900161a8b78ee3c238de4 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 16:12:39 +0300 Subject: [PATCH 1/5] fix(compiler): scope single-definition variable inlining to the declaring block Re-cut onto main (a5002c5). Main renders an animated component's style as an array, so the parity check flattens it. Main rejects a selector naming :root and a class before the inliner sees it, so the :root .theme case waits on that check. --- .../compiler/inline-variables.test.ts | 237 +++++++++++++++ .../native/variable-fold-parity.test.tsx | 266 ++++++++++++++++ .../native/variable-inlining.test.tsx | 158 ++++++++++ src/__tests__/native/variables.test.tsx | 13 +- src/compiler/declarations.ts | 50 ++- src/compiler/inline-variables.ts | 286 ++++++++++++++++-- 6 files changed, 976 insertions(+), 34 deletions(-) create mode 100644 src/__tests__/compiler/inline-variables.test.ts create mode 100644 src/__tests__/native/variable-fold-parity.test.tsx create mode 100644 src/__tests__/native/variable-inlining.test.tsx diff --git a/src/__tests__/compiler/inline-variables.test.ts b/src/__tests__/compiler/inline-variables.test.ts new file mode 100644 index 00000000..341f3102 --- /dev/null +++ b/src/__tests__/compiler/inline-variables.test.ts @@ -0,0 +1,237 @@ +import type { + ReactNativeCssStyleSheet, + StyleRule, +} from "react-native-css/compiler"; +import { compile } from "react-native-css/compiler"; + +/** + * Folding a single-definition custom property into its use sites asserts that + * every element the consuming rule matches holds that value. These tests pin + * where that assertion is provable and where it is not. + * + * Compiler plane. `../native/variable-inlining.test.tsx` renders the same + * stylesheets, because a fold is a compiler decision whose only observable is + * what the runtime paints. + */ + +function rulesFor( + sheet: ReactNativeCssStyleSheet, + className: string, +): StyleRule[] { + return sheet.s?.find(([name]) => name === className)?.[1] ?? []; +} + +/** The value folded into `property`, or undefined if nothing was folded. */ +function foldedValue( + sheet: ReactNativeCssStyleSheet, + className: string, + property: string, +) { + for (const rule of rulesFor(sheet, className)) { + for (const declaration of rule.d ?? []) { + if (!Array.isArray(declaration) && property in declaration) { + return declaration[property]; + } + } + } + return undefined; +} + +/** Whether the class resolves at least one `var()` at runtime. */ +function readsAtRuntime(sheet: ReactNativeCssStyleSheet, className: string) { + return rulesFor(sheet, className).some((rule) => rule.dv === 1); +} + +/** The value the class declares for `--`, or undefined if it declares none. */ +function declaredValue( + sheet: ReactNativeCssStyleSheet, + className: string, + name: string, +) { + for (const rule of rulesFor(sheet, className)) { + for (const [declared, value] of rule.v ?? []) { + if (declared === name) return value; + } + } + return undefined; +} + +describe("a variable is folded only where its value is provable", () => { + test("a class-scoped variable does not reach another rule", () => { + const sheet = compile( + `.parent { --x: 10px; } .child { width: var(--x); }`, + ).stylesheet(); + + // An element carrying only `.child` never matched `.parent`, so it has no + // `--x` and `width: 10` would be an invention. + expect(foldedValue(sheet, "child", "width")).toBeUndefined(); + expect(readsAtRuntime(sheet, "child")).toBe(true); + // ...and the declaration has to survive for the runtime to find it. + expect(declaredValue(sheet, "parent", "x")).toBe(10); + }); + + test("the same block is provable, and still folds", () => { + const sheet = compile(`.a { --x: 10px; width: var(--x); }`).stylesheet(); + + expect(foldedValue(sheet, "a", "width")).toBe(10); + expect(readsAtRuntime(sheet, "a")).toBe(false); + }); + + test("a block-scoped declaration survives its own fold", () => { + // The fold reached this block's references and no others. A descendant + // inherits `--x` at runtime — including one styled by a stylesheet compiled + // separately, which this pass cannot see and must not assume away. + const sheet = compile(`.a { --x: 10px; width: var(--x); }`).stylesheet(); + + expect(declaredValue(sheet, "a", "x")).toBe(10); + }); + + test.each([":root", ":host", "*", "html"])( + "%s is universal, so its variables fold anywhere", + (selector) => { + const sheet = compile( + `${selector} { --x: 10px; } .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBe(10); + expect(readsAtRuntime(sheet, "child")).toBe(false); + }, + ); + + test.each([ + ["div", "every element descends from html, from no other element name"], + [":hover", "a state is not a scope"], + [".theme", "a class is the case this whole rule exists for"], + ])("%s is not universal — %s", (selector) => { + const sheet = compile( + `${selector} { --x: 10px; } .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBeUndefined(); + expect(readsAtRuntime(sheet, "child")).toBe(true); + }); + + test.each([ + [":root", 10], + [".theme", undefined], + ])("a nested `&` under %s takes its parent's answer", (parent, expected) => { + // `&` adds no constraint of its own, so the scope is whatever the rule it + // is nested in already was. + const sheet = compile( + `${parent} { & { --x: 10px; } } .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBe(expected); + }); + + test("@layer does not narrow a universal scope", () => { + // The shape Tailwind emits for its theme. + const sheet = compile( + `@layer theme { :root, :host { --x: 10px; } } .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBe(10); + }); +}); + +describe("a conditional scope is not a universal one", () => { + test.each([ + ["@media (min-width: 1px)", "media"], + ["@supports (display: flex)", "supports"], + ["@container (min-width: 1px)", "container"], + ])("%s wrapping :root", (atRule) => { + const sheet = compile( + `${atRule} { :root { --x: 10px; } } .child { width: var(--x); }`, + ).stylesheet(); + + // Whether the declaration applies is decided somewhere this pass cannot + // see, so `.child` cannot be told it holds the value. + expect(foldedValue(sheet, "child", "width")).toBeUndefined(); + expect(readsAtRuntime(sheet, "child")).toBe(true); + }); + + test("a conditional block still folds into itself", () => { + // A query that switches the declaration off switches the reference off with + // it, so the two cannot disagree. + const sheet = compile( + `@media (min-width: 1px) { .a { --x: 10px; width: var(--x); } }`, + ).stylesheet(); + + expect(foldedValue(sheet, "a", "width")).toBe(10); + }); +}); + +describe("a registered property is a second definition", () => { + test("inherits: false makes a universal declaration non-universal", () => { + // `--x` does not inherit, so an element that is not the root holds the + // REGISTERED initial value, not the one `:root` declares. + const sheet = compile( + `@property --x { syntax: ""; inherits: false; initial-value: 10px; } + :root { --x: 20px; } + .b { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "b", "width")).toBeUndefined(); + expect(readsAtRuntime(sheet, "b")).toBe(true); + }); + + test("inherits: true leaves the universal fold alone", () => { + const sheet = compile( + `@property --x { syntax: ""; inherits: true; initial-value: 10px; } + :root { --x: 20px; } + .b { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "b", "width")).toBe(20); + }); +}); + +describe("a variable's own value is held to the same terms", () => { + test("a nested reference does not cross a block boundary", () => { + const sheet = compile( + `.a { --x: var(--y); width: var(--x); } .b { --y: 10px; }`, + ).stylesheet(); + + // `--y` belongs to `.b`. Folding it into `.a`'s value carries it to every + // element matching `.a`, none of which need ever have matched `.b` — and + // `.a`'s own `width` is where that lands, because `--x` IS foldable there. + expect(foldedValue(sheet, "a", "width")).toBeUndefined(); + expect(readsAtRuntime(sheet, "a")).toBe(true); + expect(declaredValue(sheet, "a", "x")).toStrictEqual([{}, "var", "y", 1]); + }); + + test("a nested reference in the same block folds", () => { + const sheet = compile( + `.a { --y: 10px; --x: var(--y); width: var(--x); }`, + ).stylesheet(); + + expect(declaredValue(sheet, "a", "x")).toBe(10); + expect(foldedValue(sheet, "a", "width")).toBe(10); + }); + + test("a universal nested reference folds anywhere", () => { + const sheet = compile( + `:root { --y: 10px; } .a { --x: var(--y); }`, + ).stylesheet(); + + expect(declaredValue(sheet, "a", "x")).toBe(10); + }); +}); + +test("the fold does not depend on the order the rules are written in", () => { + // `--y` has two declarations, so it is not a candidate at all, and `2px` is + // the one that wins. Pruning a candidate only when its own turn came round + // made that depend on whether `--x` was written above the declarations or + // below them — and above them, the LOSING `1px` was folded in. + const declarations = `:root { --y: 1px; } :root { --y: 2px; }`; + const reference = `.a { --x: var(--y); width: var(--x); }`; + + const varFirst = compile(`${reference} ${declarations}`).stylesheet(); + const varLast = compile(`${declarations} ${reference}`).stylesheet(); + + for (const sheet of [varFirst, varLast]) { + expect(foldedValue(sheet, "a", "width")).toBeUndefined(); + expect(readsAtRuntime(sheet, "a")).toBe(true); + expect(declaredValue(sheet, "a", "x")).toStrictEqual([{}, "var", "y", 1]); + } +}); diff --git a/src/__tests__/native/variable-fold-parity.test.tsx b/src/__tests__/native/variable-fold-parity.test.tsx new file mode 100644 index 00000000..14ae55fc --- /dev/null +++ b/src/__tests__/native/variable-fold-parity.test.tsx @@ -0,0 +1,266 @@ +import { StyleSheet } from "react-native"; + +import { render } from "@testing-library/react-native"; +import type { TokenOrValue } from "lightningcss"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { StyleCollection } from "react-native-css/native"; + +/** + * Whether the compiler folds a single-definition custom property into its use + * sites is an OPTIMISATION. It must not be observable: the same stylesheet + * compiled with the fold on and with the fold off has to render the same style. + * + * The census below is exhaustive over the value kinds lightningcss can put + * inside a custom property, by construction — both records are keyed by the + * lightningcss union itself, so a new member fails to compile until it is + * censused here. An empty list is a kind that cannot be the whole value of a + * custom property a React Native style consumes, and says why. + */ +type ValueKind = TokenOrValue["type"]; +type RawTokenKind = Extract["value"]["type"]; + +interface Shape { + /** The property that reads the variable. */ + readonly property: string; + /** The custom property's value. */ + readonly value: string; + /** + * A divergence this change does not close, with the reason it is out of + * reach. Pinned as an exact pair so it cannot drift silently in either + * direction: a NEW divergence fails, and so does one that quietly goes away. + */ + readonly knownDivergence?: { + readonly folded: Record; + readonly unfolded: Record; + readonly because: string; + }; +} + +/** + * A named CSS colour reaches the second pass as a bare ident, because + * lightningcss only promotes a custom property's value to a colour node when it + * is not expressible as one. The folded path then parses it under the consuming + * property and canonicalises it; the unfolded path stores it under no property + * at all, so it cannot. Normalising the store instead would break the reverse + * case — `font-family: var(--v)` over `--v: red` renders "red" on BOTH paths + * today and would start rendering "#f00" on one of them. + */ +const namedColour = (folded: string, unfolded: string) => ({ + folded: { color: folded }, + unfolded: { color: unfolded }, + because: + "a named colour is an ident until a property gives it a type; the variable store has no property", +}); + +const TOKEN_CENSUS: Record = { + "ident": [ + { property: "position", value: "absolute" }, + { + property: "color", + value: "red", + knownDivergence: namedColour("#f00", "red"), + }, + { + property: "color", + value: "transparent", + knownDivergence: namedColour("#0000", "transparent"), + }, + { + property: "color", + value: "rebeccapurple", + knownDivergence: namedColour("#639", "rebeccapurple"), + }, + ], + "string": [{ property: "font-family", value: '"Inter"' }], + "number": [ + { property: "flex-grow", value: "2" }, + { property: "width", value: "0" }, + { property: "opacity", value: "0" }, + { property: "z-index", value: "3" }, + ], + "percentage": [{ property: "width", value: "50%" }], + "dimension": [ + { + property: "transition-duration", + value: "3s", + knownDivergence: { + folded: {}, + unfolded: { transitionDuration: 3000 }, + because: + "folding routes the duration to the animation system, which paints nothing; not folding leaks it into the static style. A transition gap, not a variable one", + }, + }, + ], + "delim": [ + { property: "aspect-ratio", value: "16 / 9" }, + // A square ratio has a second canonical form, and the property parser + // picks it. + { property: "aspect-ratio", value: "3 / 3" }, + ], + "hash": [{ property: "color", value: "#123456" }], + // Not reachable as the whole value of a custom property a style consumes. + "at-keyword": [], + "id-hash": [], + "unquoted-url": [], + "white-space": [], + "comment": [], + "colon": [], + "semicolon": [], + "comma": [], + "include-match": [], + "dash-match": [], + "prefix-match": [], + "suffix-match": [], + "substring-match": [], + "cdo": [], + "cdc": [], + "function": [], + "parenthesis-block": [], + "square-bracket-block": [], + "curly-bracket-block": [], + "bad-url": [], + "bad-string": [], + "close-parenthesis": [], + "close-square-bracket": [], + "close-curly-bracket": [], +}; + +const VALUE_CENSUS: Record = { + "token": Object.values(TOKEN_CENSUS).flat(), + "color": [ + { property: "color", value: "#12345678" }, + { property: "color", value: "rgba(255, 0, 0, 0.5)" }, + { property: "color", value: "oklch(63.7% 0.237 25.331)" }, + ], + "length": [ + { property: "width", value: "10px" }, + { property: "width", value: "0px" }, + { property: "margin-top", value: "-4px" }, + { property: "width", value: "1rem" }, + ], + "angle": [ + { + property: "rotate", + value: "45deg", + knownDivergence: { + folded: { transform: [{ rotateZ: "45deg" }] }, + unfolded: { transform: [{ rotate: "45deg" }] }, + because: + "`rotate` is renamed to `rotateZ` by the static parser and left alone by the runtime one; fold-independent, and closing it is a runtime shorthand change", + }, + }, + ], + "function": [ + { property: "width", value: "calc(10px + 2px)" }, + { property: "transform", value: "translateX(10px)" }, + ], + "var": [ + { + property: "color", + value: "var(--inner)", + knownDivergence: namedColour("#008080", "teal"), + }, + ], + // Both paths agree, and both are wrong about the red channel by a factor of + // 255. That is `parseUnresolvedColor`'s bug, not a fold one, and parity is + // all this census claims. + "unresolved-color": [ + { property: "color", value: "rgb(255 0 0 / var(--alpha))" }, + ], + // Not reachable, or not a style value React Native consumes. + "url": [], + "env": [], + "time": [], + "resolution": [], + "dashed-ident": [], + "animation-name": [], +}; + +/** + * Shorthands are a second fold-independent class: the static parser expands + * them into their longhands, the runtime one hands React Native the raw list. + * Same shape as the `rotate` entry above, kept apart because it is a family. + */ +const SHORTHANDS: readonly Shape[] = ( + [ + [ + "margin", + "1px 2px", + "marginTop", + "marginBottom", + "marginLeft", + "marginRight", + ], + [ + "padding", + "1px 2px", + "paddingTop", + "paddingBottom", + "paddingLeft", + "paddingRight", + ], + [ + "border-width", + "1px 2px", + "borderTopWidth", + "borderBottomWidth", + "borderLeftWidth", + "borderRightWidth", + ], + ] as const +).map(([property, value, top, bottom, left, right]) => ({ + property, + value, + knownDivergence: { + folded: { [top]: 1, [bottom]: 1, [left]: 2, [right]: 2 }, + unfolded: { [property.replace("-w", "W")]: [1, 2] }, + because: + "the static parser expands the shorthand, the runtime one does not; fold-independent, and closing it needs a runtime shorthand handler", + }, +})); + +const SHAPES: readonly Shape[] = [ + ...Object.values(VALUE_CENSUS).flat(), + ...SHORTHANDS, +]; + +function renderShape( + shape: Shape, + inlineVariables: boolean, +): Record { + // `--inner` and `--alpha` back the two shapes whose value is itself a + // reference; every other shape ignores them. + const css = `.a { --inner: teal; --alpha: 0.5; --v: ${shape.value}; ${shape.property}: var(--v); }`; + StyleCollection.styles.clear(); + registerCSS(css, inlineVariables ? {} : { inlineVariables: false }); + const { style } = render().getByTestId( + testID, + ).props as { style?: Record | Record[] }; + + return StyleSheet.flatten>(style ?? {}); +} + +test("the census covers something", () => { + // Deriving the table from a union buys a drift failure at the cost of a + // vacuity one: every list could be empty and every case below would vanish. + expect(SHAPES.length).toBeGreaterThan(20); +}); + +describe.each(SHAPES)("$property: var(--v) over $value", (shape) => { + test("the fold decision is unobservable", () => { + const folded = renderShape(shape, true); + const unfolded = renderShape(shape, false); + + if (shape.knownDivergence) { + expect(folded).toStrictEqual(shape.knownDivergence.folded); + expect(unfolded).toStrictEqual(shape.knownDivergence.unfolded); + return; + } + + // A shape that renders nothing on both paths agrees vacuously, which is + // exactly how a typo in the census would pass. + expect(folded).not.toStrictEqual({}); + expect(unfolded).toStrictEqual(folded); + }); +}); diff --git a/src/__tests__/native/variable-inlining.test.tsx b/src/__tests__/native/variable-inlining.test.tsx new file mode 100644 index 00000000..ac2fb49c --- /dev/null +++ b/src/__tests__/native/variable-inlining.test.tsx @@ -0,0 +1,158 @@ +import { act, render, screen } from "@testing-library/react-native"; +import { vars } from "react-native-css"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; + +import { dimensions } from "../../native/reactivity"; + +/** + * Every test names its own custom properties. `:root` variables live in a + * store the jest `beforeEach` does not reset, so a shared name would let one + * test decide another's result depending on the order they ran in. + * + * Native plane. `../compiler/inline-variables.test.ts` asserts the same folds + * against the IR; these assert what the runtime paints, because an IR that is + * scoped correctly and a screen that renders correctly are two claims. + */ + +test("a class-scoped variable does not leak into an unrelated rule", () => { + registerCSS(`.parent { --leak-a: 10px; } .child { width: var(--leak-a); }`); + + render(); + + // No `.parent` anywhere above it, so there is no `--leak-a` to read. + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); +}); + +test("a class-scoped variable still reaches a descendant", () => { + registerCSS(`.parent { --leak-b: 10px; } .child { width: var(--leak-b); }`); + + render( + + + , + ); + + // The declaration has to survive the fold for this to resolve at all. + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("a variable's own value does not leak across blocks", () => { + registerCSS( + `.a { --nest-a: var(--nest-b); width: var(--nest-a); } .b { --nest-b: 10px; }`, + ); + + render(); + + // `--nest-b` belongs to `.b`. An element carrying only `.a` never had it. + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); +}); + +test("a conditional :root does not fold into an unconditional rule", () => { + registerCSS(` + @media (min-width: 1000px) { :root { --cond-off: 10px; } } + .child { width: var(--cond-off); } + `); + + act(() => { + dimensions.set({ width: 500, height: 500, scale: 1, fontScale: 1 }); + }); + render(); + + // The declaration does not apply at this width, so neither does the value. + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); +}); + +test("a conditional :root still applies when its query matches", () => { + registerCSS(` + @media (min-width: 1000px) { :root { --cond-on: 10px; } } + .child { width: var(--cond-on); } + `); + + act(() => { + dimensions.set({ width: 1200, height: 500, scale: 1, fontScale: 1 }); + }); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("a property registered with inherits: false resolves at runtime", () => { + registerCSS(` + @property --registered { syntax: ""; inherits: false; initial-value: 10px; } + :root { --registered: 20px; } + .b { width: var(--registered); } + `); + + render(); + + // KNOWN LIMIT, pinned rather than claimed correct. CSS says `--registered` + // does not inherit, so `.b` holds the registered initial value 10, not `:root`'s + // 20. The compiler now refuses to fold it — the sibling compiler test pins + // that — but the runtime carries no notion of a non-inherited custom + // property, so it still resolves 20 from the root scope. Modelling + // `inherits: false` in the variable store is what closes this, and refusing + // the fold is the prerequisite rather than the fix. + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 20 }); +}); + +test("a runtime write reaches a rule that reads the variable", () => { + registerCSS(`.parent { --write-a: red; } .child { color: var(--write-a); }`); + + render( + + + , + ); + + // An inline write outranks a class declaration, and folding the class value + // into `.child` would have made that unrepresentable. + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "blue", + }); +}); + +test("a runtime write cannot reach a reference in the declaring block", () => { + registerCSS(`.a { --write-b: red; color: var(--write-b); }`); + + render( + , + ); + + // KNOWN LIMIT, pinned rather than claimed correct. CSS says the inline write + // wins and this should be blue. The value was folded at build time, so no + // read remains for the write to affect. Closing it means not folding a + // same-block reference either, which is the whole optimisation; `blue` + // is recoverable today with `inlineVariables: { exclude: [...] }`. + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#f00", + }); +}); + +test("excluding a variable restores the runtime write", () => { + registerCSS(`.a { --write-c: red; color: var(--write-c); }`, { + inlineVariables: { exclude: ["--write-c"] }, + }); + + render( + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "blue", + }); +}); diff --git a/src/__tests__/native/variables.test.tsx b/src/__tests__/native/variables.test.tsx index e61340b5..161602b3 100644 --- a/src/__tests__/native/variables.test.tsx +++ b/src/__tests__/native/variables.test.tsx @@ -171,8 +171,12 @@ test("can apply and set new variables", () => { expect(screen.getByTestId(testIDs.two).props.style).toStrictEqual({ color: "#f00", }); + // `--another-var` belongs to `.my-class`, so `.another-class` reaches it by + // inheritance at runtime rather than by a fold. A runtime value carries the + // token the author wrote: no property is in hand to canonicalise it, and + // React Native reads the name as the colour. expect(screen.getByTestId(testIDs.three).props.style).toStrictEqual({ - color: "#008000", + color: "green", }); }); @@ -198,8 +202,9 @@ test("variables will be inherited", () => { , ); + // Inherited from `.green` at runtime, which is the whole subject of the test. expect(screen.getByTestId(testIDs.three).props.style).toStrictEqual({ - color: "#008000", + color: "green", }); }); @@ -269,5 +274,7 @@ test("variable overriding with classes", () => { ); const component = screen.getByTestId(testID); - expect(component.props.style).toStrictEqual({ color: "#f00" }); + // `--tier-500` belongs to `.tier-red`; `.test` reads it by inheritance. The + // `:root` tier folds into `.tier-red`'s value, so what inherits is `red`. + expect(component.props.style).toStrictEqual({ color: "red" }); }); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..f0aa4d04 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -1078,6 +1078,38 @@ export function parseCustomDeclaration( } } +/** + * A `` written as three tokens, e.g. `16 / 9`. + * + * Only a two-term ratio is one: anything longer that happens to contain a `/` + * is not a ratio and has no canonical form to normalise it to. + */ +function isRatioGroup( + group: readonly unknown[], +): group is [number, "/", number] { + return ( + group.length === 3 && + typeof group[0] === "number" && + group[1] === "/" && + typeof group[2] === "number" + ); +} + +/** + * The same value `parseAspectRatio` produces for the same ratio. + * + * Whether a `` reaches the runtime through the property parser or + * through this one is decided by whether the compiler folded the variable + * holding it, and that decision must not be visible in the value. + */ +function ratioDescriptor([width, , height]: [ + number, + "/", + number, +]): StyleDescriptor { + return width === height ? 1 : `${width}/${height}`; +} + export function reduceParseUnparsed( tokenOrValues: TokenOrValue[], builder: StylesheetBuilder, @@ -1123,17 +1155,8 @@ export function reduceParseUnparsed( } else { return [first]; } - } else if ( - // This is a special case for values - group.includes("/") && - group.every((item) => - typeof item === "string" && item === "/" - ? item - : typeof item === "number", - ) - ) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - return [group.join(" ")]; + } else if (isRatioGroup(group)) { + return [ratioDescriptor(group)]; } else { return [group]; } @@ -1198,7 +1221,10 @@ export function parseUnparsed( property, allowAuto, ); - if (!args) return; + // `0` and `false` are values, not absences. Testing truthiness here dropped + // `--v: 0` on the floor, so a variable holding it reached the runtime + // undeclared and the declaration reading it rendered nothing at all. + if (args === undefined) return; if (Array.isArray(args) && args.length === 1) { return args[0]; } else if ( diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts index 609760c1..31575c7e 100644 --- a/src/compiler/inline-variables.ts +++ b/src/compiler/inline-variables.ts @@ -1,24 +1,50 @@ import type { Declaration, DeclarationBlock, + Rule, + Selector, StyleSheet, TokenOrValue, } from "lightningcss"; import type { UniqueVarInfo } from "./compiler.types"; +/** + * Folds a custom property that the stylesheet declares exactly once into the + * `var()` references that read it, so the runtime never has to resolve it. + * + * Folding a value into a rule ASSERTS that every element the rule matches holds + * that value. A single declaration is not enough to know that — a custom + * property is scoped to the elements its declaring rule matches, and a class + * selector cannot promise that a consuming rule matches the same element: + * + * .parent { --x: 10px } .child { width: var(--x) } + * + * An element carrying only `.child` has no `--x` at all, so `width: 10px` is + * wrong for it. `canFold` names the two cases where the assertion IS provable. + */ export function inlineVariables( stylesheet: StyleSheet, vars: Map, ) { + // A second declaration is a cascade this pass cannot resolve — which of the + // two wins depends on the element — so only a property declared exactly once + // is a candidate at all. Pruning happens BEFORE anything is flattened, + // because a value being flattened reads this same map: leaving a + // multi-declaration variable in it until its own turn came round made the + // fold depend on the order the properties happened to be written in. for (const [name, info] of [...vars]) { if (info.count !== 1) { vars.delete(name); - } else { - flattenVar(name, vars); } } + const scope = collectVariableScope(stylesheet, vars); + + for (const name of [...vars.keys()]) { + flattenVar(name, vars, scope); + } + stylesheet.rules = stylesheet.rules.map(function checkRule(rule) { switch (rule.type) { case "custom": @@ -48,7 +74,7 @@ export function inlineVariables( case "keyframes": rule.value.keyframes = rule.value.keyframes.map((keyframe) => { keyframe.declarations = - replaceDeclarationBlock(keyframe.declarations, vars) ?? + replaceDeclarationBlock(keyframe.declarations, vars, scope) ?? keyframe.declarations; return keyframe; @@ -58,6 +84,7 @@ export function inlineVariables( rule.value.declarations = replaceDeclarationBlock( rule.value.declarations, vars, + scope, ); rule.value.rules = rule.value.rules?.flatMap((rule) => checkRule(rule)); @@ -65,7 +92,7 @@ export function inlineVariables( return rule; case "nested-declarations": rule.value.declarations = - replaceDeclarationBlock(rule.value.declarations, vars) ?? {}; + replaceDeclarationBlock(rule.value.declarations, vars, scope) ?? {}; return rule; case "supports": rule.value.rules = rule.value.rules.flatMap((rule) => checkRule(rule)); @@ -82,21 +109,61 @@ export function inlineVariables( return stylesheet; } +/** + * Where each single-definition custom property is declared, and whether that + * place is one every element inherits from. + * + * The two halves answer different questions. `declaringBlocks` decides whether + * a reference sits in the same block as the declaration; `universalNames` + * decides whether the declaration reaches every element regardless. + */ +interface VariableScope { + readonly declaringBlocks: Map; + readonly universalNames: Set; +} + +/** + * Whether the value of `name` may be folded into a `var()` written in `block`. + * + * Two cases are provable, and either is enough: + * + * - the declaration is in a universal, unconditional scope, so every element + * holds the property whatever else it matches; or + * - the reference is in the block that declares it, so any element the rule + * matches holds the property by matching that rule. Whether the rule applies + * at all does not matter: a query that switches the declaration off switches + * the reference off with it. + * + * Neither holds across two rules, which is why `.a { --x: red }` with + * `.b { color: var(--x) }` is left to the runtime. + */ +function canFold( + name: string, + block: DeclarationBlock | undefined, + scope: VariableScope, +) { + return ( + scope.universalNames.has(name) || + (block !== undefined && scope.declaringBlocks.get(name) === block) + ); +} + function replaceDeclarationBlock( block: DeclarationBlock | undefined, vars: Map, + scope: VariableScope, ) { if (!block) return; block.declarations = block.declarations ?.map((decl) => { - return replaceDeclaration(decl, vars); + return replaceDeclaration(decl, vars, block, scope); }) .filter((d) => !!d); block.importantDeclarations = block.importantDeclarations ?.map((decl) => { - return replaceDeclaration(decl, vars); + return replaceDeclaration(decl, vars, block, scope); }) .filter((d) => !!d); @@ -106,6 +173,8 @@ function replaceDeclarationBlock( function replaceDeclaration( declaration: Declaration, vars: Map, + block: DeclarationBlock, + scope: VariableScope, ) { if ( declaration.property !== "unparsed" && @@ -114,12 +183,21 @@ function replaceDeclaration( return declaration; } - if (declaration.property === "custom" && vars.has(declaration.value.name)) { + // A universal declaration has been folded into every reference there is, so + // nothing is left to read it. A block-scoped one is KEPT: it was folded only + // into its own block, and a descendant still inherits it at runtime — + // including a descendant styled by a stylesheet compiled separately, which + // this pass cannot see and must not assume away. + if ( + declaration.property === "custom" && + vars.has(declaration.value.name) && + scope.universalNames.has(declaration.value.name) + ) { return; } declaration.value.value = declaration.value.value.flatMap((part) => { - return flattenPart(part, vars); + return flattenPart(part, vars, block, scope); }); return declaration; @@ -128,19 +206,22 @@ function replaceDeclaration( function flattenPart( part: TokenOrValue, vars: Map, + block: DeclarationBlock | undefined, + scope: VariableScope, ): TokenOrValue | TokenOrValue[] { if (part.type === "var") { - const varInfo = vars.get(part.value.name.ident); + const name = part.value.name.ident; + const varInfo = vars.get(name); - if (!varInfo) { + if (!varInfo || !canFold(name, block, scope)) { part.value.fallback = part.value.fallback?.flatMap((arg) => { - return flattenPart(arg, vars); + return flattenPart(arg, vars, block, scope); }); return part; } else if (varInfo.value === undefined) { const fallback = part.value.fallback?.flatMap((arg) => { - return flattenPart(arg, vars); + return flattenPart(arg, vars, block, scope); }); return fallback ?? []; @@ -149,7 +230,7 @@ function flattenPart( return varInfo.value; } else if (part.type === "function") { part.value.arguments = part.value.arguments.flatMap((arg) => { - return flattenPart(arg, vars); + return flattenPart(arg, vars, block, scope); }); } @@ -159,6 +240,7 @@ function flattenPart( function flattenVar( name: string, vars: Map, + scope: VariableScope, seen = new Set(), ) { if (seen.has(name)) { @@ -173,18 +255,27 @@ function flattenVar( return; } + // A value is flattened FOR the block that declares it, because that is the + // only block it is ever substituted into. A `var()` inside it is therefore + // held to the same terms as every other reference written in that block — + // otherwise `.a { --x: var(--y) }` quietly takes `.b`'s `--y` and carries it + // to elements that never matched `.b`. + const declaringBlock = scope.declaringBlocks.get(name); + let varInfoValue = varInfo.value?.flatMap((part) => { if (part.type === "var") { - const name = part.value.name.ident; + const nestedName = part.value.name.ident; - flattenVar(name, vars, seen); + flattenVar(nestedName, vars, scope, seen); - const nestedVarInfo = vars.get(part.value.name.ident); - if (nestedVarInfo?.value) { - return nestedVarInfo.value; + if (canFold(nestedName, declaringBlock, scope)) { + const nestedVarInfo = vars.get(nestedName); + if (nestedVarInfo?.value) { + return nestedVarInfo.value; + } } } - return flattenPart(part, vars); + return flattenPart(part, vars, declaringBlock, scope); }); // If the variable is shorthand for "initial", substitute it for undefined @@ -207,3 +298,160 @@ function flattenVar( vars.set(name, varInfo); } + +/** Where a declaration block sits, as the annotating walk descends. */ +interface RuleScope { + /** Every element matches the enclosing selector. */ + readonly universal: boolean; + /** Enclosed by a query whose result this pass does not know. */ + readonly conditional: boolean; +} + +const ROOT_SCOPE: RuleScope = { universal: false, conditional: false }; + +function collectVariableScope( + stylesheet: StyleSheet, + vars: Map, +): VariableScope { + const scope: VariableScope = { + declaringBlocks: new Map(), + universalNames: new Set(), + }; + + // A property registered with `inherits: false` is NOT inherited, so a + // universal declaration of it reaches only the element it is written on — + // every descendant sees the registered initial value instead. Recording it + // before the walk keeps `:root { --x: 20px }` from being read as universal. + const notInherited = new Set(); + // Top level only, which is the whole of the compiler's `@property` support: + // an `@property` nested in an at-rule registers no initial value either, so + // there is nothing there for this to disagree with. + for (const rule of stylesheet.rules) { + if (rule.type === "property" && !rule.value.inherits) { + notInherited.add(rule.value.name); + } + } + + const annotateBlock = ( + block: DeclarationBlock | undefined, + ruleScope: RuleScope, + ) => { + if (!block) return; + + const universal = ruleScope.universal && !ruleScope.conditional; + + for (const declaration of [ + ...(block.declarations ?? []), + ...(block.importantDeclarations ?? []), + ]) { + if (declaration.property !== "custom") continue; + + const { name } = declaration.value; + if (!vars.has(name)) continue; + + scope.declaringBlocks.set(name, block); + if (universal && !notInherited.has(name)) { + scope.universalNames.add(name); + } + } + }; + + const annotateRule = (rule: Rule, ruleScope: RuleScope): void => { + switch (rule.type) { + case "style": { + const nested: RuleScope = { + ...ruleScope, + universal: isUniversalScope(rule.value.selectors, ruleScope), + }; + annotateBlock(rule.value.declarations, nested); + for (const child of rule.value.rules ?? []) { + annotateRule(child, nested); + } + return; + } + case "nested-declarations": + // The enclosing style rule's own declarations, so they carry its scope. + annotateBlock(rule.value.declarations, ruleScope); + return; + case "keyframes": + // `@keyframes` is only ever reached at the top level or inside an + // at-rule, never inside a style rule, so the scope it carries is + // already non-universal. A keyframe declares animation values rather + // than a scope another rule can rely on, but its own block still folds + // into itself. + for (const keyframe of rule.value.keyframes) { + annotateBlock(keyframe.declarations, ruleScope); + } + return; + case "media": + case "supports": + case "container": + // Whether these rules apply at all is decided elsewhere — at runtime + // for a media or container query, at build time for `@supports` — so a + // declaration inside one is never unconditional, however universal its + // selector. + for (const child of rule.value.rules) { + annotateRule(child, { + universal: false, + conditional: true, + }); + } + return; + case "layer-block": + for (const child of rule.value.rules) { + annotateRule(child, ruleScope); + } + return; + default: + return; + } + }; + + for (const rule of stylesheet.rules) annotateRule(rule, ROOT_SCOPE); + + return scope; +} + +/** + * Whether a selector list names a scope every element inherits from. + * + * ONE such selector is enough, so the list is tested with `some`: `:root, :host` + * — the shape Tailwind emits for its theme — is universal because `:root` is, + * whatever `:host` matches. + * + * A nested rule is universal only if both it and the rule it is nested in are, + * because its selectors are relative to that parent. A nested selector that is + * nothing but `&` adds no constraint of its own and takes the parent's answer. + */ +function isUniversalScope(selectors: Selector[], ruleScope: RuleScope) { + return selectors.some((selector) => { + const meaningful = selector.filter( + (component) => component.type !== "nesting", + ); + + if (meaningful.length === 0) { + return ruleScope.universal; + } + + if (meaningful.length !== 1) { + // Anything compound or combined is narrower than the whole document: + // `:root .theme` matches only descendants of an element with that class. + return false; + } + + const [component] = meaningful; + + switch (component?.type) { + case "universal": + return true; + case "type": + // Every element descends from `html`. No other element name can be + // relied on, and none of them compile to a rule here anyway. + return component.name === "html"; + case "pseudo-class": + return component.kind === "root" || component.kind === "host"; + default: + return false; + } + }); +} From f9cd9ddc3a2500c813b20ac110d0bc7b20eb9c1b Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 16:17:42 +0300 Subject: [PATCH 2/5] refactor(compiler): state the inliner's scope rules in one line each --- src/compiler/declarations.ts | 18 +----- src/compiler/inline-variables.ts | 96 ++++---------------------------- 2 files changed, 13 insertions(+), 101 deletions(-) diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index f0aa4d04..67c0a30a 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -1078,12 +1078,6 @@ export function parseCustomDeclaration( } } -/** - * A `` written as three tokens, e.g. `16 / 9`. - * - * Only a two-term ratio is one: anything longer that happens to contain a `/` - * is not a ratio and has no canonical form to normalise it to. - */ function isRatioGroup( group: readonly unknown[], ): group is [number, "/", number] { @@ -1095,13 +1089,7 @@ function isRatioGroup( ); } -/** - * The same value `parseAspectRatio` produces for the same ratio. - * - * Whether a `` reaches the runtime through the property parser or - * through this one is decided by whether the compiler folded the variable - * holding it, and that decision must not be visible in the value. - */ +// The value parseAspectRatio produces, so folding a variable that holds a ratio is invisible function ratioDescriptor([width, , height]: [ number, "/", @@ -1221,9 +1209,7 @@ export function parseUnparsed( property, allowAuto, ); - // `0` and `false` are values, not absences. Testing truthiness here dropped - // `--v: 0` on the floor, so a variable holding it reached the runtime - // undeclared and the declaration reading it rendered nothing at all. + // `0` and `false` are values, not absences if (args === undefined) return; if (Array.isArray(args) && args.length === 1) { return args[0]; diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts index 31575c7e..d79a469d 100644 --- a/src/compiler/inline-variables.ts +++ b/src/compiler/inline-variables.ts @@ -9,30 +9,12 @@ import type { import type { UniqueVarInfo } from "./compiler.types"; -/** - * Folds a custom property that the stylesheet declares exactly once into the - * `var()` references that read it, so the runtime never has to resolve it. - * - * Folding a value into a rule ASSERTS that every element the rule matches holds - * that value. A single declaration is not enough to know that — a custom - * property is scoped to the elements its declaring rule matches, and a class - * selector cannot promise that a consuming rule matches the same element: - * - * .parent { --x: 10px } .child { width: var(--x) } - * - * An element carrying only `.child` has no `--x` at all, so `width: 10px` is - * wrong for it. `canFold` names the two cases where the assertion IS provable. - */ +// A custom property is scoped to the elements its rule matches (css-variables-1 §2), so a value folds only where every matching element provably holds it export function inlineVariables( stylesheet: StyleSheet, vars: Map, ) { - // A second declaration is a cascade this pass cannot resolve — which of the - // two wins depends on the element — so only a property declared exactly once - // is a candidate at all. Pruning happens BEFORE anything is flattened, - // because a value being flattened reads this same map: leaving a - // multi-declaration variable in it until its own turn came round made the - // fold depend on the order the properties happened to be written in. + // Which of two declarations wins depends on the element, and pruning before flattening keeps the fold independent of declaration order for (const [name, info] of [...vars]) { if (info.count !== 1) { vars.delete(name); @@ -109,34 +91,12 @@ export function inlineVariables( return stylesheet; } -/** - * Where each single-definition custom property is declared, and whether that - * place is one every element inherits from. - * - * The two halves answer different questions. `declaringBlocks` decides whether - * a reference sits in the same block as the declaration; `universalNames` - * decides whether the declaration reaches every element regardless. - */ interface VariableScope { readonly declaringBlocks: Map; readonly universalNames: Set; } -/** - * Whether the value of `name` may be folded into a `var()` written in `block`. - * - * Two cases are provable, and either is enough: - * - * - the declaration is in a universal, unconditional scope, so every element - * holds the property whatever else it matches; or - * - the reference is in the block that declares it, so any element the rule - * matches holds the property by matching that rule. Whether the rule applies - * at all does not matter: a query that switches the declaration off switches - * the reference off with it. - * - * Neither holds across two rules, which is why `.a { --x: red }` with - * `.b { color: var(--x) }` is left to the runtime. - */ +// Every matching element holds the property when its declaration is universal and unconditional, or when the reference shares its block function canFold( name: string, block: DeclarationBlock | undefined, @@ -183,11 +143,7 @@ function replaceDeclaration( return declaration; } - // A universal declaration has been folded into every reference there is, so - // nothing is left to read it. A block-scoped one is KEPT: it was folded only - // into its own block, and a descendant still inherits it at runtime — - // including a descendant styled by a stylesheet compiled separately, which - // this pass cannot see and must not assume away. + // A block-scoped declaration stays for descendants, including ones a separately compiled stylesheet styles if ( declaration.property === "custom" && vars.has(declaration.value.name) && @@ -255,11 +211,7 @@ function flattenVar( return; } - // A value is flattened FOR the block that declares it, because that is the - // only block it is ever substituted into. A `var()` inside it is therefore - // held to the same terms as every other reference written in that block — - // otherwise `.a { --x: var(--y) }` quietly takes `.b`'s `--y` and carries it - // to elements that never matched `.b`. + // A value is substituted only into its declaring block, so a var() inside it folds on that block's terms const declaringBlock = scope.declaringBlocks.get(name); let varInfoValue = varInfo.value?.flatMap((part) => { @@ -299,7 +251,6 @@ function flattenVar( vars.set(name, varInfo); } -/** Where a declaration block sits, as the annotating walk descends. */ interface RuleScope { /** Every element matches the enclosing selector. */ readonly universal: boolean; @@ -318,14 +269,8 @@ function collectVariableScope( universalNames: new Set(), }; - // A property registered with `inherits: false` is NOT inherited, so a - // universal declaration of it reaches only the element it is written on — - // every descendant sees the registered initial value instead. Recording it - // before the walk keeps `:root { --x: 20px }` from being read as universal. + // A property registered with inherits: false reaches only the element that declares it (css-properties-values-api-1) const notInherited = new Set(); - // Top level only, which is the whole of the compiler's `@property` support: - // an `@property` nested in an at-rule registers no initial value either, so - // there is nothing there for this to disagree with. for (const rule of stylesheet.rules) { if (rule.type === "property" && !rule.value.inherits) { notInherited.add(rule.value.name); @@ -374,11 +319,7 @@ function collectVariableScope( annotateBlock(rule.value.declarations, ruleScope); return; case "keyframes": - // `@keyframes` is only ever reached at the top level or inside an - // at-rule, never inside a style rule, so the scope it carries is - // already non-universal. A keyframe declares animation values rather - // than a scope another rule can rely on, but its own block still folds - // into itself. + // A keyframe's block folds only into itself; @keyframes never sits inside a style rule for (const keyframe of rule.value.keyframes) { annotateBlock(keyframe.declarations, ruleScope); } @@ -386,10 +327,7 @@ function collectVariableScope( case "media": case "supports": case "container": - // Whether these rules apply at all is decided elsewhere — at runtime - // for a media or container query, at build time for `@supports` — so a - // declaration inside one is never unconditional, however universal its - // selector. + // A query decides elsewhere whether these rules apply, so nothing inside one is unconditional for (const child of rule.value.rules) { annotateRule(child, { universal: false, @@ -412,17 +350,7 @@ function collectVariableScope( return scope; } -/** - * Whether a selector list names a scope every element inherits from. - * - * ONE such selector is enough, so the list is tested with `some`: `:root, :host` - * — the shape Tailwind emits for its theme — is universal because `:root` is, - * whatever `:host` matches. - * - * A nested rule is universal only if both it and the rule it is nested in are, - * because its selectors are relative to that parent. A nested selector that is - * nothing but `&` adds no constraint of its own and takes the parent's answer. - */ +// One universal selector is enough (Tailwind emits `:root, :host`), and a nested rule is universal only where its parent is function isUniversalScope(selectors: Selector[], ruleScope: RuleScope) { return selectors.some((selector) => { const meaningful = selector.filter( @@ -434,8 +362,7 @@ function isUniversalScope(selectors: Selector[], ruleScope: RuleScope) { } if (meaningful.length !== 1) { - // Anything compound or combined is narrower than the whole document: - // `:root .theme` matches only descendants of an element with that class. + // A compound or combined selector, such as `:root .theme`, matches less than the whole document return false; } @@ -445,8 +372,7 @@ function isUniversalScope(selectors: Selector[], ruleScope: RuleScope) { case "universal": return true; case "type": - // Every element descends from `html`. No other element name can be - // relied on, and none of them compile to a rule here anyway. + // Every element descends from html, and no other element name can be relied on return component.name === "html"; case "pseudo-class": return component.kind === "root" || component.kind === "host"; From cb1a5fb0f9afc71a4f64f2ad3ce6a8552d27d01b Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 16:55:29 +0300 Subject: [PATCH 3/5] fix(compiler): fold and remove variables by what the runtime resolves The inliner counted declarations with a visitor that cannot see the rule around them, so a declaration inside an @supports block the compiler later drops still counted, and was the value that folded. The inliner now takes its census from its own walk of the rules the compiler keeps: - an @supports block is decided with supportsConditionValid, as the compiler decides it, so a dropped block declares nothing - `*` declares on every element, so `inherits: false` does not narrow it - a registered `*` property that nothing declares folds to its initial value - a declaration is removed once nothing in the stylesheet still reads it, so a reference the fold cannot reach keeps its declaration A folded value reaches the compile-time parsers, so a transparent shadow is dropped there too, as the runtime already drops one it reads from a variable. --- .../compiler/inline-variables.test.ts | 249 +++++++--- src/__tests__/native/box-shadow.test.tsx | 42 +- .../native/variable-fold-parity.test.tsx | 127 +++-- .../native/variable-inlining.test.tsx | 53 ++- .../vendor/tailwind/transform.test.ts | 4 +- src/compiler/compiler.ts | 22 +- src/compiler/compiler.types.ts | 1 - src/compiler/declarations.ts | 19 +- src/compiler/inline-variables.ts | 438 +++++++++++------- src/native/styles/shorthands/box-shadow.ts | 7 +- src/utilities/index.ts | 1 + src/utilities/transparent-color.ts | 4 + 12 files changed, 637 insertions(+), 330 deletions(-) create mode 100644 src/utilities/transparent-color.ts diff --git a/src/__tests__/compiler/inline-variables.test.ts b/src/__tests__/compiler/inline-variables.test.ts index 341f3102..537694c8 100644 --- a/src/__tests__/compiler/inline-variables.test.ts +++ b/src/__tests__/compiler/inline-variables.test.ts @@ -1,19 +1,10 @@ import type { ReactNativeCssStyleSheet, + StyleDescriptor, StyleRule, } from "react-native-css/compiler"; import { compile } from "react-native-css/compiler"; -/** - * Folding a single-definition custom property into its use sites asserts that - * every element the consuming rule matches holds that value. These tests pin - * where that assertion is provable and where it is not. - * - * Compiler plane. `../native/variable-inlining.test.tsx` renders the same - * stylesheets, because a fold is a compiler decision whose only observable is - * what the runtime paints. - */ - function rulesFor( sheet: ReactNativeCssStyleSheet, className: string, @@ -21,7 +12,6 @@ function rulesFor( return sheet.s?.find(([name]) => name === className)?.[1] ?? []; } -/** The value folded into `property`, or undefined if nothing was folded. */ function foldedValue( sheet: ReactNativeCssStyleSheet, className: string, @@ -37,12 +27,25 @@ function foldedValue( return undefined; } -/** Whether the class resolves at least one `var()` at runtime. */ +function runtimeValue( + sheet: ReactNativeCssStyleSheet, + className: string, + property: string, +): StyleDescriptor { + for (const rule of rulesFor(sheet, className)) { + for (const declaration of rule.d ?? []) { + if (Array.isArray(declaration) && declaration[1] === property) { + return declaration[0]; + } + } + } + return undefined; +} + function readsAtRuntime(sheet: ReactNativeCssStyleSheet, className: string) { return rulesFor(sheet, className).some((rule) => rule.dv === 1); } -/** The value the class declares for `--`, or undefined if it declares none. */ function declaredValue( sheet: ReactNativeCssStyleSheet, className: string, @@ -62,11 +65,9 @@ describe("a variable is folded only where its value is provable", () => { `.parent { --x: 10px; } .child { width: var(--x); }`, ).stylesheet(); - // An element carrying only `.child` never matched `.parent`, so it has no - // `--x` and `width: 10` would be an invention. + // An element carrying only `.child` never matched `.parent`, so it has no `--x` expect(foldedValue(sheet, "child", "width")).toBeUndefined(); expect(readsAtRuntime(sheet, "child")).toBe(true); - // ...and the declaration has to survive for the runtime to find it. expect(declaredValue(sheet, "parent", "x")).toBe(10); }); @@ -77,15 +78,6 @@ describe("a variable is folded only where its value is provable", () => { expect(readsAtRuntime(sheet, "a")).toBe(false); }); - test("a block-scoped declaration survives its own fold", () => { - // The fold reached this block's references and no others. A descendant - // inherits `--x` at runtime — including one styled by a stylesheet compiled - // separately, which this pass cannot see and must not assume away. - const sheet = compile(`.a { --x: 10px; width: var(--x); }`).stylesheet(); - - expect(declaredValue(sheet, "a", "x")).toBe(10); - }); - test.each([":root", ":host", "*", "html"])( "%s is universal, so its variables fold anywhere", (selector) => { @@ -102,7 +94,7 @@ describe("a variable is folded only where its value is provable", () => { ["div", "every element descends from html, from no other element name"], [":hover", "a state is not a scope"], [".theme", "a class is the case this whole rule exists for"], - ])("%s is not universal — %s", (selector) => { + ])("%s is not universal: %s", (selector) => { const sheet = compile( `${selector} { --x: 10px; } .child { width: var(--x); }`, ).stylesheet(); @@ -115,8 +107,6 @@ describe("a variable is folded only where its value is provable", () => { [":root", 10], [".theme", undefined], ])("a nested `&` under %s takes its parent's answer", (parent, expected) => { - // `&` adds no constraint of its own, so the scope is whatever the rule it - // is nested in already was. const sheet = compile( `${parent} { & { --x: 10px; } } .child { width: var(--x); }`, ).stylesheet(); @@ -124,8 +114,15 @@ describe("a variable is folded only where its value is provable", () => { expect(foldedValue(sheet, "child", "width")).toBe(expected); }); + test("a nested selector under :root names its descendants, not every element", () => { + const sheet = compile( + `:root { * { --x: 10px; } } .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBeUndefined(); + }); + test("@layer does not narrow a universal scope", () => { - // The shape Tailwind emits for its theme. const sheet = compile( `@layer theme { :root, :host { --x: 10px; } } .child { width: var(--x); }`, ).stylesheet(); @@ -134,25 +131,88 @@ describe("a variable is folded only where its value is provable", () => { }); }); +describe("a folded declaration is removed once nothing reads it", () => { + test("a declaration whose every reference folded is removed", () => { + const sheet = compile(`.a { --x: 10px; width: var(--x); }`).stylesheet(); + + expect(foldedValue(sheet, "a", "width")).toBe(10); + expect(declaredValue(sheet, "a", "x")).toBeUndefined(); + }); + + test("a declaration another rule reads at runtime is kept", () => { + const sheet = compile( + `.a { --x: 10px; width: var(--x); } .b { height: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "a", "width")).toBe(10); + expect(readsAtRuntime(sheet, "b")).toBe(true); + expect(declaredValue(sheet, "a", "x")).toBe(10); + }); + + test.each([ + ["an rgb() alpha", `.a { --c: 0.5; color: rgb(255 0 0 / var(--c)); }`], + [ + "a light-dark() branch", + `.a { --c: 0.5; color: light-dark(rgb(255 0 0 / var(--c)), #00f); }`, + ], + ])("a reference inside %s keeps its declaration", (_name, css) => { + const sheet = compile(css).stylesheet(); + + expect(declaredValue(sheet, "a", "c")).toBe(0.5); + }); + + test("a variable declared initial folds to the reference's fallback", () => { + const sheet = compile( + `.a { --x: initial; width: var(--x, 10px); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "a", "width")).toBe(10); + }); + + test("every declaration of a fully folded chain is removed", () => { + const sheet = compile( + `.a { --y: 10px; --x: var(--y); width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "a", "width")).toBe(10); + expect(declaredValue(sheet, "a", "x")).toBeUndefined(); + expect(declaredValue(sheet, "a", "y")).toBeUndefined(); + }); + + test("a declaration read only by a removed declaration is removed", () => { + const sheet = compile( + `.a { --x: var(--y); height: 1px; } .b { --y: 10px; height: 2px; }`, + ).stylesheet(); + + expect(declaredValue(sheet, "a", "x")).toBeUndefined(); + expect(declaredValue(sheet, "b", "y")).toBeUndefined(); + }); + + test("a declaration read by a kept declaration is kept", () => { + const sheet = compile( + `.a { --x: var(--y); height: 1px; } .b { --y: 10px; height: 2px; } .c { width: var(--x); }`, + ).stylesheet(); + + expect(declaredValue(sheet, "a", "x")).toStrictEqual([{}, "var", "y", 1]); + expect(declaredValue(sheet, "b", "y")).toBe(10); + }); +}); + describe("a conditional scope is not a universal one", () => { test.each([ ["@media (min-width: 1px)", "media"], - ["@supports (display: flex)", "supports"], ["@container (min-width: 1px)", "container"], ])("%s wrapping :root", (atRule) => { const sheet = compile( `${atRule} { :root { --x: 10px; } } .child { width: var(--x); }`, ).stylesheet(); - // Whether the declaration applies is decided somewhere this pass cannot - // see, so `.child` cannot be told it holds the value. expect(foldedValue(sheet, "child", "width")).toBeUndefined(); expect(readsAtRuntime(sheet, "child")).toBe(true); }); test("a conditional block still folds into itself", () => { - // A query that switches the declaration off switches the reference off with - // it, so the two cannot disagree. + // A query that switches the declaration off switches the reference off with it const sheet = compile( `@media (min-width: 1px) { .a { --x: 10px; width: var(--x); } }`, ).stylesheet(); @@ -161,10 +221,39 @@ describe("a conditional scope is not a universal one", () => { }); }); -describe("a registered property is a second definition", () => { - test("inherits: false makes a universal declaration non-universal", () => { - // `--x` does not inherit, so an element that is not the root holds the - // REGISTERED initial value, not the one `:root` declares. +describe("@supports is decided when the stylesheet compiles", () => { + test("a kept block's declarations apply unconditionally", () => { + const sheet = compile( + `@supports (color: color-mix(in lab, red, red)) { :root { --x: 10px; } } + .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBe(10); + }); + + test("a dropped block's declarations do not apply", () => { + const sheet = compile( + `@supports (display: grid) { :root { --x: 10px; } } + .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBeUndefined(); + }); + + test("a dropped block's declaration is not a second definition", () => { + const sheet = compile( + `@supports (display: grid) { :root { --x: 20px; } } + :root { --x: 10px; } + .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBe(10); + }); +}); + +describe("a registered property", () => { + test("inherits: false keeps a :root declaration from reaching other elements", () => { + // An element other than the root holds the registered initial value, not the one `:root` declares const sheet = compile( `@property --x { syntax: ""; inherits: false; initial-value: 10px; } :root { --x: 20px; } @@ -184,6 +273,59 @@ describe("a registered property is a second definition", () => { expect(foldedValue(sheet, "b", "width")).toBe(20); }); + + test("inherits: false does not narrow a declaration on every element", () => { + const sheet = compile( + `@property --x { syntax: ""; inherits: false; initial-value: 10px; } + * { --x: 20px; } + .b { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "b", "width")).toBe(20); + }); + + test("an undeclared property folds to its initial value", () => { + const sheet = compile( + `@property --x { syntax: "*"; inherits: false; initial-value: 10px; } + .b { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "b", "width")).toBe(10); + }); + + test("the initial value wins over a fallback block the compiler drops", () => { + // Tailwind's shape: the fallback serves browsers without @property, and the compiler drops it + const sheet = compile( + `@property --x { syntax: "*"; inherits: false; initial-value: 10px; } + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) { + *, ::before, ::after, ::backdrop { --x: 20px; } + } + .b { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "b", "width")).toBe(10); + }); + + test("a typed initial value is left to the runtime", () => { + const sheet = compile( + `@property --x { syntax: ""; inherits: false; initial-value: 10px; } + .b { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "b", "width")).toBeUndefined(); + expect(readsAtRuntime(sheet, "b")).toBe(true); + }); + + test("a declared property does not fold its initial value across rules", () => { + const sheet = compile( + `@property --x { syntax: "*"; inherits: false; initial-value: 10px; } + .a { --x: 20px; } + .b { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "b", "width")).toBeUndefined(); + expect(readsAtRuntime(sheet, "b")).toBe(true); + }); }); describe("a variable's own value is held to the same terms", () => { @@ -192,12 +334,15 @@ describe("a variable's own value is held to the same terms", () => { `.a { --x: var(--y); width: var(--x); } .b { --y: 10px; }`, ).stylesheet(); - // `--y` belongs to `.b`. Folding it into `.a`'s value carries it to every - // element matching `.a`, none of which need ever have matched `.b` — and - // `.a`'s own `width` is where that lands, because `--x` IS foldable there. + // `--y` belongs to `.b`, and folding it into `.a` would carry it to elements that never matched `.b` expect(foldedValue(sheet, "a", "width")).toBeUndefined(); - expect(readsAtRuntime(sheet, "a")).toBe(true); - expect(declaredValue(sheet, "a", "x")).toStrictEqual([{}, "var", "y", 1]); + expect(runtimeValue(sheet, "a", "width")).toStrictEqual([ + {}, + "var", + "y", + 1, + ]); + expect(declaredValue(sheet, "b", "y")).toBe(10); }); test("a nested reference in the same block folds", () => { @@ -205,13 +350,12 @@ describe("a variable's own value is held to the same terms", () => { `.a { --y: 10px; --x: var(--y); width: var(--x); }`, ).stylesheet(); - expect(declaredValue(sheet, "a", "x")).toBe(10); expect(foldedValue(sheet, "a", "width")).toBe(10); }); - test("a universal nested reference folds anywhere", () => { + test("a universal nested reference folds into a kept declaration", () => { const sheet = compile( - `:root { --y: 10px; } .a { --x: var(--y); }`, + `:root { --y: 10px; } .a { --x: var(--y); } .b { width: var(--x); }`, ).stylesheet(); expect(declaredValue(sheet, "a", "x")).toBe(10); @@ -219,10 +363,7 @@ describe("a variable's own value is held to the same terms", () => { }); test("the fold does not depend on the order the rules are written in", () => { - // `--y` has two declarations, so it is not a candidate at all, and `2px` is - // the one that wins. Pruning a candidate only when its own turn came round - // made that depend on whether `--x` was written above the declarations or - // below them — and above them, the LOSING `1px` was folded in. + // `--y` is declared twice, so it is not a candidate, and the winning `2px` is left to the runtime const declarations = `:root { --y: 1px; } :root { --y: 2px; }`; const reference = `.a { --x: var(--y); width: var(--x); }`; @@ -231,7 +372,11 @@ test("the fold does not depend on the order the rules are written in", () => { for (const sheet of [varFirst, varLast]) { expect(foldedValue(sheet, "a", "width")).toBeUndefined(); - expect(readsAtRuntime(sheet, "a")).toBe(true); - expect(declaredValue(sheet, "a", "x")).toStrictEqual([{}, "var", "y", 1]); + expect(runtimeValue(sheet, "a", "width")).toStrictEqual([ + {}, + "var", + "y", + 1, + ]); } }); diff --git a/src/__tests__/native/box-shadow.test.tsx b/src/__tests__/native/box-shadow.test.tsx index 85a809a5..b0479b58 100644 --- a/src/__tests__/native/box-shadow.test.tsx +++ b/src/__tests__/native/box-shadow.test.tsx @@ -383,10 +383,7 @@ describe("shadow values from CSS variables", () => { }); }); - // Compile-time path: lightningcss inlines the variable and processes the - // value through its CSS parser. The shorthand handler converts to native - // format without filtering — transparent shadows pass through as-is. - test("transparent shadow is preserved", () => { + test("a transparent shadow is dropped when its variable folds, as when it does not", () => { registerCSS(` :root { --my-shadow: 0 0 0 0 #0000; @@ -397,18 +394,38 @@ describe("shadow values from CSS variables", () => { render(); const component = screen.getByTestId(testID); - expect(component.props.style).toStrictEqual({ + expect(component.props.style).toStrictEqual({ boxShadow: [] }); + }); + + test("only the transparent shadows of a list are dropped", () => { + registerCSS(`.test { box-shadow: 0 0 #0000, 0 4px 6px -1px #0000001a; }`); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ boxShadow: [ { offsetX: 0, - offsetY: 0, - blurRadius: 0, - spreadDistance: 0, - color: "#0000", + offsetY: 4, + blurRadius: 6, + spreadDistance: -1, + color: "#0000001a", }, ], }); }); + + test("a list of transparent shadows still replaces an earlier one", () => { + registerCSS( + `.shadow { box-shadow: 1px 1px #f00; } .test { box-shadow: 0 0 transparent; }`, + ); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + boxShadow: [], + }); + }); }); describe("shadow values from runtime variables", () => { @@ -526,12 +543,7 @@ describe("shadow values from runtime variables", () => { ]); }); - // Runtime path: variables resolve at runtime, so the shorthand handler - // processes the raw values. Transparent shadows (e.g., Tailwind v4's - // @property --tw-inset-shadow { initial-value: 0 0 0 0 #0000 }) are - // filtered out to avoid creating invisible native shadow objects that - // the renderer would still process. This is intentionally different - // from the compile-time path which preserves them. + // A transparent shadow paints nothing, so neither path hands React Native one to process test("transparent shadow is filtered", () => { registerCSS( `.test { diff --git a/src/__tests__/native/variable-fold-parity.test.tsx b/src/__tests__/native/variable-fold-parity.test.tsx index 14ae55fc..b6232711 100644 --- a/src/__tests__/native/variable-fold-parity.test.tsx +++ b/src/__tests__/native/variable-fold-parity.test.tsx @@ -6,30 +6,13 @@ import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; import { StyleCollection } from "react-native-css/native"; -/** - * Whether the compiler folds a single-definition custom property into its use - * sites is an OPTIMISATION. It must not be observable: the same stylesheet - * compiled with the fold on and with the fold off has to render the same style. - * - * The census below is exhaustive over the value kinds lightningcss can put - * inside a custom property, by construction — both records are keyed by the - * lightningcss union itself, so a new member fails to compile until it is - * censused here. An empty list is a kind that cannot be the whole value of a - * custom property a React Native style consumes, and says why. - */ +// Keyed by lightningcss's own unions, so a value kind it adds fails to compile until it is listed here type ValueKind = TokenOrValue["type"]; type RawTokenKind = Extract["value"]["type"]; interface Shape { - /** The property that reads the variable. */ readonly property: string; - /** The custom property's value. */ readonly value: string; - /** - * A divergence this change does not close, with the reason it is out of - * reach. Pinned as an exact pair so it cannot drift silently in either - * direction: a NEW divergence fails, and so does one that quietly goes away. - */ readonly knownDivergence?: { readonly folded: Record; readonly unfolded: Record; @@ -37,15 +20,7 @@ interface Shape { }; } -/** - * A named CSS colour reaches the second pass as a bare ident, because - * lightningcss only promotes a custom property's value to a colour node when it - * is not expressible as one. The folded path then parses it under the consuming - * property and canonicalises it; the unfolded path stores it under no property - * at all, so it cannot. Normalising the store instead would break the reverse - * case — `font-family: var(--v)` over `--v: red` renders "red" on BOTH paths - * today and would start rendering "#f00" on one of them. - */ +// Normalising the variable store instead would turn `font-family: var(--v)` over `--v: red` into "#f00" const namedColour = (folded: string, unfolded: string) => ({ folded: { color: folded }, unfolded: { color: unfolded }, @@ -79,7 +54,19 @@ const TOKEN_CENSUS: Record = { { property: "opacity", value: "0" }, { property: "z-index", value: "3" }, ], - "percentage": [{ property: "width", value: "50%" }], + "percentage": [ + { property: "width", value: "50%" }, + { + property: "scale", + value: "50%", + knownDivergence: { + folded: { transform: [{ scaleX: 0.5 }, { scaleY: 0.5 }] }, + unfolded: { transform: [{ scale: 0.5 }] }, + because: + "the static parser writes a uniform scale as both axes and the runtime one as `scale`; fold-independent, and closing it is a runtime transform change", + }, + }, + ], "dimension": [ { property: "transition-duration", @@ -94,8 +81,7 @@ const TOKEN_CENSUS: Record = { ], "delim": [ { property: "aspect-ratio", value: "16 / 9" }, - // A square ratio has a second canonical form, and the property parser - // picks it. + // The property parser writes a square ratio as 1 { property: "aspect-ratio", value: "3 / 3" }, ], "hash": [{ property: "color", value: "#123456" }], @@ -162,9 +148,7 @@ const VALUE_CENSUS: Record = { knownDivergence: namedColour("#008080", "teal"), }, ], - // Both paths agree, and both are wrong about the red channel by a factor of - // 255. That is `parseUnresolvedColor`'s bug, not a fold one, and parity is - // all this census claims. + // Both paths share parseUnresolvedColor's channel scale, so only their agreement is claimed here "unresolved-color": [ { property: "color", value: "rgb(255 0 0 / var(--alpha))" }, ], @@ -177,11 +161,7 @@ const VALUE_CENSUS: Record = { "animation-name": [], }; -/** - * Shorthands are a second fold-independent class: the static parser expands - * them into their longhands, the runtime one hands React Native the raw list. - * Same shape as the `rotate` entry above, kept apart because it is a family. - */ +// The static parser expands a shorthand into its longhands, and the runtime one hands React Native the list const SHORTHANDS: readonly Shape[] = ( [ [ @@ -220,17 +200,23 @@ const SHORTHANDS: readonly Shape[] = ( }, })); +const SHADOW_LISTS: readonly Shape[] = [ + { property: "box-shadow", value: "1px 2px 3px 4px #123456" }, + { property: "box-shadow", value: "0 0 #0000, 1px 2px 3px 4px #123456" }, + { property: "box-shadow", value: "0 0 transparent" }, +]; + const SHAPES: readonly Shape[] = [ ...Object.values(VALUE_CENSUS).flat(), ...SHORTHANDS, + ...SHADOW_LISTS, ]; function renderShape( shape: Shape, inlineVariables: boolean, ): Record { - // `--inner` and `--alpha` back the two shapes whose value is itself a - // reference; every other shape ignores them. + // `--inner` and `--alpha` back the shapes whose value is itself a reference const css = `.a { --inner: teal; --alpha: 0.5; --v: ${shape.value}; ${shape.property}: var(--v); }`; StyleCollection.styles.clear(); registerCSS(css, inlineVariables ? {} : { inlineVariables: false }); @@ -242,8 +228,6 @@ function renderShape( } test("the census covers something", () => { - // Deriving the table from a union buys a drift failure at the cost of a - // vacuity one: every list could be empty and every case below would vanish. expect(SHAPES.length).toBeGreaterThan(20); }); @@ -258,9 +242,64 @@ describe.each(SHAPES)("$property: var(--v) over $value", (shape) => { return; } - // A shape that renders nothing on both paths agrees vacuously, which is - // exactly how a typo in the census would pass. + // A shape that renders nothing on both paths would agree vacuously expect(folded).not.toStrictEqual({}); expect(unfolded).toStrictEqual(folded); }); }); + +function renderStylesheet( + css: string, + className: string, + inlineVariables: boolean, +): Record { + StyleCollection.styles.clear(); + registerCSS(css, inlineVariables ? {} : { inlineVariables: false }); + const { style } = render( + , + ).getByTestId(testID).props as { + style?: Record | Record[]; + }; + + return StyleSheet.flatten>(style ?? {}); +} + +// Each case names its own properties, because root and universal variables outlive a test +test.each([ + [ + "a declaration in a rule the element does not match", + `.p { --scope-a: 10px; } .a { width: var(--scope-a); height: 5px; }`, + "a", + ], + [ + "a declaration another rule on the element also reads", + `.a { --scope-b: 10px; width: var(--scope-b); } .b { height: var(--scope-b); }`, + "a b", + ], + [ + "a registered initial value beside a fallback block the compiler drops", + `@property --scope-c { syntax: "*"; inherits: false; initial-value: 10px; } + @supports (display: grid) { *, ::before { --scope-c: 20px; } } + .a { width: var(--scope-c); }`, + "a", + ], + [ + "a declaration inside an @supports block the compiler keeps", + `@supports (color: color-mix(in lab, red, red)) { :root { --scope-d: 10px; } } + .a { width: var(--scope-d); }`, + "a", + ], + [ + "a declaration on every element, registered inherits: false", + `@property --scope-e { syntax: ""; inherits: false; initial-value: 0px; } + * { --scope-e: 10px; } + .a { width: var(--scope-e); }`, + "a", + ], +])("%s renders the same whether or not it folds", (_name, css, className) => { + const folded = renderStylesheet(css, className, true); + const unfolded = renderStylesheet(css, className, false); + + expect(folded).not.toStrictEqual({}); + expect(unfolded).toStrictEqual(folded); +}); diff --git a/src/__tests__/native/variable-inlining.test.tsx b/src/__tests__/native/variable-inlining.test.tsx index ac2fb49c..a02c2cd8 100644 --- a/src/__tests__/native/variable-inlining.test.tsx +++ b/src/__tests__/native/variable-inlining.test.tsx @@ -5,15 +5,7 @@ import { registerCSS, testID } from "react-native-css/jest"; import { dimensions } from "../../native/reactivity"; -/** - * Every test names its own custom properties. `:root` variables live in a - * store the jest `beforeEach` does not reset, so a shared name would let one - * test decide another's result depending on the order they ran in. - * - * Native plane. `../compiler/inline-variables.test.ts` asserts the same folds - * against the IR; these assert what the runtime paints, because an IR that is - * scoped correctly and a screen that renders correctly are two claims. - */ +// Each test names its own properties, because root variables outlive a test test("a class-scoped variable does not leak into an unrelated rule", () => { registerCSS(`.parent { --leak-a: 10px; } .child { width: var(--leak-a); }`); @@ -86,16 +78,36 @@ test("a property registered with inherits: false resolves at runtime", () => { render(); - // KNOWN LIMIT, pinned rather than claimed correct. CSS says `--registered` - // does not inherit, so `.b` holds the registered initial value 10, not `:root`'s - // 20. The compiler now refuses to fold it — the sibling compiler test pins - // that — but the runtime carries no notion of a non-inherited custom - // property, so it still resolves 20 from the root scope. Modelling - // `inherits: false` in the variable store is what closes this, and refusing - // the fold is the prerequisite rather than the fix. + // Known limit: CSS gives `.b` the initial 10, but the runtime has no non-inherited custom property and resolves :root's 20 expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 20 }); }); +test("a registered initial value applies where nothing declares the property", () => { + registerCSS(` + @property --initial-a { syntax: "*"; inherits: false; initial-value: 10px; } + @supports (display: grid) { *, ::before { --initial-a: 20px; } } + .a { width: var(--initial-a); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("a declaration another rule reads survives its own fold", () => { + registerCSS( + `.a { --kept-a: 10px; width: var(--kept-a); } .b { height: var(--kept-a); }`, + ); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ height: 10 }); +}); + test("a runtime write reaches a rule that reads the variable", () => { registerCSS(`.parent { --write-a: red; } .child { color: var(--write-a); }`); @@ -109,8 +121,7 @@ test("a runtime write reaches a rule that reads the variable", () => { , ); - // An inline write outranks a class declaration, and folding the class value - // into `.child` would have made that unrepresentable. + // An inline write outranks a class declaration, which a value folded into `.child` could not express expect(screen.getByTestId(testID).props.style).toStrictEqual({ color: "blue", }); @@ -128,11 +139,7 @@ test("a runtime write cannot reach a reference in the declaring block", () => { />, ); - // KNOWN LIMIT, pinned rather than claimed correct. CSS says the inline write - // wins and this should be blue. The value was folded at build time, so no - // read remains for the write to affect. Closing it means not folding a - // same-block reference either, which is the whole optimisation; `blue` - // is recoverable today with `inlineVariables: { exclude: [...] }`. + // Known limit: CSS lets the inline write win, but the fold leaves no read for it; `exclude` restores it expect(screen.getByTestId(testID).props.style).toStrictEqual({ color: "#f00", }); diff --git a/src/__tests__/vendor/tailwind/transform.test.ts b/src/__tests__/vendor/tailwind/transform.test.ts index 2a7cf239..0ff41545 100644 --- a/src/__tests__/vendor/tailwind/transform.test.ts +++ b/src/__tests__/vendor/tailwind/transform.test.ts @@ -26,7 +26,7 @@ describe("Transforms - Scale", () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - transform: [{ scale: 0 }], + transform: [{ scaleX: 0 }, { scaleY: 0 }], }, }, }); @@ -53,7 +53,7 @@ describe("Transforms - Scale", () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - transform: [{ scale: 0.5 }], + transform: [{ scaleX: 0.5 }, { scaleY: 0.5 }], }, }, }); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 363ded58..f591410b 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -22,7 +22,6 @@ import type { ContainerQuery, StyleDescriptor, StyleRuleMapping, - UniqueVarInfo, } from "./compiler.types"; import { parseContainerCondition } from "./container-query"; import { @@ -85,8 +84,6 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { * Due to the above issue, we run lightningcss twice */ - const vars = new Map(); - // Determine the effective rem multiplier for compile-time inlining. // If inlineRem is explicitly set, use that. Otherwise, scan for // :root { font-size: Npx } in the CSS to allow CSS-based configuration. @@ -148,25 +145,8 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { if (options.inlineVariables !== false) { const exclusionList: string[] = options.inlineVariables?.exclude ?? []; - firstPassVisitor.Declaration = (decl) => { - if ( - decl.property === "custom" && - decl.value.name.startsWith("--") && - !exclusionList.includes(decl.value.name) - ) { - const entry = vars.get(decl.value.name) ?? { - count: 0, - value: [ - ...decl.value.value, - { type: "token", value: { type: "white-space", value: " " } }, - ], - }; - entry.count++; - vars.set(decl.value.name, entry); - } - }; firstPassVisitor.StyleSheetExit = (sheet) => { - return inlineVariables(sheet, vars); + return inlineVariables(sheet, exclusionList); }; } diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..e60a8983 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -160,7 +160,6 @@ export type InlineVariable = { }; export type UniqueVarInfo = { - count: number; value: TokenOrValue[] | undefined; flat?: true; }; diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 67c0a30a..109ce61c 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -36,7 +36,7 @@ import type { UnresolvedColor, } from "lightningcss"; -import { isStyleFunction } from "../utilities"; +import { isStyleFunction, isTransparentColor } from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, @@ -2471,11 +2471,18 @@ export function parseBoxShadow( { value }: DeclarationType<"box-shadow">, builder: StylesheetBuilder, ) { - for (const [index, shadow] of value.entries()) { - builder.addDescriptor( - `&.boxShadow.[${index}].color`, - parseColor(shadow.color, builder), - ); + const shadows = value + .map((shadow) => ({ shadow, color: parseColor(shadow.color, builder) })) + .filter(({ color }) => !isTransparentColor(color)); + + // A transparent shadow paints nothing, so it is dropped as the runtime drops one it reads from a variable + if (shadows.length === 0 && value.length > 0) { + builder.addDescriptor("box-shadow", [{}, "boxShadow", [], 1]); + return; + } + + for (const [index, { shadow, color }] of shadows.entries()) { + builder.addDescriptor(`&.boxShadow.[${index}].color`, color); builder.addDescriptor( `&.boxShadow.[${index}].offsetX`, parseLength(shadow.xOffset, builder), diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts index d79a469d..24e807db 100644 --- a/src/compiler/inline-variables.ts +++ b/src/compiler/inline-variables.ts @@ -8,26 +8,55 @@ import type { } from "lightningcss"; import type { UniqueVarInfo } from "./compiler.types"; +import { supportsConditionValid } from "./supports"; // A custom property is scoped to the elements its rule matches (css-variables-1 §2), so a value folds only where every matching element provably holds it export function inlineVariables( stylesheet: StyleSheet, - vars: Map, + exclude: readonly string[], ) { - // Which of two declarations wins depends on the element, and pruning before flattening keeps the fold independent of declaration order - for (const [name, info] of [...vars]) { - if (info.count !== 1) { - vars.delete(name); - } + const scope = collectVariableScope(stylesheet, exclude); + + for (const name of [...scope.vars.keys()]) { + flattenVar(name, scope); } - const scope = collectVariableScope(stylesheet, vars); + forEachDeclarationBlock(stylesheet.rules, (block) => { + foldDeclarationBlock(block, scope); + }); - for (const name of [...vars.keys()]) { - flattenVar(name, vars, scope); - } + const reads = collectRuntimeReads(stylesheet.rules, scope); + + forEachDeclarationBlock(stylesheet.rules, (block) => { + removeUnreadDeclarations(block, scope, reads); + }); + + return stylesheet; +} + +interface VariableScope { + readonly vars: Map; + readonly declaringBlocks: Map; + readonly universalNames: Set; +} + +// Every matching element holds the property when its declaration is universal and unconditional, or when the reference shares its block +function canFold( + name: string, + block: DeclarationBlock | undefined, + scope: VariableScope, +) { + return ( + scope.universalNames.has(name) || + (block !== undefined && scope.declaringBlocks.get(name) === block) + ); +} - stylesheet.rules = stylesheet.rules.map(function checkRule(rule) { +function forEachDeclarationBlock( + rules: Rule[], + visit: (block: DeclarationBlock) => void, +): void { + for (const rule of rules) { switch (rule.type) { case "custom": case "font-face": @@ -48,136 +77,162 @@ export function inlineVariables( case "custom-media": case "scope": case "starting-style": - return rule; - - case "media": - rule.value.rules = rule.value.rules.map((rule) => checkRule(rule)); - return rule; + break; case "keyframes": - rule.value.keyframes = rule.value.keyframes.map((keyframe) => { - keyframe.declarations = - replaceDeclarationBlock(keyframe.declarations, vars, scope) ?? - keyframe.declarations; - - return keyframe; - }); - return rule; + for (const keyframe of rule.value.keyframes) { + visit(keyframe.declarations); + } + break; case "style": - rule.value.declarations = replaceDeclarationBlock( - rule.value.declarations, - vars, - scope, - ); - - rule.value.rules = rule.value.rules?.flatMap((rule) => checkRule(rule)); - - return rule; + if (rule.value.declarations) { + visit(rule.value.declarations); + } + forEachDeclarationBlock(rule.value.rules ?? [], visit); + break; case "nested-declarations": - rule.value.declarations = - replaceDeclarationBlock(rule.value.declarations, vars, scope) ?? {}; - return rule; + visit(rule.value.declarations); + break; + case "media": case "supports": - rule.value.rules = rule.value.rules.flatMap((rule) => checkRule(rule)); - return rule; case "layer-block": - rule.value.rules = rule.value.rules.flatMap((rule) => checkRule(rule)); - return rule; case "container": - rule.value.rules = rule.value.rules.flatMap((rule) => checkRule(rule)); - return rule; + forEachDeclarationBlock(rule.value.rules, visit); + break; } - }); - - return stylesheet; + } } -interface VariableScope { - readonly declaringBlocks: Map; - readonly universalNames: Set; +function declarationsOf(block: DeclarationBlock): Declaration[] { + return [ + ...(block.declarations ?? []), + ...(block.importantDeclarations ?? []), + ]; } -// Every matching element holds the property when its declaration is universal and unconditional, or when the reference shares its block -function canFold( - name: string, - block: DeclarationBlock | undefined, - scope: VariableScope, -) { - return ( - scope.universalNames.has(name) || - (block !== undefined && scope.declaringBlocks.get(name) === block) - ); +function foldDeclarationBlock(block: DeclarationBlock, scope: VariableScope) { + for (const declaration of declarationsOf(block)) { + if ( + declaration.property === "unparsed" || + declaration.property === "custom" + ) { + declaration.value.value = declaration.value.value.flatMap((part) => { + return flattenPart(part, scope, block); + }); + } + } } -function replaceDeclarationBlock( - block: DeclarationBlock | undefined, - vars: Map, - scope: VariableScope, -) { - if (!block) return; +function collectRuntimeReads(rules: Rule[], scope: VariableScope) { + const reads = new Set(); + const readsOfCandidates = new Map(); + const pending: string[] = []; - block.declarations = block.declarations - ?.map((decl) => { - return replaceDeclaration(decl, vars, block, scope); - }) - .filter((d) => !!d); + const read = (name: string) => { + if (!reads.has(name)) { + reads.add(name); + pending.push(name); + } + }; - block.importantDeclarations = block.importantDeclarations - ?.map((decl) => { - return replaceDeclaration(decl, vars, block, scope); - }) - .filter((d) => !!d); + forEachDeclarationBlock(rules, (block) => { + for (const declaration of declarationsOf(block)) { + if (declaration.property === "custom") { + const { name, value } = declaration.value; + + // A candidate's own value is read only while something reads the candidate + if (scope.vars.has(name)) { + const names = readsOfCandidates.get(name) ?? []; + forEachReadName(value, (readName) => names.push(readName)); + readsOfCandidates.set(name, names); + } else { + forEachReadName(value, read); + } + } else if (declaration.property === "unparsed") { + forEachReadName(declaration.value.value, read); + } + } + }); - return block; + for (let name = pending.pop(); name !== undefined; name = pending.pop()) { + for (const readName of readsOfCandidates.get(name) ?? []) { + read(readName); + } + } + + return reads; +} + +function forEachReadName( + parts: TokenOrValue[], + visit: (name: string) => void, +): void { + for (const part of parts) { + switch (part.type) { + case "var": + visit(part.value.name.ident); + forEachReadName(part.value.fallback ?? [], visit); + break; + case "env": + forEachReadName(part.value.fallback ?? [], visit); + break; + case "function": + forEachReadName(part.value.arguments, visit); + break; + case "unresolved-color": + switch (part.value.type) { + case "rgb": + case "hsl": + forEachReadName(part.value.alpha, visit); + break; + case "light-dark": + forEachReadName(part.value.light, visit); + forEachReadName(part.value.dark, visit); + break; + default: + part.value satisfies never; + } + break; + default: + break; + } + } } -function replaceDeclaration( - declaration: Declaration, - vars: Map, +function removeUnreadDeclarations( block: DeclarationBlock, scope: VariableScope, + reads: Set, ) { - if ( - declaration.property !== "unparsed" && - declaration.property !== "custom" - ) { - return declaration; - } - - // A block-scoped declaration stays for descendants, including ones a separately compiled stylesheet styles - if ( - declaration.property === "custom" && - vars.has(declaration.value.name) && - scope.universalNames.has(declaration.value.name) - ) { - return; - } - - declaration.value.value = declaration.value.value.flatMap((part) => { - return flattenPart(part, vars, block, scope); - }); + const isKept = (declaration: Declaration) => { + return ( + declaration.property !== "custom" || + !scope.vars.has(declaration.value.name) || + reads.has(declaration.value.name) + ); + }; - return declaration; + block.declarations = block.declarations?.filter(isKept); + block.importantDeclarations = block.importantDeclarations?.filter(isKept); } function flattenPart( part: TokenOrValue, - vars: Map, - block: DeclarationBlock | undefined, scope: VariableScope, + block: DeclarationBlock | undefined, ): TokenOrValue | TokenOrValue[] { if (part.type === "var") { const name = part.value.name.ident; - const varInfo = vars.get(name); + const varInfo = scope.vars.get(name); if (!varInfo || !canFold(name, block, scope)) { part.value.fallback = part.value.fallback?.flatMap((arg) => { - return flattenPart(arg, vars, block, scope); + return flattenPart(arg, scope, block); }); return part; } else if (varInfo.value === undefined) { const fallback = part.value.fallback?.flatMap((arg) => { - return flattenPart(arg, vars, block, scope); + return flattenPart(arg, scope, block); }); return fallback ?? []; @@ -186,7 +241,7 @@ function flattenPart( return varInfo.value; } else if (part.type === "function") { part.value.arguments = part.value.arguments.flatMap((arg) => { - return flattenPart(arg, vars, block, scope); + return flattenPart(arg, scope, block); }); } @@ -195,17 +250,16 @@ function flattenPart( function flattenVar( name: string, - vars: Map, scope: VariableScope, seen = new Set(), ) { if (seen.has(name)) { - vars.delete(name); + scope.vars.delete(name); } seen.add(name); - let varInfo = vars.get(name); + let varInfo = scope.vars.get(name); if (!varInfo || varInfo.flat) { return; @@ -218,16 +272,16 @@ function flattenVar( if (part.type === "var") { const nestedName = part.value.name.ident; - flattenVar(nestedName, vars, scope, seen); + flattenVar(nestedName, scope, seen); if (canFold(nestedName, declaringBlock, scope)) { - const nestedVarInfo = vars.get(nestedName); + const nestedVarInfo = scope.vars.get(nestedName); if (nestedVarInfo?.value) { return nestedVarInfo.value; } } } - return flattenPart(part, vars, declaringBlock, scope); + return flattenPart(part, scope, declaringBlock); }); // If the variable is shorthand for "initial", substitute it for undefined @@ -243,61 +297,59 @@ function flattenVar( } varInfo = { - count: 1, flat: true, value: varInfoValue, }; - vars.set(name, varInfo); + scope.vars.set(name, varInfo); } +type Reach = "every-element" | "inherited-from-root" | "matched-elements"; + interface RuleScope { - /** Every element matches the enclosing selector. */ - readonly universal: boolean; - /** Enclosed by a query whose result this pass does not know. */ + readonly reach: Reach; readonly conditional: boolean; } -const ROOT_SCOPE: RuleScope = { universal: false, conditional: false }; +interface CustomDeclaration { + readonly block: DeclarationBlock; + readonly reach: Reach; + readonly value: TokenOrValue[]; +} + +const DOCUMENT_SCOPE: RuleScope = { + reach: "matched-elements", + conditional: false, +}; + +// Separates a value spliced between tokens, as the source's own whitespace did +const VALUE_SEPARATOR: TokenOrValue = { + type: "token", + value: { type: "white-space", value: " " }, +}; function collectVariableScope( stylesheet: StyleSheet, - vars: Map, + exclude: readonly string[], ): VariableScope { - const scope: VariableScope = { - declaringBlocks: new Map(), - universalNames: new Set(), - }; - - // A property registered with inherits: false reaches only the element that declares it (css-properties-values-api-1) + const declared = new Map(); const notInherited = new Set(); - for (const rule of stylesheet.rules) { - if (rule.type === "property" && !rule.value.inherits) { - notInherited.add(rule.value.name); - } - } - - const annotateBlock = ( - block: DeclarationBlock | undefined, - ruleScope: RuleScope, - ) => { - if (!block) return; + const initialValues = new Map(); - const universal = ruleScope.universal && !ruleScope.conditional; - - for (const declaration of [ - ...(block.declarations ?? []), - ...(block.importantDeclarations ?? []), - ]) { + const annotateBlock = (block: DeclarationBlock, ruleScope: RuleScope) => { + for (const declaration of declarationsOf(block)) { if (declaration.property !== "custom") continue; - const { name } = declaration.value; - if (!vars.has(name)) continue; + const { name, value } = declaration.value; + if (!name.startsWith("--") || exclude.includes(name)) continue; - scope.declaringBlocks.set(name, block); - if (universal && !notInherited.has(name)) { - scope.universalNames.add(name); - } + const declarations = declared.get(name) ?? []; + declarations.push({ + block, + reach: ruleScope.conditional ? "matched-elements" : ruleScope.reach, + value, + }); + declared.set(name, declarations); } }; @@ -306,78 +358,136 @@ function collectVariableScope( case "style": { const nested: RuleScope = { ...ruleScope, - universal: isUniversalScope(rule.value.selectors, ruleScope), + reach: selectorReach(rule.value.selectors, ruleScope), }; - annotateBlock(rule.value.declarations, nested); + if (rule.value.declarations) { + annotateBlock(rule.value.declarations, nested); + } for (const child of rule.value.rules ?? []) { annotateRule(child, nested); } return; } case "nested-declarations": - // The enclosing style rule's own declarations, so they carry its scope. annotateBlock(rule.value.declarations, ruleScope); return; case "keyframes": - // A keyframe's block folds only into itself; @keyframes never sits inside a style rule for (const keyframe of rule.value.keyframes) { - annotateBlock(keyframe.declarations, ruleScope); + annotateBlock(keyframe.declarations, { + ...ruleScope, + reach: "matched-elements", + }); } return; case "media": - case "supports": case "container": - // A query decides elsewhere whether these rules apply, so nothing inside one is unconditional for (const child of rule.value.rules) { annotateRule(child, { - universal: false, + reach: "matched-elements", conditional: true, }); } return; + case "supports": + // The compiler keeps or drops an @supports block at build time, so its declarations apply unconditionally or not at all + if (supportsConditionValid(rule.value.condition)) { + for (const child of rule.value.rules) { + annotateRule(child, ruleScope); + } + } + return; case "layer-block": for (const child of rule.value.rules) { annotateRule(child, ruleScope); } return; + case "property": + if (!rule.value.inherits) { + notInherited.add(rule.value.name); + } + if ( + rule.value.initialValue?.type === "token-list" && + !ruleScope.conditional + ) { + initialValues.set(rule.value.name, rule.value.initialValue.value); + } + return; default: return; } }; - for (const rule of stylesheet.rules) annotateRule(rule, ROOT_SCOPE); + for (const rule of stylesheet.rules) annotateRule(rule, DOCUMENT_SCOPE); + + const scope: VariableScope = { + vars: new Map(), + declaringBlocks: new Map(), + universalNames: new Set(), + }; + + for (const [name, declarations] of declared) { + // Which of two declarations wins depends on the element + const [declaration, ...others] = declarations; + if (!declaration || others.length > 0) continue; + + scope.vars.set(name, { value: [...declaration.value, VALUE_SEPARATOR] }); + scope.declaringBlocks.set(name, declaration.block); + + if ( + declaration.reach === "every-element" || + (declaration.reach === "inherited-from-root" && !notInherited.has(name)) + ) { + scope.universalNames.add(name); + } + } + + // An element no declaration reaches holds the registered initial value (css-properties-values-api-1 §2.3) + for (const [name, initialValue] of initialValues) { + if (declared.has(name) || exclude.includes(name)) continue; + + scope.vars.set(name, { value: [...initialValue, VALUE_SEPARATOR] }); + scope.universalNames.add(name); + } return scope; } -// One universal selector is enough (Tailwind emits `:root, :host`), and a nested rule is universal only where its parent is -function isUniversalScope(selectors: Selector[], ruleScope: RuleScope) { - return selectors.some((selector) => { +// A `*` in the list declares on every element, and a nested rule reaches only as far as its parent +function selectorReach(selectors: Selector[], ruleScope: RuleScope): Reach { + const reaches = selectors.map((selector): Reach => { const meaningful = selector.filter( (component) => component.type !== "nesting", ); if (meaningful.length === 0) { - return ruleScope.universal; + return ruleScope.reach; } if (meaningful.length !== 1) { // A compound or combined selector, such as `:root .theme`, matches less than the whole document - return false; + return "matched-elements"; } const [component] = meaningful; switch (component?.type) { case "universal": - return true; + return "every-element"; case "type": // Every element descends from html, and no other element name can be relied on - return component.name === "html"; + return component.name === "html" + ? "inherited-from-root" + : "matched-elements"; case "pseudo-class": - return component.kind === "root" || component.kind === "host"; + return component.kind === "root" || component.kind === "host" + ? "inherited-from-root" + : "matched-elements"; default: - return false; + return "matched-elements"; } }); + + if (reaches.includes("every-element")) return "every-element"; + if (reaches.includes("inherited-from-root")) return "inherited-from-root"; + return "matched-elements"; } diff --git a/src/native/styles/shorthands/box-shadow.ts b/src/native/styles/shorthands/box-shadow.ts index 20936e49..fb18f004 100644 --- a/src/native/styles/shorthands/box-shadow.ts +++ b/src/native/styles/shorthands/box-shadow.ts @@ -1,5 +1,8 @@ import type { StyleDescriptor } from "react-native-css/compiler"; -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + isStyleDescriptorArray, + isTransparentColor, +} from "react-native-css/utilities"; import type { StyleFunctionResolver } from "../resolve"; import { shorthandHandler } from "./_handler"; @@ -83,7 +86,7 @@ function flattenShadowDescriptor(arg: StyleDescriptor): StyleDescriptor[] { function omitTransparentShadows(style: unknown) { if (typeof style === "object" && style && "color" in style) { - if (style.color === "#0000" || style.color === "transparent") { + if (isTransparentColor(style.color)) { return; } } diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0c95da63..02a4afc7 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,3 +1,4 @@ export * from "./specificity"; export * from "./style-descriptor"; +export * from "./transparent-color"; export * from "./dot-notation.types"; diff --git a/src/utilities/transparent-color.ts b/src/utilities/transparent-color.ts new file mode 100644 index 00000000..f8dbe4d8 --- /dev/null +++ b/src/utilities/transparent-color.ts @@ -0,0 +1,4 @@ +// lightningcss writes every fully transparent colour as #0000, and a variable can still carry the keyword +export function isTransparentColor(color: unknown): boolean { + return color === "#0000" || color === "transparent"; +} From 87959e99fbe08fe7faf7161dd6903ad3096c96c7 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 16:57:19 +0300 Subject: [PATCH 4/5] test(native): state the inherited colour expectations in one line each --- src/__tests__/native/variables.test.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/__tests__/native/variables.test.tsx b/src/__tests__/native/variables.test.tsx index 161602b3..8de82e1d 100644 --- a/src/__tests__/native/variables.test.tsx +++ b/src/__tests__/native/variables.test.tsx @@ -171,10 +171,7 @@ test("can apply and set new variables", () => { expect(screen.getByTestId(testIDs.two).props.style).toStrictEqual({ color: "#f00", }); - // `--another-var` belongs to `.my-class`, so `.another-class` reaches it by - // inheritance at runtime rather than by a fold. A runtime value carries the - // token the author wrote: no property is in hand to canonicalise it, and - // React Native reads the name as the colour. + // `.another-class` inherits `--another-var` from `.my-class` at runtime, which carries the name as written expect(screen.getByTestId(testIDs.three).props.style).toStrictEqual({ color: "green", }); @@ -202,7 +199,7 @@ test("variables will be inherited", () => { , ); - // Inherited from `.green` at runtime, which is the whole subject of the test. + // Inherited from `.green` at runtime, which carries the name as written expect(screen.getByTestId(testIDs.three).props.style).toStrictEqual({ color: "green", }); @@ -274,7 +271,6 @@ test("variable overriding with classes", () => { ); const component = screen.getByTestId(testID); - // `--tier-500` belongs to `.tier-red`; `.test` reads it by inheritance. The - // `:root` tier folds into `.tier-red`'s value, so what inherits is `red`. + // `.test` inherits `--tier-500` from `.tier-red`, whose value the `:root` tier folded into expect(component.props.style).toStrictEqual({ color: "red" }); }); From dcef09c106c60e25eb128cc2f67726957f96b322 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 16:58:36 +0300 Subject: [PATCH 5/5] refactor(compiler): cite the initial-value descriptor for a registered fold --- src/compiler/inline-variables.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts index 24e807db..f33759b7 100644 --- a/src/compiler/inline-variables.ts +++ b/src/compiler/inline-variables.ts @@ -441,7 +441,7 @@ function collectVariableScope( } } - // An element no declaration reaches holds the registered initial value (css-properties-values-api-1 §2.3) + // Where nothing declares a registered property, every element holds its initial value (css-properties-values-api-1 §3.3) for (const [name, initialValue] of initialValues) { if (declared.has(name) || exclude.includes(name)) continue;