Repository navigation
fix(compiler): keep a var() substitution's meaning when inlining it - #503
YevheniiKotyrlo wants to merge 30 commits into
Conversation
`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.
… left to the runtime
…ation that reads it
…h the compiler resolves
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.
|
What the marker costs a compile. Ubuntu 24.04 under WSL2, Node 22.18.0, a 76 KB Tailwind v4 stylesheet of 427 classes:
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. |
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 withinlineVariables: false:.child { --size-reader: var(--size); width: var(--size-reader, 5px) } .small { --size: 3px } .large { --size: 4px }renderswidth: 3onchild large.var()answers with its fallback (css-variables-1 §2.3, §3.1). Inlined into a reader with a fallback, the fallback is gone: with--aand--bin a cycle,--reader: var(--a) 2pxread aswidth: var(--reader, 5px)renders no width..first { text-shadow: 9px 9px 0 #010203 } .second { --shadow: 2px; text-shadow: var(--shadow) }keeps the earlier shadow once--shadowis inlined.Solution
var()with a fallback keeps reading a variable whose value still reads one the runtime resolves, and that variable stays declared. Avar()without a fallback is inlined as before.var()and noenv(), 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 anyvar()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.tsxruns each case twice, with variables inlined and withinlineVariables: 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!importantand 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.tsxchecks 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 anenv()compiles as the writtenenv().env()case hold there too, and guard the marker. Each of 13 mutations fails its own cases.Verification
Every
ci.ymljob but the iOS build, on Ubuntu with Node 22.18.0: green on this branch and with it merged intomain, whereyarn testpasses 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).