Skip to content

fix(compiler): scope single-definition variable inlining to the declaring block - #413

Open
YevheniiKotyrlo wants to merge 5 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/single-definition-inliner-scope
Open

YevheniiKotyrlo wants to merge 5 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/single-definition-inliner-scope

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem

inlineVariables folds a custom property declared once into every var() that reads it, then deletes the declaration. It counts declarations with a visitor that cannot see the rule around them, so the fold ignores where the declaration applies. On main:

  1. A declaration reaches rules that cannot inherit it. .parent { --x: 10px } .child { width: var(--x) } compiles .child to width: 10, so an element carrying only .child gets a width. Chromium, Firefox and WebKit give it none (css-variables-1 §2). Whether .child folds also depends on unrelated rules: a second --x anywhere in the sheet turns the fold off.
  2. A dropped @supports block still declares. Tailwind registers its utility variables with @property and repeats each initial value in an @supports fallback for browsers without @property. The compiler drops that block (supportsConditionValid), but the visitor counts the declaration inside it, and that declaration is the value that folds. With @property --x { syntax: "*"; inherits: false; initial-value: 10px }, a fallback * { --x: 20px } and .b { width: var(--x) }, main folds 20. The same sheet renders 10 with inlineVariables: false, and in Chromium and Firefox.
  3. inherits: false is ignored. :root { --x: 20px } folds 20 into .b although the registration says other elements hold the initial value.
  4. A declaration is removed while something still reads it. .a { --c: 0.5; color: rgb(255 0 0 / var(--c)) } loses --c, because the fold does not reach inside an unresolved colour, and the alpha comes out empty.

Folding also decides which parser a value meets, and three parsers disagree with the runtime:

  • a variable holding 0 renders nothing unfolded, because parseUnparsed read 0 as an absence;
  • an unfolded <ratio> keeps the text 16 / 9 where the property parser gives 16/9;
  • a compiled transparent shadow is kept where the runtime drops it.

Solution

The inliner takes its census from its own walk over the rules the compiler keeps. It folds a value into a var() only when every element the reference's rule matches holds that value:

  • the reference is in the block that declares it, or
  • the declaration reaches every element: *, or :root, :host and html for a property that inherits, outside @media and @container.

An @supports block is decided with supportsConditionValid, as extractRule decides it, so a kept block is unconditional and a dropped one declares nothing. A registered *-syntax property that nothing declares folds to its initial value (css-properties-values-api-1 §3.3). A declaration is removed once nothing left in the stylesheet reads it, so one another rule reads at runtime stays. inlineVariables.exclude still keeps a variable a separately compiled stylesheet or vars() needs.

The three parsers now agree with the runtime: 0 and false are values, a <ratio> gets the descriptor parseAspectRatio gives it, and both paths drop a transparent shadow through one isTransparentColor.

Existing expectations change in three places:

  • transparent shadow is preserved pinned the compile-time path keeping it. Both paths drop it now.
  • scale-0 and scale-50 fold now, so they get the compiler's scaleX and scaleY instead of the runtime's scale. Both render the same scale.
  • Three cases in variables.test.tsx read "green" and "red" where they read "#008000" and "#f00". The colour now reaches the element by runtime inheritance instead of a fold into a rule that does not declare it, and the runtime keeps a named colour as written. React Native reads both.

Tests

  • src/__tests__/compiler/inline-variables.test.ts: where a value folds and where it must not, by selector, conditional rule, @supports, registration and nesting, and when a declaration is removed or kept.
  • src/__tests__/native/variable-inlining.test.tsx: what the runtime paints for the same stylesheets.
  • src/__tests__/native/variable-fold-parity.test.tsx: every value kind lightningcss can put in a custom property, rendered with the fold on and off. Each divergence the fold does not cause is pinned with its reason.
  • src/__tests__/native/box-shadow.test.tsx: transparent shadows dropped on both paths, and a transparent list still replacing an earlier one.

On main, 44 of the 168 cases in the six files fail; all pass here. Sixteen mutations, one per rule, each fail their own cases.

The scope cases agree with Chromium 153, Firefox 155 and WebKit 26.6:

  • * with inherits: false reads 20px.
  • :root with inherits: false reads the initial 10px.
  • A registered property nothing declares reads 10px.
  • An element outside the declaring rule reads no value.

In the Tailwind-shaped fallback, Chromium and Firefox drop the block as the compiler does. Playwright's WebKit keeps it.

Verification

On Windows with Node 26:

  • yarn lint clean
  • yarn typecheck clean
  • yarn test --maxWorkers=2 --coverage: 1421 passed, 4 failed. The 4 are the babel cases that also fail on main on this machine.
  • yarn build clean
  • yarn example expo export --platform web exported
  • nothing unstaged after either build

No existing issue tracks this. I searched for inlineVariables, variable inlining, custom property scope, @property initial-value and @supports.

Device evidence, before and after: #413 (comment)

Known limits

Merge order

This overlaps #389, #391, #411 and #420; I'll re-cut whichever lands after the others.

It also shares lines with #412 (compiler/compiler.ts, compiler/inline-variables.ts), #417 (compiler/declarations.ts, utilities/index.ts), #443 (__tests__/compiler/inline-variables.test.ts); whichever lands second rebases.

Base

Re-written on main (a5002c5).

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The two rows disagree — the subject is red OUTSIDE its scope, because a single declaration was folded there.

FIXED — Both rows read the same: red inside the declaring block, green outside it.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

…ring 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.
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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant