Skip to content

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

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

fix(compiler): scope single-definition variable inlining to the declaring block#413
YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/single-definition-inliner-scope

Conversation

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor

Draft deliberately. The fix is sound on its own terms, and it surfaces a second issue I do not think I should decide unilaterally — see What this costs below. I would rather show you the tradeoff than pick for you.

Problem

inlineVariables counts declarations of each custom property across the whole stylesheet. At exactly one declaration it folds that value into every var() 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

import { compile } from 'react-native-css/compiler';

const rulesFor = (css, className) => {
  const sheet = compile(css).stylesheet();
  return JSON.stringify((sheet.s ?? []).find(([name]) => name === className)?.[1]);
};

console.log('one-def   .child ', rulesFor(`.parent { --x: 10px; } .child { width: var(--x); }`, 'child'));
console.log('one-def   .parent', rulesFor(`.parent { --x: 10px; } .child { width: var(--x); }`, 'parent'));
console.log('two-def   .child ', rulesFor(`.parent { --x: 10px; } .other { --x: 20px; } .child { width: var(--x); }`, 'child'));

On 3.0.7:

one-def   .child  [{"s":[1,1],"d":[{"width":10}]}]
one-def   .parent undefined
two-def   .child  [{"s":[3,1],"d":[[[{},"var","x",1],"width",1]],"dv":1}]

Three things, in ascending order of how much I think they matter:

  1. .child is not a descendant of .parent. Per CSS, var(--x) on an element with no --x in scope is invalid at computed-value time — width is unset, or takes the registered initial-value. It gets 10.
  2. .parent emits no rule at all, so a descendant that would legitimately inherit the property finds nothing.
  3. Compare row 1 with row 3. The only difference is an unrelated .other { --x: 20px } elsewhere in the sheet, and it changes what .child renders. 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.

  • Substitute a single-definition variable only into uses in the same block that declares it.
  • Remove the declaration only when nothing outside that block reads it.

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:

same-block .tx4   [{"s":[1,1],"d":[[[{},"translate",[16,[{},"var","tw-translate-y",1]],1],"translate",1]],"dv":1}]
cross .a          [{"s":[1,1],"v":[["x",10]],"d":[{"width":10}]}]
cross .b          [{"s":[2,1],"d":[[[{},"var","x",1],"height",1]],"dv":1}]

--tw-translate-x still folds. cross .a is the mixed case — declares AND uses, while .b also 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:

- "aspectRatio": "16/9"     + "16 / 9"
- "color": "#f00"           + "red"

red and #f00 are the same colour; 16 / 9 and 16/9 are 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:

  1. Normalize on the runtime path — the runtime would need the same normalization lightningcss applies when resolving a var() to a token string. Larger, correct in general.
  2. Narrow further — fold cross-block only where the value is already normalized. Smaller, but it puts a whole-sheet dependency back in a subtler place, so I think it is the weaker answer.

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: false on 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.

…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

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 — stock 3.0.7 after — with this PR

Both frames come from the same device in the same run (Android 36 emulator, 1140×2400 @ 480dpi), differing only in whether this PR is applied.

Each frame carries a build-probe width=<dp> line — a rem-derived box that resolves differently on a patched build. The capture harness reads it off the device and refuses to save a frame whose probe disagrees with the variant it claims, so a before image cannot silently be a second after.

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