Repository navigation
fix(native): honour inherits: false on registered custom properties - #412
YevheniiKotyrlo wants to merge 1 commit into
Conversation
Device evidence — before / afterUNFIXED — The inner box paints a ring too — a non-inheriting registered property reached a descendant. FIXED — One ring, on the outer box. The inner box carries
Android 16 emulator (API 36), 1140×2400 @ 480dpi, both frames from one capture session. Render cost —
|
| µs per operation | main |
this PR | this PR − main |
noise |
|---|---|---|---|---|
update: a parent's ring-2 ring-red-500 becomes ring-4 ring-blue-500 above a shadow-sm child |
222 (206–237) | 115 (110–137) | −104 | 12 |
mount: a ringed card with a shadow-sm child and text |
249 (247–254) | 240 (237–246) | −11.6 | 2.9 |
mount: a shadow-sm card with text |
117 (115–140) | 115 (112–122) | −12.1 | 15 |
style: width: var(--missing, 10px), a variable nothing sets |
2.83 (2.74–2.89) | 2.96 (2.85–3.30) | +0.10 | 0.18 |
update: an inline vars() value changes |
58.8 (55.2–68.6) | 57.2 (54.0–64.9) | −1.19 | 4.1 |
The main and this PR columns are each branch's median over 20 runs, with the interquartile range. The difference column is the median, over ten repetitions, of the mean of this PR's two runs minus the mean of main's two runs within one repetition, so drift between repetitions does not move it: the shadow-sm card's medians differ by 2 µs while, within each repetition, this PR ran 12 µs faster, inside that row's 15 µs noise. Noise is the median, over both branches and all ten repetitions, of the difference between one branch's two runs within a repetition. Jest with NODE_ENV=production, as a release build runs, on Windows 11 with Node 26.12 and an AMD Ryzen 9 9950X3D; each case's classes, and the 1,708-class sheet injected and compiled below, come from Tailwind v4.1.13's own output; main 2421ea7 against 18b3132. To run it, save render-cost.test.tsx as src/__tests__/native/render-cost.test.tsx on each branch and run yarn jest render-cost a few times.
Every case, and the method
| µs per operation | main |
this PR | this PR − main |
noise |
|---|---|---|---|---|
update: a parent's ring-2 ring-red-500 becomes ring-4 ring-blue-500 above a shadow-sm child |
222 (206–237) | 115 (110–137) | −104 | 12 |
update: the child's shadow-sm becomes shadow-md under a ring |
126 (121–137) | 120 (113–130) | −6.69 | 17 |
update: an inline vars() value changes |
58.8 (55.2–68.6) | 57.2 (54.0–64.9) | −1.19 | 4.1 |
update: a VariableContextProvider value changes |
39.0 (37.0–41.9) | 37.9 (35.8–43.5) | −1.64 | 6.1 |
mount: a ringed card with a shadow-sm child and text |
249 (247–254) | 240 (237–246) | −11.6 | 2.9 |
mount: a shadow-sm card with text |
117 (115–140) | 115 (112–122) | −12.1 | 15 |
mount: width: var(--missing, 10px), a variable nothing sets |
50.7 (48.4–55.3) | 49.9 (47.8–58.7) | +0.09 | 7.4 |
updateRules: ring-2 ring-red-500 becomes ring-4 ring-blue-500 |
22.7 (21.3–23.8) | 22.4 (21.7–23.8) | −0.61 | 1.6 |
updateRules: an inline vars() value changes |
7.16 (6.95–7.48) | 7.03 (6.83–7.36) | −0.24 | 0.42 |
style: shadow-sm with no ring above |
22.0 (21.5–22.9) | 22.0 (21.3–23.0) | −0.06 | 0.72 |
style: shadow-sm under a ring |
24.0 (23.4–24.9) | 21.8 (21.1–23.6) | −1.94 | 0.84 |
style: width: var(--missing, 10px), a variable nothing sets |
2.83 (2.74–2.89) | 2.96 (2.85–3.30) | +0.10 | 0.18 |
Each repetition runs main, this PR, this PR and main, each in its own jest process pinned to the same cores at high priority, and there are ten repetitions. A run times each case in seven batches sized to the case, from 500 operations for the ringed card's mount to 70,000 for the var(--missing, 10px) style, and keeps the median batch. Process CPU time gives the same result: nothing slower by more than its noise.
A var() that nothing sets now looks up its registered @property initial value before its fallback, each time it is read, so an @property in a stylesheet that loads later still reaches the element. The lookup costs about 0.1 µs: +0.10 µs here, within the 0.18 µs noise, and +0.11 µs against a 0.05 µs noise in that earlier run.
Injecting a 1,708-class Tailwind v4 stylesheet with 33 @property rules takes 4,784 µs on main and 4,759 µs here, within the 120 µs noise. Compiling it takes 402.1 ms on main and 405.2 ms here, within the 17 ms (4.2%) noise, and 314.9 ms against 316.4 ms with inlineVariables: false, within 9.4 ms (3.0%). After a thousand mounts and unmounts of each scene, nothing stays subscribed to the variable registries or to the rules on either branch.
adc1691 to
7ffd379
Compare
cdf6e28 to
0e1b564
Compare
0e1b564 to
5f66495
Compare
@Property { inherits: false } stops a custom property at the element that declares it (css-properties-values-api-1 §3). The native runtime inherited every custom property, so a descendant resolved a value it must not see. Every Tailwind v4 shadow-* utility composes var(--tw-ring-shadow), so a shadow-none descendant painted its ancestor's ring. The compiler publishes each registration (vp): its name, whether it inherits and its initial value, separate from :root's variables, since a declaration wins over a property's default wherever the @Property rule sits. Its first pass decides every registration, the last valid @Property of a name at the top level, in a layer, in @container or under an @supports that holds, as browsers register them, since lightningcss prints only the last @Property of a name. A registration whose initial value depends on the element (em, rem, ch, lh, cq* units, inside calc() or a transform too) is ignored, as browsers do. One whose initial value has no descriptor here (a <time>, <resolution>, <image>, <url> or transform, a viewport unit other than vw and vh, a math function over one, or a token list the compiler cannot reduce) is left out, so the property inherits as on main. The inliner folds a single declaration into every consumer, so a non-inheriting property declared once on a class reached elements without that class: a ring-2 element painted shadow-sm's shadow. It now leaves a non-inheriting property to the runtime unless universal rules outside @media and @container are all that declare it, which every element does for itself. At runtime varResolver reads a non-inheriting property only from the element itself (its rules, an inline vars(), a universal rule) or from its registered initial value, the last rung before the var() fallback: no ancestor's variables, whoever publishes them, and not :root. Its guard on an ancestor's value holds until a later registration lets the name inherit, so a descendant is not styled again when an ancestor's non-inheriting value changes. A variable that inherits is computed where it is declared (css-variables-1 §2), so an element's published variables and :root's take the non-inheriting properties they refer to from their own scope, and the inliner leaves such a variable where it is declared. An ancestor's guaranteed-invalid value inherits as such, so the reader takes its own fallback. inject adds each stylesheet's registrations by name, the last one winning, as it does for :root and universal variables, since Metro injects every CSS file apart. Each name's inheritance and initial value are an observable of their own, so a mounted element follows a later stylesheet that changes a property's inherits, either way, or its initial value, and only that name's readers run again. The jest preset resets the registries between tests. Both registries are pinned to globalThis, like the style collection, so a second copy of the module reads the same ones.
5f66495 to
18b3132
Compare


Problem
@property { inherits: false }stops a custom property at the element that declares it (css-properties-values-api-1 §3). Onmainthe native runtime inherits every custom property, so a descendant resolves a value it must not see.Every Tailwind v4
shadow-*utility,shadow-noneincluded, composesvar(--tw-ring-shadow). So ashadow-noneelement inside aring-2 ring-red-500ancestor paints the ancestor's ring.The value reaches descendants through every channel:
vars();VariableContextProvider;:root, which reaches every element;ring-2element also paintsshadow-sm's shadow.Two more defects on
main:@propertyrule'sinitial-valueis written to the:rootslot, so source order decides between a:rootdeclaration and the property's default.Solution
vp): its name, whether it inherits and its initial value, apart from:root's variables (vr).@propertyof a name at the top level, in a layer, in@containeror under an@supportsthat holds, as browsers register them. lightningcss prints only the last@propertyof a name, so deciding later would let an ignored one drop an earlier valid one.calc()and transforms too) is ignored, as browsers do.@mediaand@containerare all that declare it, which every element then does for itself.varResolverreads a non-inheriting property only from the element itself (its rules, an inlinevars(),*) or from its registered initial value, before thevar()fallback: never from an ancestor, whoever publishes the value, nor from:root. Its guard on an ancestor's value holds until a later registration lets the name inherit, so a descendant is not styled again when that value changes.:rootfor:root's variables, and the inliner leaves it where it is declared. Other references still resolve where they are read, as onmain.injectadds each stylesheet's registrations by name, the last one winning, as it does forvrandvu. Each name's inheritance and initial value are observables of their own, so a mounted element follows a later stylesheet that changes either, and only that name's readers run again. Both registries are pinned toglobalThis, like the style collection, and the jest preset resets them between tests.Tests
src/__tests__/compiler/property.test.ts: thevpslot, which@propertyrules register and where, initial values that depend on the element or have no descriptor, and what the inliner leaves to the runtime.src/__tests__/native/non-inheriting-variables.test.tsx: each channel; values that inherit and refer to a non-inheriting property; a later stylesheet that changes a registration; a second copy of the module; Tailwind's ring, shadow and properties layer.src/__tests__/native/vars.test.tsxandvariable-cycles.test.tsx: inlinevars(), and cycles through:rootand an ancestor.<image>with an element-relative stop, which Firefox ignores.main, 99 of the 173 cases in these files fail.Verification
Every
ci.ymljob passes on this head, the iOS dev build with Xcode 27.1 where CI pins 26.6.No render path is slower than on
mainpast the noise, and an update that changes an ancestor's ring takes half the time, since its descendants no longer read the ring. Avar()that nothing sets costs about 0.1 µs more, for looking up its registered initial value. The measurement and the device evidence: #412 (comment)No issue here or in nativewind/nativewind tracks this.
Known limits
@propertyrule lightningcss rejects aborts the whole compile, where a browser ignores just that rule. fix(compiler): recover from a rule lightningcss cannot parse #493 recovers from it.calc(1 + 1px)as a<length>and simplifiescalc(3px * sign(1em))to3pxbefore the compiler sees either, so each registers here where Chromium and Firefox ignore the rule.@propertyinside another at-rule as unknown, so only top-level ones register with it; from 1.31 one in a layer, in@containeror under an@supportsthat holds registers too, as in browsers. One in@mediadoes not register, where browsers register it while the query matches.<time>,<resolution>,<image>,<url>, transforms, viewport units other thanvwandvh) are left out, onmaintoo, so such a property inherits.unsetunless its syntax is*.var()left after inlining fails the compile (compile()fails with "failed to deserialize; expected an object-like struct named Specifier, found ()" on lightningcss >= 1.31 (any unresolvedvar()); the 1.30.2 guard doesn't cover it #470). A non-inheriting property declared once, and a declaration that refers to one, now keep theirs.@mediaquery, into every consumer. fix(compiler): scope single-definition variable inlining to the declaring block #413 keeps it within the declaring block, which ends both.Merge order
This overlaps #388, #389, #391, #392, #411, #413, #415, #423, #425, #426, #431, #435, #436, #443, #463, #467, #469, #484, #485, #486, #494, #496, #498, #501 and #503; I'll re-cut whichever lands after the others.