fix(native): honour inherits: false on registered custom properties - #412
Draft
YevheniiKotyrlo wants to merge 12 commits into
Draft
fix(native): honour inherits: false on registered custom properties#412YevheniiKotyrlo wants to merge 12 commits into
inherits: false on registered custom properties#412YevheniiKotyrlo wants to merge 12 commits into
Conversation
A custom property registered by an `@property` rule with `inherits: false`
does not cascade to descendants (css-properties-values-api-1 §2.2). The
descriptor was parsed and discarded, so every custom property inherited
unconditionally through `VariableContext`.
The visible cost is Tailwind v4, which relies on the descriptor heavily.
Its `ring-*` utilities are custom properties composed into a five-variable
`box-shadow`, and every `shadow-*` utility — `shadow-none` included — emits
that same composition. So an element carrying any shadow utility renders
its ANCESTOR's ring around itself, on native only:
<View className="ring-2 ring-red-500">
<View className="shadow-none" /> ← painted a red 2px ring
- compiler: `extractPropertyRule` records a non-inheriting name, before the
`initial-value` early return — the two descriptors are independent, and
Tailwind registers `--tw-ring-color` with no default.
- stylesheet: emitted as `vn`, beside the existing `vr` / `vu`.
- runtime: `updateRules` skips those names when building the
`VariableContext` it publishes. The declaring element is unaffected — it
resolves its own `var()` from the rule directly, in `calculateProps`.
8 tests: 3 compiler (recorded with and without an initial value, `inherits:
true` not recorded, an unregistered property not recorded) and 5 rendered
(does not reach a descendant, the inheriting and unregistered counterparts
still do, still applies to the element declaring it, and the ring case
above). Each fixture declares its custom properties twice, so the
single-definition inliner cannot fold them and the runtime path is the one
under test.
The blocks I added restated the pull request description. What is left is only what the code cannot state: the `??=` reason for the module-level set, that rules.ts filters the context published to descendants and not the declaring element's own resolution, and that the fixtures declare each property twice on purpose.
…the gaps The registry was module-scoped in the one file whose sibling registries are globalThis-pinned for exactly this reason. StyleCollection is pinned, so whichever copy of the module wins the global does all the injecting and fills its own Set; a rules.ts bound to the other copy reads an empty one and the filter never fires, which is the original bug back with nothing to indicate why. Reverting the guard turns the new registry test red. Five tests cover behaviour nothing reached. Withholding is now pinned at grandchild depth, so an implementation that only blanked the immediate child fails. A property registered with no initial value resolves its var() fallback on a descendant — the --tw-ring-color shape, which the compiler test asserted and the runtime never did. A descendant that declares the property itself wins. And the jest reset is named as its own subject rather than resting on two earlier tests happening to reuse one variable name and happening to run first. The unregistered-property test declared its variable once, so the inliner erased it and the compiled output was empty — it asserted vn was absent from a stylesheet containing nothing, and survived a mutant that recorded every custom property in the file. It now declares twice and asserts the rules exist. Three compiler cases added: a name recorded once however many rules declare it (with different syntaxes, since lightningcss collapses identical @Property blocks before the visitor runs), last-declaration-wins in both orders, and @Property inside @media pinned as the known limitation it is.
…rovider
The provider spread its `value` straight into the VariableContext, so a property
registered `inherits: false` reached every descendant — the same leak the rest of
this branch closes, through the one channel that never consulted the registry.
It is also a platform divergence. On web the provider renders a real
`<div style={{"--my-var": 10}}>` and the browser's own cascade withholds a
non-inheriting property from the subtree, so the same input gave opposite answers
per platform, on the exact rule the feature is about.
`updateRules` did the filtering inline, which is how the provider came to be
missed. Both now go through `assignInheritedVariables`, beside the registry: one
definition of what an element publishes to its descendants, for every channel that
builds a VariableContext.
Three tests. Withheld, an inheriting property still published, and one name
withheld from a `value` whose sibling name is not — so blanking the whole object
fails. Reverting the provider to a plain spread turns the first and third red and
leaves the second green.
`updateRules` merged the inline `vars()` objects into the published variables after
the registry filter had already run, so `<View className="a" style={vars({"--my-var":
10})}>` handed 10 to every descendant while the same value written in CSS was
withheld. The result depended on how the value arrived rather than on what it was.
An inline declaration wins the cascade on the element it sits on; it does not change
the property's inheritance. css-properties-values-api-1 puts the inherit flag on the
REGISTRATION — "controlling whether or not the property inherits by default" — and
nothing in the cascade lets a declaration override that, whatever its origin. Web
agrees by construction: `vars()` there returns a plain `{"--my-var": "10"}` spread
into `style`, which is a real inline custom-property declaration the browser applies
the registration to.
The element's own bag is untouched, so the carrier still resolves its own value; only
the copy published to descendants is filtered. `assignInheritedVariables` is now
generic over the value type, because a resolved inline value is not a StyleDescriptor
— varResolver memoises PlatformColor objects and null back into the same bag.
Four tests: withheld from a descendant, still applied to the carrier, and an
inheriting and an unregistered property both still reaching the descendant. The
carrier declares an unrelated variable through a className, because an element whose
rules declare no variable publishes no context at all and the assertion would hold
without the registry being consulted. Dropping the filter turns the first red alone.
…riables
The published variable object merged the inherited bag OVER the element's own —
`Object.assign({}, variables, inheritedVariables, ...inline)` — and `variables`
already contains the inherited bag beneath the element's declarations. So carrying
any inline `vars()` at all, even one naming a variable nobody reads, handed every
descendant the ANCESTOR's value for each name the element itself declared.
Found in the expression the previous commit filters; the order is the whole fix.
`inheritedVariables` still has to be listed, because `variables` is undefined when a
rule reads a variable without declaring one.
The test drives three levels: an ancestor at 1px, a middle element declaring 50px
and carrying an unrelated inline vars(), and a child reading the name. It read the
ancestor's 1px and now reads 50px. Removing the inline vars() from the middle
element made the same tree resolve correctly, which is what named the merge.
`:root { --my-var: 50px }` beside `@property --my-var { inherits: false }` reached a
descendant as 50, where the spec has it resolve the registered 0px. Two independent
mechanisms produced that, and closing either alone leaves the other.
The runtime one is a shared slot. `@property`'s initial-value went through
addRootVariable, so it and the `:root` declaration were two entries in one
rootVariables list and the winner was whichever came last in the source — Tailwind
emits @Property first and :root after, which is the losing order. varResolver then
hands rootVariables to every element, which is inheritance: exactly what the
registration switches off.
They are different things and now sit in different slots. A `:root` declaration is a
value the root element HAS and descendants read by inheriting it; a registered
initial value is what the property resolves to on an element that declares it
nowhere. So `vi` joins `vr` / `vu` / `vn`, varResolver skips the rootVariables rung
for a non-inheriting name, and consults the registered default last — after every
declaration, because a declaration beats a property's own default. That also fixes an
inheriting property whose @Property block sits after its :root declaration, which
previously resolved to the default.
The universal rung is deliberately not skipped: `* { --x }` declares the property ON
each element rather than handing it down.
The compile-time one is the inliner. A custom property with exactly one declaration
is folded into its consumers, which answers for every element the consumer matches —
sound only while the value reaches all of them. For a non-inheriting property it
reaches the declaring element and nothing below, so `:root { --my-var: 50px }`
written once became a literal `width: 50` on `.child` before any registry existed.
Registrations are now collected in the first pass, where the inliner runs, and those
names are left to the runtime. lightningcss keeps only the last @Property per name,
so the first pass sees the winning declaration.
Ten existing expectations move from `vr` to `vi`, same values: every @property-only
case in property.test.ts. "defaults are root variables, not universal" asserted the
old home and is now "neither root nor universal", plus a case pinning a :root
declaration and a registered default landing in different slots.
Five compiler cases: the vn census derived from the fixture rather than restated,
@Property without an `inherits` descriptor never reaching the registry, a
non-inheriting property left to the runtime however few rules declare it, and an
inheriting one still inlined. Six runtime cases across :root, a single-declaration
ancestor class, both source orders, and the declaring element keeping its own value.
Each production line was reverted in turn: dropping the rootVariables skip turns 4
red, dropping the registered-default rung turns 8 red including two the branch
already had, and dropping the inliner exclusion turns exactly the 2
single-declaration cases red.
`inject` only ever added to it, so the registry was append-only across reloads. A Fast Refresh that edits an @Property rule to `inherits: true`, or deletes it, left the property pinned non-inheriting for the rest of the session, and only a full reload could clear it. Editing the descriptor is the one change this feature makes worth making, and it was the one change that did not take. The container is cleared rather than replaced. The globalThis pin exists so a second copy of root.ts shares this exact Set, and handing that copy a Set nothing writes to any more is the original dual-package bug in a new shape. A test pins the identity alongside the contents. The jest reset stays: inject only replaces the registry when it runs, so a test that never calls registerCSS still needs it. Three tests: `inherits: false` re-registered as true, the @Property rule deleted outright, and a rendered tree inheriting again after the second registration. Removing the clear turns exactly those three red.
…them
`inject` put `options.vu` into rootVariables, so universalVariables was written by
nothing and the rung varResolver reads for it always returned undefined. Both kinds
landed in one slot, with `vu` injected after `vr` and overwriting it, which is why
the dead rung never showed.
Skipping rootVariables for a non-inheriting name turns that into a wrong answer:
`* { --my-var: 5px }` resolved 5 through the root slot and now resolves the
registered 0px, when `*` matches each element in its own right — the element
DECLARES the property and the registration is not involved. A browser gives 5.
Sending `vu` to universalVariables restores it through the rung that models what the
selector means. Precedence is unchanged for a name declared in both: `*` won before
because it was injected last into the shared slot, and wins now because varResolver
reads universal before root — which is also the right order, a declaration on the
element beating a value inherited from the root.
Three tests: a non-inheriting property reaching an element and a grandchild through
`*`, and `*` beating `:root` for one name. Putting `vu` back into rootVariables turns
the first two red.
…try of names
The jest beforeEach cleared StyleCollection.styles and the non-inheriting names, but
the variable families themselves carried over. A stylesheet reload only overwrites
the names the new sheet mentions, so a name it drops keeps whatever the previous
sheet gave it — right for a running app, wrong between two tests.
Nothing had exercised it because no test read a name a previous test had written
through :root or `*`. Adding the universal-selector cases did: run the suite with
--randomize and `* { --my-var: 5px }` reaches three tests that never declared it,
turning them red on some orders and green on others.
root.ts owns the registries and the two seeds it writes at import, so it owns the
reset. rootVariables could not simply be cleared — dropping __rn-css-rem and
__rn-css-color would cost every later test its rem and currentcolor — so the seeding
is now a function the reset calls back.
Verified with --randomize over the whole suite three times: 1095 passing, order
independent. Reverting the beforeEach to clearing names alone turns the same three
red under seed 1234.
YevheniiKotyrlo
marked this pull request as draft
August 15, 2026 14:35
`registeredInitialValues` is the one variable store in this file a second copy of the module cannot reach. The Set beside it is already pinned, and StyleCollection — which does all the injecting — is pinned too, so under a dual package split the copy that loses the race answers `undefined` for every `@property` initial value. That is not a lost fallback. Tailwind composes a registered width into arithmetic on the element that DECLARES it — `calc(2px + var(--tw-ring-offset-width))` — so the copy corrupts a length that element computes for itself, with no ancestor involved. Two tests, both mutation-proven against a real second copy: `jest.resetModules()` re-evaluates the module against the same globalThis, which is the dual package topology exactly. The second drives the real compiler and `StyleCollection.inject`, so it measures the injected value rather than a hand-written one.
Deleting an `@property` rule un-registered half of it. `inject` clears
`nonInheritedVariables` before re-adding, so the name stopped being
non-inheriting, but nothing cleared `registeredInitialValues` before the
`options.vi` loop. The initial value outlived the rule that declared it, and the
two halves of one registration disagreed for the rest of the session.
Measured on one sheet, `.probe { width: calc(2px + var(--my-var)) }` against an
`initial-value: 3px` registration:
cold, no @Property rule style {}
@Property present, then deleted style { width: 5 }
Retracting is not a `clear()`. `family.clear()` is a `Map.clear()` and notifies
nobody: a mounted element keeps painting the deleted value, and the next
registration of that name lands on a fresh observable that element never
subscribed to. Measured — with `clear()` in `inject` the registry reads
`undefined` while the element still paints `width: 5`, so the registry-level
assertion alone would bless it. `set(undefined)` takes the same notification
path a changed value takes and leaves the observable its readers hold in place.
That needs the names the previous sheet registered, so `family` gains `keys()`.
Its Map is already that record; a second registry alongside it would be the same
two-halves-disagreeing shape as the defect. The observable's argument widens to
accept `undefined`, which the read function's first line already implements.
`resetVariableRegistries` keeps clearing, because nothing is mounted across the
test boundary it serves and it has no reader to strand. Its clear of
`registeredInitialValues` was unguarded — removing the line left all 1121 tests
green — and now turns exactly one red.
Correcting the record from 304a506: `root.ts` holds four variable stores and a
second copy of the module cannot reach three of them. That commit pins one,
`registeredInitialValues`. `rootVariables` and `universalVariables` are
pre-existing and are PR 410's subject; `nonInheritedVariables`, the other store
this branch adds, was already pinned.
Five tests. Four are runtime, because only a mounted reader separates a
retraction that notifies from one that does not: the cold-load control, the
registry retraction, the mounted element, and an observable identity that fails
the moment the retraction becomes a `clear()`. The fifth pins the sheet a
deleted rule compiles to, which is what the runtime retracts against.
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.
Problem
@property { inherits: false }means the property does not cascade to descendants (css-properties-values-api-1 §2.2). The native runtime'sVariableContextinherits every custom property unconditionally, so a descendant resolves a value the spec says it must not see.Tailwind v4 registers several of its internal properties exactly this way —
--tw-ring-colorand--tw-inset-ring-coloramong them — so a ring set on an ancestor is inherited by every descendant that reads the shared five-variablebox-shadowcomposition.What that looks like in practice
Every
shadow-*utility — includingshadow-none— emits the same composition:shadow-noneonly zeroes--tw-shadow. So a descendant carryingshadow-nonesubscribes to an ancestor's ring and paints it. Measured through the real compiler andcalculateProps, with an ancestor carryingring-2 ring-red-500:boxShadowshadow-noneshadow-smshadow-none ring-transparentshadow-none ring-0No class defeats it, which is the point: it is not a specificity problem a stylesheet can work around.
Fix
Record (compiler).
extractPropertyRulerecords the name wheninheritsis false. It does so before theinitial-value == nullearly return, deliberately: the two descriptors are independent, and Tailwind registers several of these properties with no default — those are exactly the ones a descendant must not resolve.Publish (stylesheet). Two top-level slots on
ReactNativeCssStyleSheet.vn?: string[]carries the non-inheriting names without the leading--, matchingStyleRule.v.vi?: RootVariablescarries an@propertyrule'sinitial-value— the value a property takes on an element that declares it nowhere.Withhold (runtime). Four channels publish a custom property to a descendant, and a fix in any one of them alone leaves the ring leaking through the other three. All four now route through one predicate —
assignInheritedVariablesinnative-internal/root.ts— or through the resolver skip beside it:updateRulescopiedrule.vverbatim into the published bagsrc/native/react/rules.tsVariableContextProviderspreadprops.valuestraight into the contextsrc/native-internal/variables.tsxvars()merged in AFTER the rule filter, sostyle={vars({"--my-var": 10})}handed 10 to every descendant while the same value in CSS was withheldsrc/native/react/rules.ts:rootviavarResolver—rootVariablesis handed to EVERY element, which is inheritance by another namesrc/native/styles/variables.tsA fifth is compile-time: the single-definition inliner folded a lone declaration into consumers in other rules, so
:root { --my-var: 50px }became a literalwidth: 50on.childbefore any registry was consulted.src/compiler/inline-variables.tstakes the non-inheriting name set and refuses to fold those names.The declaring element is unaffected in every case — unfiltered rules and unfiltered inline
vars()objects still reachcalculateProps. That asymmetry is the whole fix: the property applies where it is declared and stops at the element boundary.Four further defects the same path turned up
vars()clobbered the element's own variables. The merge wasObject.assign({}, variables, inheritedVariables, ...inline), andvariablesalready contains the inherited bag under the element's own declarations — so merginginheritedVariablesover it handed every descendant the ANCESTOR's value for each name the element itself declared. Fires for any element carrying any inlinevars(), registered or not, entirely independent ofinherits.initial-valueand:rootshared one slot.@property's default went throughaddRootVariable, so it and the:rootdeclaration were two entries in one list and source order decided the winner. Tailwind emits@propertyfirst and:rootafter — the losing order. This also broke inheriting properties whose@propertyblock sits after their:rootdeclaration. Hencevi.injectonly ever added, so a Fast Refresh flippinginherits: false → true, or deleting the@propertyrule, left the property pinned non-inheriting for the session. Both halves are now replaced — the name set and the initial value — because replacing one leaves the two disagreeing about the same registration. See the section below: retracting an observed value is not aclear().vuwas written to the wrong registry.injectregisteredoptions.vuintorootVariables(...), souniversalVariableswas never written and the resolver's universal rung was dead code. Not an unrelated tidy-up: skipping therootVariablesrung for a non-inheriting name turns that dead rung into a wrong answer for* { --my-var: 5px }.Retracting a registration, and why it is not a
clear()Deleting an
@propertyrule has to un-register both halves. The name set is a plainSetand clears cleanly. The initial values are an observablefamily, and thereclear()is aMap.clear()that notifies nobody — so a mounted element keeps painting the deleted registration's value, and the next registration of that name lands on a fresh observable that element never subscribed to.Measured on one sheet,
.probe { width: calc(2px + var(--my-var)) }against aninitial-value: 3pxregistration:@propertyruleundefined{}@propertypresent, then deleted — before3{ width: 5 }clear()retractionundefined{ width: 5 }set(undefined)undefined{}The third row is why the test that settles this renders rather than reading the registry: a
clear()makes the registry-level assertion pass while the element is still wrong.So the retraction sets the stale names to
undefined, which takes the same notification path a changed value takes. That needs the names the previous sheet registered, sofamilygains akeys()accessor beside its existingdeleteandclear. ItsMapis already that record; a second registry alongside it would be the same two-halves-disagreeing shape as the defect itself. The observable's argument type widens to acceptundefined, which its read function's first line already implements — that branch was simply unreachable fromsetbefore.resetVariableRegistriesstill clears, and the asymmetry is deliberate: nothing is mounted across the test boundary it serves, so it has no reader to strand.The dual-package split
native-internal/root.tsholds four variable stores, and a second copy of the module cannot reach three of them. This PR pins the two it introduces —nonInheritedVariablesandregisteredInitialValues— toglobalThis, likestyle-collection.tsandvariables.tsxalready do.rootVariablesanduniversalVariablesare pre-existing and are #410's subject, below.StyleCollectionis itself pinned, so whichever copy ofnative-internal/rootwins does all the injecting and fills ITS containers; a module-scoped store hands the other copy something nothing ever writes to.For
nonInheritedVariablesthat is a filter that never fires — the ring leaks again, with nothing to indicate why. ForregisteredInitialValuesit is sharper, and worth stating separately: it is not a fallback the resolver can do without. Tailwind composes a registered width into arithmetic on the element that DECLARES the ring —calc(2px + var(--tw-ring-offset-width))— so a copy reading an empty registry does not lose an inherited value it was never entitled to, it corrupts a length that element computes for itself, with no ancestor involved.Neither store has a seed to protect, so a plain
??=is the whole guard.Relationship to #410. That PR pins
rootVariablesanduniversalVariablesfor the same reason, and this one does not duplicate it:registeredInitialValuesdoes not exist on #410's base, so #410 cannot cover it. Both branch fromf70c402and both restructure the tail ofroot.ts, so they conflict textually and not in mechanism — samedeclare global+??=guard, different stores. Whichever lands second is a merge, not a redesign, and foldingregisteredInitialValuesinto #410'sRootVariableRegistriesobject is a reasonable tidy-up at that point. I am happy to do it in whichever order you prefer.Tests
Five files:
src/__tests__/compiler/property.test.ts, and undersrc/__tests__/native/non-inheriting-channels.test.tsx,non-inheriting-registry.test.ts,non-inheriting-variables.test.tsx, plus additions tovars.test.tsx.The retraction is guarded at runtime, because the compiler plane cannot see it. The compiler compiles one sheet at a time and holds no registry, so all it can say is that a sheet with no
@propertyrule emits novi— whichproperty.test.tspins, and which is what the runtime retracts against. The defect itself only exists across two injects with a reader mounted, so its four tests render: a cold-load control, the registry retraction, the mounted element, and an observable identity that fails the moment the retraction becomes aclear(). Each was mutation-proven — reverting to the append-only loop turns two red onwidth: 5, and substituting aclear()turns a different two red, which is how the third row of the table above was measured.The dual-package tests drive a real second copy —
jest.resetModules()re-evaluates the module against the sameglobalThis, which is the split's topology exactly — and one of them injects through the real compiler andStyleCollection.inject, so it measures the value the pipeline produces rather than a hand-written one. Each was mutation-proven: I unpinned the store and watched the assertion go red before trusting it.Every fixture declares each custom property twice, and that is load-bearing. A property with a single definition is folded into its consumers by the compiler's inliner, which never reaches the runtime path under test — the first draft of these passed and failed for that reason rather than for the fix. Real Tailwind output always carries many definitions (one per
ring-*/shadow-*utility), so two is the faithful shape rather than a trick. #413 scopes that inliner to its declaring block; once it lands these fixtures can be written the normal way.Open questions
vnis additive;virelocates@propertyinitial values out ofvr, so identical CSS compiles to a different stylesheet shape. There is no version field onReactNativeCssStyleSheet: an older runtime fed a newer sheet silently loses every@propertyinitial value, and a newer runtime fed an older sheet gets them back invr, where they are treated as inheritable:rootdeclarations — the original bug. This is the largest ask here.vars()merge order changes for everyone, registered properties or not.*and:rootstop sharing a slot. The resolver reads universal before root; that precedence held before only because one census was always empty.inherits: falseflag and theinitial-value. A registered initial value therefore stops resolving the moment its@propertyrule leaves the sheet, which is the behaviour a Fast Refresh needs and a change for anyone relying on the old append-only registry.Also here:
familygains akeys()accessor insrc/native/reactivity.ts, and the variable observables'setargument widens toVariableValue[] | undefined. Both are additive, butfamilyis a shared primitive rather than something this feature owns.src/jest/index.tsresets the variable registries in itsbeforeEach, which changes cross-test isolation for every downstream consumer of the preset and makes the preset responsible for re-applying the__rn-cssseeds.react-native-css/native-internal—registeredInitialValues,nonInheritedVariables,assignInheritedVariables,resetVariableRegistries,replaceRegisteredInitialValues— and twoglobalThiskeys with adeclare globalaugmentation.@propertyinside@mediais not recorded, pinned by test rather than fixed.Quality gates
Paired run on the same machine, this branch against its base
f70c402:Both suite lines account for every suite (2 + 4 + 53 = 59; 2 + 4 + 56 = 62), so neither run silently subtracted one by failing to load it — I assert
numTotalTestsand a zero suite-load count out of--jsonrather than reading the summary.The failing set is byte-identical and red on the base itself —
src/__tests__/babel/react-native.test.ts(1) andsrc/__tests__/babel/react-native-web.test.ts(2), allbabel-plugin-testeroutput mismatches from Windows path handling, which #390 addresses.yarn typecheckandyarn lintare clean.