Skip to content

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

Draft
YevheniiKotyrlo wants to merge 12 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/non-inheriting-custom-properties
Draft

fix(native): honour inherits: false on registered custom properties#412
YevheniiKotyrlo wants to merge 12 commits 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 } means the property does not cascade to descendants (css-properties-values-api-1 §2.2). The native runtime's VariableContext inherits 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-color and --tw-inset-ring-color among them — so a ring set on an ancestor is inherited by every descendant that reads the shared five-variable box-shadow composition.

What that looks like in practice

Every shadow-* utility — including shadow-none — emits the same composition:

box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);

shadow-none only zeroes --tw-shadow. So a descendant carrying shadow-none subscribes to an ancestor's ring and paints it. Measured through the real compiler and calculateProps, with an ancestor carrying ring-2 ring-red-500:

descendant class list resolved boxShadow
shadow-none the ancestor's ring
shadow-sm the ancestor's ring + its own two
shadow-none ring-transparent the ancestor's ring
shadow-none ring-0 the ancestor's ring
no shadow-family class nothing

No class defeats it, which is the point: it is not a specificity problem a stylesheet can work around.

Fix

Record (compiler). extractPropertyRule records the name when inherits is false. It does so before the initial-value == null early 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 --, matching StyleRule.v. vi?: RootVariables carries an @property rule's initial-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 — assignInheritedVariables in native-internal/root.ts — or through the resolver skip beside it:

Channel Closed in
A stylesheet rule's variables — updateRules copied rule.v verbatim into the published bag src/native/react/rules.ts
VariableContextProvider spread props.value straight into the context src/native-internal/variables.tsx
An inline vars() merged in AFTER the rule filter, so style={vars({"--my-var": 10})} handed 10 to every descendant while the same value in CSS was withheld src/native/react/rules.ts
:root via varResolverrootVariables is handed to EVERY element, which is inheritance by another name src/native/styles/variables.ts

A fifth is compile-time: the single-definition inliner folded a lone declaration into consumers in other rules, so :root { --my-var: 50px } became a literal width: 50 on .child before any registry was consulted. src/compiler/inline-variables.ts takes 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 reach calculateProps. 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

  1. An inline vars() clobbered the element's own variables. The merge was Object.assign({}, variables, inheritedVariables, ...inline), and variables already contains the inherited bag under the element's own declarations — so merging inheritedVariables over it handed every descendant the ANCESTOR's value for each name the element itself declared. Fires for any element carrying any inline vars(), registered or not, entirely independent of inherits.
  2. initial-value and :root shared one slot. @property's default went through addRootVariable, so it and the :root declaration were two entries in one list and source order decided the winner. Tailwind emits @property first and :root after — the losing order. This also broke inheriting properties whose @property block sits after their :root declaration. Hence vi.
  3. The registry was append-only across reloads, and a registration has two halves. inject only ever added, so a Fast Refresh flipping inherits: false → true, or deleting the @property rule, 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 a clear().
  4. vu was written to the wrong registry. inject registered options.vu into rootVariables(...), so universalVariables was never written and the resolver's universal rung was dead code. Not an unrelated tidy-up: skipping the rootVariables rung 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 @property rule has to un-register both halves. The name set is a plain Set and clears cleanly. The initial values are an observable family, and there clear() is a Map.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 an initial-value: 3px registration:

registry rendered style
cold, no @property rule undefined {}
@property present, then deleted — before 3 { width: 5 }
the same, with a clear() retraction undefined { width: 5 }
the same, with 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, so family gains a keys() accessor beside its existing delete and clear. Its Map is 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 accept undefined, which its read function's first line already implements — that branch was simply unreachable from set before.

resetVariableRegistries still 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.ts holds four variable stores, and a second copy of the module cannot reach three of them. This PR pins the two it introduces — nonInheritedVariables and registeredInitialValues — to globalThis, like style-collection.ts and variables.tsx already do. rootVariables and universalVariables are pre-existing and are #410's subject, below. StyleCollection is itself pinned, so whichever copy of native-internal/root wins does all the injecting and fills ITS containers; a module-scoped store hands the other copy something nothing ever writes to.

For nonInheritedVariables that is a filter that never fires — the ring leaks again, with nothing to indicate why. For registeredInitialValues it 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 rootVariables and universalVariables for the same reason, and this one does not duplicate it: registeredInitialValues does not exist on #410's base, so #410 cannot cover it. Both branch from f70c402 and both restructure the tail of root.ts, so they conflict textually and not in mechanism — same declare global + ??= guard, different stores. Whichever lands second is a merge, not a redesign, and folding registeredInitialValues into #410's RootVariableRegistries object 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 under src/__tests__/native/ non-inheriting-channels.test.tsx, non-inheriting-registry.test.ts, non-inheriting-variables.test.tsx, plus additions to vars.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 @property rule emits no vi — which property.test.ts pins, 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 a clear(). Each was mutation-proven — reverting to the append-only loop turns two red on width: 5, and substituting a clear() 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 copyjest.resetModules() re-evaluates the module against the same globalThis, which is the split's topology exactly — and one of them injects through the real compiler and StyleCollection.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

  1. The wire format gains two slots, and one of them is a MOVE. vn is additive; vi relocates @property initial values out of vr, so identical CSS compiles to a different stylesheet shape. There is no version field on ReactNativeCssStyleSheet: an older runtime fed a newer sheet silently loses every @property initial value, and a newer runtime fed an older sheet gets them back in vr, where they are treated as inheritable :root declarations — the original bug. This is the largest ask here.
  2. The inline-vars() merge order changes for everyone, registered properties or not.
  3. * and :root stop sharing a slot. The resolver reads universal before root; that precedence held before only because one census was always empty.
  4. A stylesheet reload now un-registers names the new sheet drops, both the inherits: false flag and the initial-value. A registered initial value therefore stops resolving the moment its @property rule 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: family gains a keys() accessor in src/native/reactivity.ts, and the variable observables' set argument widens to VariableValue[] | undefined. Both are additive, but family is a shared primitive rather than something this feature owns.
  5. src/jest/index.ts resets the variable registries in its beforeEach, which changes cross-test isolation for every downstream consumer of the preset and makes the preset responsible for re-applying the __rn-css seeds.
  6. Five new exports on react-native-css/native-internalregisteredInitialValues, nonInheritedVariables, assignInheritedVariables, resetVariableRegistries, replaceRegisteredInitialValues — and two globalThis keys with a declare global augmentation.
  7. @property inside @media is not recorded, pinned by test rather than fixed.

Quality gates

Paired run on the same machine, this branch against its base f70c402:

f70c402   Test Suites: 2 failed, 4 skipped, 53 passed, 55 of 59 total
          Tests:       3 failed, 21 skipped, 1048 passed, 1072 total

this PR   Test Suites: 2 failed, 4 skipped, 56 passed, 58 of 62 total
          Tests:       3 failed, 21 skipped, 1102 passed, 1126 total

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 numTotalTests and a zero suite-load count out of --json rather than reading the summary.

The failing set is byte-identical and red on the base itself — src/__tests__/babel/react-native.test.ts (1) and src/__tests__/babel/react-native-web.test.ts (2), all babel-plugin-tester output mismatches from Windows path handling, which #390 addresses. yarn typecheck and yarn lint are clean.

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