Skip to content

feat(native): honour inherits: false on registered custom properties - #567

Open
sammoore wants to merge 3 commits into
nativewind:mainfrom
sammoore:fix/non-inheriting-custom-properties
Open

sammoore wants to merge 3 commits into
nativewind:mainfrom
sammoore:fix/non-inheriting-custom-properties

Conversation

@sammoore

@sammoore sammoore commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

This replaces #412 -- minor cleanup & the removal of computational-independence.ts and the relevant tests, which solve a different issue (see first bullet of the Known limits). A .patch file was shared with @YevheniiKotyrlo with just those changes extracted.

Also, changes from fix: to feat:, as I think this should mark a minor version bump.

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 descendants resolve values they must not see. With Tailwind v4 that means:

Parent Child main This PR
ring-2 ring-red-500 shadow-sm child also paints the red ring shadow only
-translate-x-1/2 translate-y-2 child also moves -50% on x y only
blur-sm brightness-50 child also blurred brightness only
bg-linear-to-r from-red-500 bg-linear-to-b to-blue-500 child starts from red starts from transparent
leading-6 text-base lineHeight: 294 lineHeight: 21
border-dashed border child border dashed solid

The value leaks through every channel: a rule's variables, inline vars(), VariableContextProvider, :root, and the compile-time inliner, which folds a property declared once on one class into every consumer.

Two smaller defects on main are fixed too: an @property initial value was written to the :root slot, so source order decided between it and a :root declaration; and the variable registries survived from one jest test to the next.

Solution

  • Compiler. The stylesheet carries each registration (vp: name, inherits, initial value), separate from :root's variables. Registrations are collected from the whole stylesheet at the end of the first pass, since lightningcss prints only the last @property of a name: the last one of a name wins, at the top level, in a layer, in @container or under an @supports that holds. The inliner leaves a non-inheriting property to the runtime unless its only declarations are on * outside @media and @container, and never inlines a declaration that refers to one.
  • Runtime. varResolver reads a non-inheriting property only from the element itself (its rules, an inline vars(), *) or its registered initial value, then the var() fallback, never from an ancestor or :root. 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 the declaring element.
  • Registrations. 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 is its own observable, so a mounted tree follows a later stylesheet that changes either. The registries are pinned to globalThis so a second module copy shares them, and the jest preset resets them between tests.

Tests

  • compiler/property.test.ts: the vp slot, which @property rules register, and what the inliner leaves to the runtime.
  • native/non-inheriting-variables.test.tsx: each channel, inheriting values that refer to a non-inheriting one, later stylesheets that change a registration, a second module copy, and Tailwind's ring and shadow.
  • native/vars.test.tsx, native/variable-cycles.test.tsx: inline vars(), and cycles through :root and an ancestor.

Expectations match what Chromium, Firefox and WebKit compute for the same CSS. On main, 82 of the 142 cases in these files fail.

Verification

No render path is measurably slower than on main, and updating an ancestor's ring takes about half the time, since descendants no longer read it. Measurements and device evidence: #412 (comment)

Known limits

@sammoore

sammoore commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

@danstepanov please run CI on this 🙏

YevheniiKotyrlo and others added 3 commits November 6, 2026 16:34
@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.
- Always install StyleSheetExit so there's one place that registers
  properties. StyleSheetExit returns after that if we are not inlining
  variables.
- Instead of the location-key workaround, StyleSheetExit already
  receives the whole stylesheet, and countUniversalDeclarations already
  walks all of the rules there. Collect registrations the same way.
- Remove the change that unsubscribes to styleObs only if it's changed;
  was not clear if it made sense, and it's a separate fix buried in the
  PR. Tests still pass either way. If it is needed, that shouldn't be
  the case (we would want a test for it).
- Minor doc changes.
@sammoore
sammoore force-pushed the fix/non-inheriting-custom-properties branch from 81913dd to d0e26f0 Compare November 6, 2026 21:34

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.

2 participants