Skip to content

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

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

YevheniiKotyrlo wants to merge 1 commit 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 stylesheet. 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 by one rulesTheCompilerKeeps, which extractRule now uses too, so a kept block is unconditional and a dropped one is emptied before the census. A registered *-syntax property that nothing declares folds to its initial value (css-properties-values-api-1 §3.3). A declaration is removed once no rule 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, nesting, keyframe and !important, 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 as written, through a folded variable and through a kept one. Folding renders the written value for every kind. A kept variable does too, except where a row names the runtime's open defect: a named colour stays a name, scale and rotate keep their own keys until fix(transforms): resolve and cascade transform properties as browsers do #389, a 1px 2px shorthand stays a list, and a lone transition-duration stays unanimated.
  • src/__tests__/native/box-shadow.test.tsx: transparent shadows dropped on both paths, and a transparent list still replacing an earlier one.

On main, 45 of the 209 cases in the six files fail; all pass here. Twenty-five mutations, one per rule, each fail at least one case.

The scope cases agree with Chromium 156, Firefox 157 and WebKit 27.2:

  • * 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.
  • A variable declared initial, read directly or through another variable, gives way to the fallback 10px.
  • Three quarters through an animation whose keyframe sets --x: 20px, the declaring rule's own var(--x) reads 20px.

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

Verification

On Ubuntu with Node 22.18.0, each job in ci.yml but the iOS build, on this branch merged into main (2421ea7):

  • yarn lint and yarn typecheck clean
  • yarn test --maxWorkers=2 --coverage: 1472 passed
  • node --test node/__tests__/*.mjs passed
  • yarn build and yarn example expo export --platform web, with nothing unstaged after either build
  • yarn commitlint clean

The iOS dev build ran with my Mac's Xcode 27.1, where ci.yml pins 26.6. It fails compiling Expo's prebuilt ExpoModulesJSI before any of this package's code, and fails the same way on main.

Compiling a Tailwind v4 stylesheet of 1,708 classes costs the same as on main: about 480 ms on Node 26.12.1, with this branch 1.7% faster per round over 360 compiles a side, inside the 2.1% two runs of the same tree differ by.

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 conflicts with #379, #389, #391, #411, #412, #417, #420, #489, #491, #494, #495, #497, #501 and #503; I'll re-cut whichever lands after the others.

@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

The inliner folded a custom property declared once into every var() in the
stylesheet, whether or not the reference's element can hold the declaration.
It now folds a value only into the block that declares it, or from a
declaration outside @media and @container that reaches every element: `*`,
or `:root`, `:host` and `html` for an inheriting property. An @supports block
counts as extractRule decides it, through one rulesTheCompilerKeeps, so a
dropped block is emptied before the census; a registered `*` property that
nothing declares folds to its initial value; and a declaration is removed once
no rule reads it.

Values the fold no longer normalises reach the token path, which now reads
`0` and `false` as values and gives a <ratio> the descriptor parseAspectRatio
gives it. A compiled box-shadow drops a transparent shadow, as the runtime
does.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/single-definition-inliner-scope branch from aeb29d6 to 2dbd607 Compare October 23, 2026 18:25
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 23, 2026 18:48

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