Skip to content

fix(compiler): keep a var() substitution's meaning when inlining it - #503

Draft
YevheniiKotyrlo wants to merge 30 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/inlined-substitution
Draft

YevheniiKotyrlo wants to merge 30 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/inlined-substitution

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The compiler inlines a custom property declared once into each var() that reads it, so the reader compiles as though the value had been written there. Three readings change from what browsers do, and from what the same stylesheet gives with inlineVariables: false:

  • A variable two rules declare is left to the runtime, but a variable declared once that reads it can be flattened before the count check reaches it, and takes the first rule's value: .child { --size-reader: var(--size); width: var(--size-reader, 5px) } .small { --size: 3px } .large { --size: 4px } renders width: 3 on child large.
  • A value that reads a variable only the runtime resolves can be the guaranteed-invalid value, which a var() answers with its fallback (css-variables-1 §2.3, §3.1). Inlined into a reader with a fallback, the fallback is gone: with --a and --b in a cycle, --reader: var(--a) 2px read as width: var(--reader, 5px) renders no width.
  • A substituted value the property cannot take resets the declaration, where the same value written is ignored (css-variables-1 §3.1). .first { text-shadow: 9px 9px 0 #010203 } .second { --shadow: 2px; text-shadow: var(--shadow) } keeps the earlier shadow once --shadow is inlined.

Solution

  • Every variable declared more than once leaves the inliner before any value is flattened.
  • A var() with a fallback keeps reading a variable whose value still reads one the runtime resolves, and that variable stays declared. A var() without a fallback is inlined as before.
  • Only the second pass knows whether lightningcss parses a substituted value, so a declaration list in which a substitution leaves a declaration with nothing for the runtime to read, no var() and no env(), gains one custom declaration, --__rn-css-substituted, naming those properties. The second pass drops it, and where it could not parse a named declaration, points the declaration at a library variable holding its value, which the runtime resets as it resets any var() the property cannot take. A value written for the property in the same list stays unparsed in both passes, so beside one the first pass points the substitution at the library variable itself, and the written value is still ignored.

Tests

  • src/__tests__/native/variables.test.tsx runs each case twice, with variables inlined and with inlineVariables: false: a fallback over a cycle and over a variable no rule gives the element, a variable two rules declare read through one declared once, the reset for a plain, an !important and a nested-declarations substitution, and a written value the property cannot take beside a substitution of that property and of another one. Chromium 153, Firefox 155 and WebKit 26.6 compute every expected value.
  • src/__tests__/compiler/compiler.test.tsx checks that a substituted value the property takes compiles exactly as the written one, in a rule and in a keyframe, with nothing left for the runtime, and that a substitution leaving an env() compiles as the written env().
  • On fix: resolve CSS-wide keywords and the inherited colour as CSS defines them #391's head the seven inlined cases fail and their kept twins pass; the written value beside a substitution of its own property and the env() case hold there too, and guard the marker. Each of 13 mutations fails its own cases.

Verification

Every ci.yml job but the iOS build, on Ubuntu with Node 22.18.0: green on this branch and with it merged into main, where yarn test passes 1672.

On a 76 KB Tailwind stylesheet the compiler emits the same stylesheet as #391's, and the compile takes 8.5 ms longer (111.1 to 119.6 ms, #391 against itself within 0.4 ms), nearly all of it the marker. With the second pass handing nothing back to lightningcss, as #490 makes it, the marker cost 2.0 ms (58.6 to 60.5 ms) one commit earlier, before the env() fix, which only marks fewer declarations. The measurement.

Merge order

Stacked on #391, whose inliner this changes, so #391 lands first.

It shares lines with #389 (compiler/compiler.ts); whichever lands second rebases.

It shares lines with other open pull requests only through #391's commits, which #391 names.

Base

#391's head (e58f2c49).

`color: inherit`, `unset` and `currentcolor` compile to a read of the
colour every `color` rule publishes, and a colour that reads that
channel is not published back into it. While an element computes its
own `color`, the read resolves in the scope it inherited, because
css-color-4 makes `currentcolor` the inherited colour there; on every
other property it stays the element's own colour. nativewind#451 ordered the
element's own variables before inherited ones, so the inherited read is
now explicit rather than a consequence of lookup order.
The compiler and the resolver each asked whether a var() reads the
inherited colour, under one name with two meanings. The shallow question
is now `isInheritedColorLookup` beside the variable it names, and the
compiler's walk calls it for each var() it meets. Each constraint is stated
in one line, citing the sections that state it.
No caller passes parseUnparsed a string or a number: with either removed from the parameter
type, tsc reports no call site, and the test suite never takes either branch. The string
branch's `currentcolor` arm was a third spelling of the inherited-colour lookup that nothing
reached.
Each expected value is what Chromium, Firefox and WebKit compute for the same CSS. The cases
that pinned a divergence are gone: a light-dark() descendant in dark mode, a custom property
holding `inherit` that is not inlined, and the string relative colour syntax resolves to.
`::selection` is left to the pseudo-element mapping, and `::placeholder` also reads the
element's own colour.
A CSS-wide keyword is a value only on its own (css-values-4 §2.1), so the compiler resolves
one where it is a declaration's whole value or a var() fallback's, and rejects one inside a
longer value. `revert` and `revert-layer` roll back to `unset`, as React Native has no
user-agent or user origin. `unset` is `inherit` on `color` and on a custom property and
`initial` elsewhere, and `initial` clears the property to React Native's default. A custom
property left to inherit publishes nothing, so a read finds its ancestor's value, and its
warnings now name it.

A shorthand cleared by a keyword resets every longhand it sets, and a cleared filter is none.

An element's inherited colour comes from its last rule that publishes one, so a later `color`
that inherits no longer leaves an earlier rule's colour for its subtree, and a pseudo-element's
colour stays its own.
The first pass prints the stylesheet before the second parses it, so no white space from a
comment reaches the keyword, and the filter for it was unreachable.
A shorthand parsed at runtime was registered with an empty argument when its value failed to
parse, which cleared an earlier rule's value; browsers ignore an invalid declaration, so it is
left out. The CSS-wide keyword cases join the existing keyword suite.
… none

A filter or transform list that a var() fills with `none`, a cleared value or `initial` is
`none` alone or invalid at computed-value time, and either way the property is `none`; the
resolvers returned the keyword inside the list.
…rive cleared

Only a shorthand that returns its longhands as an object receives a cleared argument: animation
wraps its argument in a list, and box-shadow and drop-shadow handle it before the handler.
…he declaration

A keyword is a value only on its own, so one inside a function, a colour or a list makes the
whole declaration invalid; rejecting the token alone left `box-shadow: 1px 1px inherit` as a
shadow and `blur(inherit)` as an empty blur. A var() or env() defers its value, and a custom
property's value is any tokens, so neither is checked.
…CSS-wide keyword whole

These three parse their own value, so a keyword inside it was dropped as a token and the rest
applied: `object-position: left inherit` set `left`. They now share the declaration's keyword
decision, so the declaration is invalid and an earlier rule's value stands, as it does in
Chromium 153, Firefox 155 and WebKit 26.6.
… the function is

A fallback replaces its var() or env(), so a CSS-wide keyword in it applies only when the
function is the declaration's whole value (css-variables-1 §3). Inside a longer value the keyword
leaves the declaration invalid at computed-value time, yet it was resolved everywhere:
`color-mix(in srgb, var(--missing, inherit), blue)` mixed the inherited colour with blue, and
`translateX(var(--missing, initial))` compiled `unset` into the function. Inside a longer value
the fallback is now dropped with a warning.

An env() fallback is parsed for the declaring property, as a var() fallback is, rather than
under the env() name.
A keyword on its own was reported lowercased, while a nested one and every other value warning
quote the value as the stylesheet spells it.
A custom property set to inherit, unset, revert or revert-layer was
dropped, so an earlier rule's value on the same element won, and one
set to initial was dropped with a warning and read the ancestor's
value. Each now compiles to a value the runtime resolves where the
property is declared: inherit reads the parent's value over every
declaration on the element, and initial is the guaranteed-invalid
value, which takes a var() fallback and hides an ancestor's
declaration (css-variables-1 §2).

A declaration a var() leaves without a value is invalid at
computed-value time and behaves as unset (css-variables-1 §3.1): color
inherits, a runtime-parsed shorthand resets its longhands, and any
other property is cleared. A filter list with such an entry has no
filter, an empty fallback substitutes nothing, and the inliner never
folds an inheriting keyword and compiles a read of an inlined initial
the same way. A context lookup reads only a variable's own entries, so
var(--constructor) takes its fallback.
…it sits

A var() with no fallback for a variable that has no value left its
declaration a value when it sat inside a list, a function or another
custom property: `--reader: var(--source) 1px` read `1px`, and a
custom property whose value failed fell back to its ancestor's
declaration instead of the reader's fallback. The lookup now records
the failed substitution itself, so the declaration is invalid at
computed-value time and a custom property holding one is the
guaranteed-invalid value (css-variables-1 §3.1). A channel rncss
publishes for a computed value, such as the em, is a default when
nothing publishes it, never a failure.

The inliner flattens every variable a value reads, inside functions
and fallbacks too, before substituting it, so the result no longer
depends on declaration order, and leaves each member of a cycle to the
runtime. A custom property reading the guaranteed-invalid value is
guaranteed-invalid itself, a declaration whose value substitutes to
nothing is unset, and an empty custom property keeps its empty value,
which substitutes nothing inside a list at runtime as well.
…me too

The compiler made a declaration whose value substitutes to nothing
unset, while the runtime handed React Native the empty list it left:
`margin: var(--a,) var(--b,)` with neither declared rendered
`margin: []` over an earlier rule's value. The list resolver records
when every entry substituted nothing, which leaves a property's value
invalid at computed-value time and a custom property's value empty, as
css-variables-1 §3.1 defines.
The animation shorthand answered `unset` with nothing, so `animation: initial`,
`animation: unset` and `animation: var(--missing)` left an earlier rule's
animation running. It now resets every longhand it sets, as the other
shorthands do.
Inlining a variable declared once wrote its value into each reader as
though the author had written it there, and three readings changed:

- A variable two rules declare has a value per element, but a variable
  declared once and flattened before the count check reached it took the
  first rule's value. Variables declared more than once now leave the
  inliner before any value is flattened.
- A value that still reads a variable only the runtime resolves may be
  the guaranteed-invalid value, which a var() answers with its fallback.
  Inlined into a var() that has a fallback, the fallback was lost; that
  reader now keeps its var(), and the variable stays declared for it.
- A substituted value the property cannot take resets the declaration,
  where a written one is ignored (css-variables-1 §3.1). The value now
  travels beside the declaration in a library variable, which the second
  pass reads only where it cannot parse the declaration as written.
The second pass reads which declarations a var() substituted from the
first pass's output. A declaration per substitution carried a copy of
its value through both lightningcss passes; one declaration per list
naming the substituted properties carries the same fact, and the second
pass reads each value from the declaration itself.

A value written for the property stays unparsed in both passes, so
beside one the first pass reads the substitution through its library
variable itself, and the written value is still ignored.
…time

The runtime reads an env() the way it reads a var(), and resets the
declaration itself when the value cannot be used. A substitution that
left an env() behind was named in its list's marker anyway, so the
second pass routed `padding-top: env(safe-area-inset-top)` through a
library variable it did not need.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

What the marker costs a compile. Ubuntu 24.04 under WSL2, Node 22.18.0, a 76 KB Tailwind v4 stylesheet of 427 classes: compile() and stylesheet(), 40 runs a process after 10 warm-ups, 12 interleaved rounds of three processes, #391's head measured twice for the noise floor.

Build Median of the 12 process medians 10th–90th percentile
#391, A 110.79 ms 105.29–116.90 ms
#391, B 111.14 ms 106.51–112.40 ms
this branch 119.59 ms 114.67–124.37 ms

The floor is 0.35 ms and this branch is 8.5 ms slower. Both builds emit the same stylesheet for every class.

The marker is that cost: with no declaration reporting a substitution, the previous head measured 106.96 ms against #391's 106.48 ms in one session. A CPU profile puts it in lightningcss serialising the marker declarations and in garbage collection, not in the JavaScript that reads them. With the second pass returning nothing to lightningcss, as #490 makes it, #391 measures 58.55 ms and the previous head 60.53 ms, so the marker costs 2.0 ms there.

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 17, 2026 12:05
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 17, 2026 13:19

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