Skip to content

fix(native): let an element's own custom property outrank an inherited one - #443

Open
YevheniiKotyrlo wants to merge 3 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/vars-deprecation-scope
Open

fix(native): let an element's own custom property outrank an inherited one#443
YevheniiKotyrlo wants to merge 3 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/vars-deprecation-scope

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Problem

varResolver reads the inherited variable context before the element's own record and returns from it:

// src/native/styles/variables.ts
if (name in variables) {          // the INHERITED context
  renderGuards?.push(["v", name, variables[name]]);
  return resolve(variables[name]);
}

variableHistory.add(name);

let value = resolve(inlineVariables?.[name] as StyleDescriptor);  // the element's OWN, second

So an element's own declaration of --x is used only when no ancestor declared that name. css-cascade-4 §7.2 makes inheritance a defaulting step — reached only when the cascade yields no declared value — and a custom property is an ordinary property (css-variables-1 §2), so this is backwards.

It is not specific to vars(). A rule's v entries and a vars() object land in the same runtime record (calculate-props.ts writes both into inlineVariables), so the ordering governs plain stylesheet CSS, which is the half most consumers write. Measured on main (f70c402), with --my-var declared more than once in each sheet so the compiler cannot fold it:

Tree Contract main
.ancestor { --my-var: red }.own { --my-var: blue; color: var(--my-var) } { color: "blue" } { color: "red" }
<Provider value={{"--my-var": "red"}}>.own { --my-var: blue; color: var(--my-var) } { color: "blue" } { color: "red" }
the same, provider value changed across a re-render stays blue tracks the provider
<Provider value={{"--my-var": "red"}}>style={vars({"--my-var": "blue"})} { color: "blue" } { color: "red" }
the same, plus .my-class { --my-var: blue }, vars() says green { color: "green" } { color: "red" }
two siblings under one provider, one carrying its own vars() red / blue red / red

Web is unaffected and already correct: it implements no precedence of its own — vars() returns a plain {"--x": v} style object and VariableContextProvider renders a display: contents div carrying the property, so the browser's cascade decides. I measured the same tree in chromium. The compiler is unaffected too; it emits v / vr tuples and never orders the two channels.

Fix

The inherited read moves below the element's own record. It keeps pushing the raw descriptor rather than the resolved value, because testGuards compares the guard against the next render's context and a resolved value would never match.

The second read of variables[name] that followed the inline arm is deleted. It was unreachable-effective: it ran only when name was absent from variables, so resolve(variables[name]) was resolve(undefined) and its value !== undefined guard never fired.

Net: 6 insertions, 12 deletions in one function.

Tests

Ten runtime cases across variables.test.tsx (stylesheet channel) and vars.test.tsx (vars() channel). The six in the table above are red on main and green after. The other four are the regression half — an own rule still outranks :root, a declaring element still publishes to its descendants, a parent's vars() still reaches one, and an inherited value still applies when the element declares nothing, so the fix cannot be satisfied by ignoring the context.

Four compile-time cases in a new src/__tests__/compiler/inline-variables.test.ts, because the runtime ones have a precondition that is easy to lose. inline-variables.ts folds a custom property the sheet declares exactly once into the declaration that reads it, and a folded property performs no runtime var() lookup at all — .own { --my-var: blue; color: var(--my-var) } alone compiles to d: [{ color: "#00f" }] and passes on unfixed main without reaching varResolver. Pinning the fold is what keeps every case above from going quietly vacuous if that rule ever changes.

Test Suites: 2 failed, 4 skipped, 54 passed, 56 of 60 total
Tests:       3 failed, 21 skipped, 1062 passed, 1086 total

Mutation-proven against main with the same test files in place: 9 failed, 1056 passed, 1086 total — same total, so the delta is exactly the six cases.

