Skip to content

varResolver: a var() resolved from the :root/inline/universal fallback isn't guarded — a token provided via context after first render never re-resolves #377

Description

@lavaxun

Summary

In varResolver (src/native/styles/variables.ts), a re-render dependency guard is recorded only on the name in variables (inherited VariableContext) path. When a var() is instead resolved from the inline / universal / :root fallback, the function returns without pushing a guard. As a result, a token that is absent from context at first render (so it resolves from :root) and is later provided via a nearer VariableContextProvider is never re-resolved on an already-mounted node — the node stays at the :root/fallback value permanently.

It's timing-sensitive: it only manifests when the context value arrives after the first paint of the consuming node. On a fast first paint the token is already in context (guarded → works); on a slower first paint (e.g. Android/Hermes committing before an async provider updates) the node latches the fallback value forever.

Version

react-native-css@3.0.7

Repro

A styled/useCssElement node whose var(--x) starts unset (resolves from :root) and is later given a value via a VariableContextProvider higher in the tree keeps the :root value — it never re-resolves when --x subsequently appears in context.

Concretely: render a node using var(--brand) where --brand is not yet provided by any context; later push --brand into a VariableContextProvider. The mounted node keeps the :root/fallback value instead of updating.

Root cause

varResolver pushes renderGuards.push(["v", name, variables[name]]) inside the if (name in variables) { … } branch and then returns. The other path — reached when name is not in variables (it runs variableHistory.add(name) and then the inline → universal → :root → fallback lookups) — returns without recording any guard for name. So testGuards watches nothing for that token, and a later context change for it never triggers re-resolution of the mounted node.

Proposed fix

Record a guard for the "currently absent from context" state as well — push renderGuards.push(["v", name, variables[name]]) immediately after variableHistory.add(name) (before the fallback lookups). variables[name] is undefined there, which is the correct sentinel: the guard then fires precisely when the token later appears in context, and there is no value shadow (the returned value is still the :root/fallback until context provides one). It terminates cleanly — exactly one extra re-resolve on the absent→present transition.

Context

We hit this in a production React Native app where channel/brand CSS variables are supplied asynchronously (fetched, then injected as a delta over the :root defaults). iOS happened to paint after the fetch (token in context → guarded → correct); Android painted before it and latched the default. We're currently carrying a local patch that applies the one-line fix above; filing upstream so it can live in the library. Happy to open a PR if that's useful.

(Distinct from #245, which is about a memory leak on re-render.)

Activity

  1. github-actions commented on Jul 15, 2026

    @github-actions

    🤖 Auto-triage

    Status: CONFIRMED
    Type: bug
    Version: v5 (react-native-css@3.0.7)

    Findings

    Reproduced the bug via a Jest unit test. A node whose var(--brand) initially resolves from :root (returning #f00) does not re-resolve when --brand is later injected via a VariableContextProvider higher in the tree — the node stays at the :root value (#f00) instead of updating to the new context value (#00f). Root-cause analysis in the issue is accurate: varResolver in src/native/styles/variables.ts:49-52 only pushes a renderGuard on the name in variables branch; the fallback path (inline → universal → :root → fallback) returns without recording a guard for the absent-from-context state, so testGuards never observes the later absent→present transition.

    What I tested

    • Read src/native/styles/variables.ts and traced the guard-push paths against the reported code path.
    • Wrote a minimal Jest repro: initial render with :root { --brand: red } and .test { color: var(--brand) }, then a state-driven rerender that wraps the same node in <VariableContextProvider value={{ "--brand": "blue" }}>.
    • Result: post-update style was { color: "#f00" } instead of the expected { color: "#00f" } — confirms the latch on the :root fallback.
    • Confirmed current published version is 3.0.7 (matches reporter).

    Next steps

    • Reporter's proposed one-line fix — pushing renderGuards.push(["v", name, variables[name]]) (i.e. undefined) immediately after variableHistory.add(name) in src/native/styles/variables.ts:54 — matches the guard semantics used elsewhere in the resolver and should cause exactly one extra re-resolve on the absent→present transition without a stale-value shadow.
    • Reporter offered a PR; worth accepting given they've already vetted it in production.
    • Consider adding a regression test covering this transition (initial :root-only → later VariableContextProvider injection) alongside the existing VariableContextProvider test in src/__tests__/native/variables.test.tsx.

    This is an automated triage. See auto-triage.yml.

  2. added
    auto-triagedIssue has been automatically triaged by the auto-triage workflow
    bugSomething isn't working
    confirmedBug reproduced and confirmed by triage
    on Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    auto-triagedIssue has been automatically triaged by the auto-triage workflowbugSomething isn't workingconfirmedBug reproduced and confirmed by triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions