Skip to content

fix(native): honour inherits: false on registered custom properties - #412

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/non-inheriting-custom-properties
Open

YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/non-inheriting-custom-properties

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem

@property { inherits: false } stops a custom property at the element that declares it (css-properties-values-api-1 §3). On main the native runtime inherits every custom property, so a descendant resolves a value it must not see.

Every Tailwind v4 shadow-* utility, shadow-none included, composes var(--tw-ring-shadow). So a shadow-none element inside a ring-2 ring-red-500 ancestor paints the ancestor's ring.

The value reaches descendants through every channel:

  • a stylesheet rule's variables;
  • an inline vars();
  • VariableContextProvider;
  • :root, which reaches every element;
  • the compile-time inliner, which folds a property declared once into every consumer, so a property declared on one class reaches elements without that class: a ring-2 element also paints shadow-sm's shadow.

Two more defects on main:

  • An @property rule's initial-value is written to the :root slot, so source order decides between a :root declaration and the property's default.
  • The variable registries survive from one jest test to the next.

Solution

  • Compiler.
    • The stylesheet carries each registration (vp): its name, whether it inherits and its initial value, apart from :root's variables (vr).
    • The 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. lightningcss prints only the last @property of a name, so deciding later would let an ignored one drop an earlier valid one.
    • A registration whose initial value depends on the element (font- or container-relative units, inside calc() and transforms too) is ignored, as browsers do.
    • The inliner leaves a non-inheriting property to the runtime, unless universal rules outside @media and @container are all that declare it, which every element then does for itself.
  • Runtime.
    • varResolver reads a non-inheriting property only from the element itself (its rules, an inline vars(), *) or from its registered initial value, before the var() 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.
    • A variable that inherits carries the value computed where it is declared (css-variables-1 §2), so the non-inheriting properties it refers to come from that element, or from :root for :root's variables, and the inliner leaves it where it is declared. Other references still resolve where they are read, as on main.
  • Registrations. Metro injects each CSS file on its own, so inject adds each stylesheet's registrations by name, the last one winning, as it does for vr and vu. 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 to globalThis, like the style collection, and the jest preset resets them between tests.

Tests

  • src/__tests__/compiler/property.test.ts: the vp slot, which @property rules 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.tsx and variable-cycles.test.tsx: inline vars(), and cycles through :root and an ancestor.
  • Every expectation is what Chromium, Firefox and WebKit compute for the same CSS. Where they disagree, this follows Chromium and WebKit: they ignore a container-relative initial value, which Firefox keeps, and keep an <image> with an element-relative stop, which Firefox ignores.
  • On main, 99 of the 173 cases in these files fail.

Verification

Every ci.yml job passes on this head, the iOS dev build with Xcode 27.1 where CI pins 26.6.

No render path is slower than on main past the noise, and an update that changes an ancestor's ring takes half the time, since its descendants no longer read the ring. A var() 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

  • An @property rule 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.
  • lightningcss accepts calc(1 + 1px) as a <length> and simplifies calc(3px * sign(1em)) to 3px before the compiler sees either, so each registers here where Chromium and Firefox ignore the rule.
  • lightningcss 1.30 parses an @property inside another at-rule as unknown, so only top-level ones register with it; from 1.31 one in a layer, in @container or under an @supports that holds registers too, as in browsers. One in @media does not register, where browsers register it while the query matches.
  • Initial values the compiler has no descriptor for (<time>, <resolution>, <image>, <url>, transforms, viewport units other than vw and vh) are left out, on main too, so such a property inherits.
  • A registered property that is invalid at computed-value time, in a cycle for one, takes the reader's fallback; CSS makes it unset unless its syntax is *.
  • With lightningcss 1.31 or later, a 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 unresolved var()); 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.
  • A registration in one CSS file does not reach another file's inliner, and the inliner still folds an inheriting property declared once on a class, or under a @media query, 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.

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — 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 shadow-none and declares no ring.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36), 1140×2400 @ 480dpi, both frames from one capture session. before is the published 3.1.0-rc.0 with the patches my test app needs to run, and after adds my other open PRs, this one among them. One of them shows on every screen: #417 sets the text in the theme's font, where before falls back to the platform's. Both frames come from an earlier revision of this PR (7ffd379); src/__tests__/native/non-inheriting-variables.test.tsx asserts the same result on this head.

Render cost — main against this PR

main — When an ancestor's ring changes, every shadow-* descendant is styled again, because its box-shadow reads the ancestor's --tw-ring-shadow.

This PR — Descendants no longer read the ancestor's ring, so that update takes half the time, and the ringed card mounts 12 µs faster. Nothing is slower than on main by more than the noise here. A var() that nothing sets now also looks up its registered initial value, which costs about 0.1 µs: within the noise in this run, beyond it in an earlier, quieter run of a revision whose var() lookup is this head's.

µ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.

@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/non-inheriting-custom-properties branch from adc1691 to 7ffd379 Compare October 11, 2026 12:35
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 16, 2026 13:17
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 16, 2026 13:18
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/non-inheriting-custom-properties branch 2 times, most recently from cdf6e28 to 0e1b564 Compare October 23, 2026 16:10
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 28, 2026 21:41
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/non-inheriting-custom-properties branch from 0e1b564 to 5f66495 Compare October 29, 2026 00:18
@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.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/non-inheriting-custom-properties branch from 5f66495 to 18b3132 Compare October 29, 2026 06:33
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 29, 2026 10:26

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