The 3 failures are the pre-existing Windows-only babel-plugin-tester mismatches over an unrewritten relative require("../View"), identical on main (#390 fixes them; CI is green).

What this does not fix, measured

An own declaration whose value is invalid at computed-value time still falls back to the inherited value — .own { --my-var: var(--nothing-declares-this); color: var(--my-var) } under a provider paints the provider's value on main and after this change, where css-variables-1 §3.2 makes the referencing declaration invalid instead.

Keying the own read on presence (name in inlineVariables) rather than on resolving to something would do it, but not on its own: the same function memoises resolved :root and universal values back into options.inlineVariables, so a presence-keyed read would let a :root value masquerade as the element's own on a second lookup in the same pass. That needs the memo moved into a record of its own, which is a larger change and composes with this one.

Quality gates

yarn test       1062 pass (main + these tests: 1056), 21 skipped
                3 fail — pre-existing on main, Windows only
yarn typecheck  clean
yarn lint       clean

Happy to reshape this — the tests are separately revertible commits, and the compiler file can land on its own if you would rather review the fold pin apart from the precedence cases.

…d one

`varResolver` read the inherited variable context before the element's own
record and returned from it, so a `vars()` declaration on the element was used
only when no ancestor declared the same name.

css-cascade-4 §7.2 makes inheritance a defaulting step: an element inherits a
property only when the cascade yields no declared value for it. A custom
property is an ordinary property (css-variables-1 §2), so an element declaring
`--x` uses its own value rather than its ancestor's.

Measured against a provider declaring `--my-var: red` around an element whose
`vars()` declares `--my-var: blue`, with the class reading `color: var(--my-var)`:

  expected  { color: "blue" }
  actual    { color: "red" }

Web is unaffected and already correct — it implements no precedence of its own.
`vars()` returns a plain `{"--x": v}` style object and `VariableContextProvider`
renders a `display: contents` div carrying the property, so the browser's cascade
decides.

The inherited read moves below the element's own record and keeps pushing the
RAW descriptor, which is what `testGuards` compares against the next render's
context. The second read of `variables[name]` that followed it is deleted: it
ran only when `name` was absent from `variables`, so it resolved `undefined` and
its guard never fired.
`inline-variables.ts` folds a custom property the sheet declares exactly once
into the declaration that reads it, so the compiled rule carries no variable and
the runtime performs no `var()` lookup.

That is a precondition every precedence test depends on and nothing stated:
`.own { --my-var: blue; color: var(--my-var) }` alone compiles to
`d: [{ color: "#00f" }]`, so a test written that way passes whatever the
resolver does. Pinning the fold means a change to it goes red here instead of
making those suites quietly vacuous.
A rule's `v` entries and a `vars()` object land in the same runtime record, so
the resolution order governs plain stylesheet CSS too — and that half had no
coverage. Five cases in `variables.test.tsx` and three more in `vars.test.tsx`.

Six are red on main: an own rule against an ancestor's rule, against a
provider, and across a re-render that changes the provider's value; `vars()`
against the element's own class declaration; and two siblings under one
provider. The rest are the regression half — an own rule still outranks
`:root`, a declaring element still publishes to its descendants, and an
inherited value still applies where the element declares nothing.

Every sheet declares the name more than once, or the compiler folds it and the
case measures nothing.
@YevheniiKotyrlo

YevheniiKotyrlo commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — Subject matches the control. The inherited value was consulted first and returned, so the element's own declaration never reached the cascade.

FIXED — Subject is violet, control is red. An element that declares a custom property uses its own value; inheritance is the defaulting step it never reaches.

before — stock 3.0.7 after — with this PR

Both bars sit inside one <VariableContextProvider> declaring the fill red. The control declares nothing of its own, so it inherits. The subject declares the same name for itself in violet through vars() — so the two builds are separated by whether that own declaration wins.

Read the control bar, not the whole frame. Both frames come from the same device in the same run (Android 36 emulator, 1140×2400 @ 480dpi), but before is stock 3.0.7 rather than "this build minus this PR" — so a second, unrelated difference is visible and worth naming rather than leaving for you to spot. The compiler's inlineRem defaults to 14 and our build sets it to 16, so every rem-derived length in the before frame renders at 87.5% of the after one: smaller type, tighter spacing, a shorter probe box. That is a different fix, not this one.

What isolates this change is that the control bar is red in both frames. The provider chain resolves identically on either build, so the only thing that moves is the subject — and only this PR moves it.

The build-probe width=<dp> line is that same rem fold used as a build stamp: a 3rem box, so 42 unfixed and 48 fixed. The capture harness reads it off the device and refuses to save a frame whose probe disagrees with the variant it claims, so a before image cannot silently be a second after.

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