Repository navigation
Conversation
Contributor
Author
|
@danstepanov please run CI on this 🙏 |
@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
force-pushed
the
fix/non-inheriting-custom-properties
branch
from
November 6, 2026 21:34
81913dd to
d0e26f0
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This replaces #412 -- minor cleanup & the removal of
computational-independence.tsand the relevant tests, which solve a different issue (see first bullet of theKnown limits). A.patchfile was shared with @YevheniiKotyrlo with just those changes extracted.Also, changes from
fix:tofeat:, 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). Onmainthe native runtime inherits every custom property, so descendants resolve values they must not see. With Tailwind v4 that means:mainring-2 ring-red-500shadow-sm-translate-x-1/2translate-y-2blur-smbrightness-50bg-linear-to-r from-red-500bg-linear-to-b to-blue-500leading-6text-baselineHeight: 294lineHeight: 21border-dashedborderThe 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
mainare fixed too: an@propertyinitial value was written to the:rootslot, so source order decided between it and a:rootdeclaration; and the variable registries survived from one jest test to the next.Solution
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@propertyof a name: the last one of a name wins, at the top level, in a layer, in@containeror under an@supportsthat holds. The inliner leaves a non-inheriting property to the runtime unless its only declarations are on*outside@mediaand@container, and never inlines a declaration that refers to one.varResolverreads a non-inheriting property only from the element itself (its rules, an inlinevars(),*) or its registered initial value, then thevar()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.injectadds each stylesheet's registrations by name, the last one winning, as it does forvrandvu. 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 toglobalThisso a second module copy shares them, and the jest preset resets them between tests.Tests
compiler/property.test.ts: thevpslot, which@propertyrules 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: inlinevars(), and cycles through:rootand 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
@propertywith an element-dependent initial value (em,rem,ch,lh,cq*) is registered here, where browsers ignore the rule. Rejecting it is left to a follow-up PR.@propertyinside@medianever registers, where browsers register it while the query matches. This is noted in the README.@propertyrules register; nested ones register from 1.31.@propertythat lightningcss rejects aborts the whole compile (fix(compiler): recover from a rule lightningcss cannot parse #493 recovers from it).<time>,<resolution>,<image>,<url>, transforms, most viewport units) are left out, as onmain, 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); non-inheriting properties now keep theirs.