fix(compiler): scope single-definition variable inlining to the declaring block - #413
Open
YevheniiKotyrlo wants to merge 2 commits into
Open
Conversation
…ring block
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.
`inlineVariables` counted declarations across the whole stylesheet and, at
exactly one, folded the value into every `var()` reference in the sheet and
deleted the declaring rule.
.parent { --x: 10px; } .child { width: var(--x); }
`.child` compiles to `width: 10` although it is not a descendant of `.parent`,
and `.parent` emits no rule at all. Adding an unrelated second declaration of
`--x` anywhere in the sheet changes `.child` back to a runtime read - so one
rule's output depends on the existence of another it has no relationship with.
Fold only into uses in the same block that declares the variable, and remove
the declaration only when nothing outside that block reads it. The same-block
case Tailwind v4 actually emits keeps folding.
Folding a single-declaration custom property into a `var()` asserts that every
element the consuming rule matches holds that value. One declaration does not
show that. `canFold` names the two cases that do, and either is enough:
- the declaration is in a universal, unconditional scope (`:root`, `:host`,
`html`, `*`), so every element holds it whatever else it matches; or
- the reference is in the block that declares it, so any element the rule
matches holds it by matching that rule. A query that switches the declaration
off switches the reference off with it, so the two cannot disagree.
Neither holds across two rules, so `.a { --x: red }` with
`.b { color: var(--x) }` is left to the runtime. The same terms apply inside a
variable's own value: `.a { --x: var(--y) }` no longer takes `.b`'s `--y` and
carries it to elements that never matched `.b`.
Four further scoping holes close with it:
- A conditional `:root` is not universal. Whether `@media`/`@supports`/
`@container` applies is decided elsewhere, so a declaration inside one cannot
be folded into a rule outside it.
- A property registered `inherits: false` is not inherited, so a universal
declaration of it reaches only the element it is written on.
- Pruning multi-declaration candidates now happens before anything is
flattened. Doing it lazily made the fold depend on whether the reference was
written above the declarations or below them, and above them the LOSING
cascade value was folded in.
- A block-scoped declaration survives its own fold. It was folded into its own
block only, and a descendant still inherits it at runtime — including one
styled by a stylesheet compiled separately, which this pass cannot see.
Whether a fold happened must not be observable, so two places where the
unfolded value differed from the folded one are normalised:
- `parseUnparsed` tested `args` for truthiness, which dropped `--v: 0` and
`--v: 0px` entirely: the variable reached the runtime undeclared and the
declaration reading it rendered nothing at all.
- A `<ratio>` reaching the runtime through the token path serialised as
`16 / 9` where the property parser produces `16/9`, and never collapsed a
square ratio to `1`.
Three expectations in `variables.test.tsx` change, all the same case: a
class-scoped variable read from another rule now resolves at runtime, and a
runtime value carries the token the author wrote because no property is in hand
to canonicalise it. React Native reads the name as the colour.
YevheniiKotyrlo
marked this pull request as ready for review
August 15, 2026 19:13
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Problem
inlineVariablescounts declarations of each custom property across the whole stylesheet. At exactly one declaration it folds that value into everyvar()reference in the sheet and deletes the declaring rule. Neither step consults scope.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.
Reproduction
On 3.0.7:
Three things, in ascending order of how much I think they matter:
.childis not a descendant of.parent. Per CSS,var(--x)on an element with no--xin scope is invalid at computed-value time —widthis unset, or takes the registeredinitial-value. It gets10..parentemits no rule at all, so a descendant that would legitimately inherit the property finds nothing..other { --x: 20px }elsewhere in the sheet, and it changes what.childrenders. One rule's output depends on the existence of another it has no relationship with.Point 3 is the one that stands without reference to any particular scoping interpretation: an optimization whose result depends on global stylesheet state is not sound.
Fix
Fold within a scope the compiler can actually see — the declaration block.
Both clauses are needed and they gate different things: the first decides where a value may be folded, the second whether the declaration may go. A variable declared in one block and read in another is foldable nowhere and removable never.
The optimization survives
Measured on the shape Tailwind v4 emits — declare and use in one block:
--tw-translate-xstill folds.cross .ais the mixed case — declares AND uses, while.balso reads it — and behaves exactly as the two clauses say: local use folded, declaration kept.What this costs, and why it is a draft
13 tests across 6 suites change. On my machine: baseline 29 failures (3 suites, all pre-existing Windows path-rewrite ones) → 42 failures (9 suites) with this applied. The new ones are
compiler/@nativeMapping,native/variables,native/colors,native/box-shadow,vendor/tailwind/typography,vendor/tailwind/layout.They are not wrong values:
redand#f00are the same colour;16 / 9and16/9are the same ratio. What changed is the path. When lightningcss folds a value at compile time it also minifies and normalizes it. A value that stays a runtime variable keeps its raw token text.So the inliner is not purely an optimization — it is also the route through which folded values get normalized. I did not expect that and it is the reason I have not pushed this as ready.
Two ways I can see to resolve it, and I do not know which you would prefer:
var()to a token string. Larger, correct in general.I am happy to implement either, or to update the affected expectations if you consider the unnormalized runtime output acceptable. Tell me which and I will finish it.
Related: #412 (
inherits: falseon registered custom properties) is the runtime half of the same area, and its tests declare every property twice specifically to defeat this inliner. If this lands, those can be written normally.