A state mixin can silently shadow an inherited base var

Open
#7,190 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
74/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
python
Domain
full-stack

Research direction

Start in reflex/state.py at BaseState._check_overridden_inherited_vars and reproduce the mixin example from the issue. Trace how the inherited name and class attribute resolve through the MRO, then add a regression test covering class-level access and the shadowing error. Done means mixin redeclarations are rejected or remain reactive without silently replacing the inherited Var.

Written by the indexing model from the issue text.

Description

Summary

_check_overridden_inherited_vars (added in #7077) rejects a base var that shadows one inherited from a parent state, but the check is bypassed when the redeclaration is laundered through a mixin=True state. The shadow still happens — it just isn't reported.

The consequence is compile-time: class-level access to the name stops returning a Var and returns the mixin's raw default instead, so any frontend reference to it is baked into the page as a frozen literal with no state subscription.

Reproduction
import reflex as rx

class Parent(rx.State):
    ordinary_var: str = "parent"

# Direct redeclaration -> correctly rejected
class DirectShadow(Parent):
    ordinary_var: str = "shadowed-directly"
# BaseVarShadowsInheritedVarError: The var `ordinary_var` in ... shadows a var
# inherited from ...; use a different name instead

# The same redeclaration through a mixin -> accepted
class OrdinaryMixin(rx.State, mixin=True):
    ordinary_var: str = "shadowed-via-mixin"

class MixinShadow(OrdinaryMixin, Parent):
    pass

MixinShadow is created without complaint. Observed state:

Parent.ordinary_var                            -> StringCastedVar(...)   # a Var
MixinShadow.ordinary_var                       -> 'shadowed-via-mixin'   # a plain str
'ordinary_var' in MixinShadow.base_vars        -> False
'ordinary_var' in MixinShadow.inherited_vars   -> True

So the var machinery still (correctly) treats the parent as the owner, while the class attribute has been overwritten by the mixin's raw default.

Rendering it compiles a constant:

rx.text(MixinShadow.ordinary_var).render()["children"]
# [{'contents': '"shadowed-via-mixin"'}]   -- a literal, not a state subscription

Instance-level behaviour is unaffected once the state is wired into a tree — reads and writes delegate to the parent and the delta is correct:

c.ordinary_var                 -> 'parent'      (mixin default silently discarded)
c.ordinary_var = "written"
p.ordinary_var                 -> 'written'
p.get_delta()                  -> {'reflex___state____state.__main______parent': {'ordinary_var_rx_state_': 'written'}}

Two things are wrong, then: the mixin's declared default is silently dropped, and class-level access is no longer reactive.

Root cause

BaseState._check_overridden_inherited_vars (reflex/state.py) skips any field not present in cls.__dict__:

if (
    name.startswith("_")
    or not own_field.is_var
    or name not in cls.inherited_vars
    or name not in cls.__dict__
):
    continue

With a mixin, the raw default lives in OrdinaryMixin.__dict__, not in MixinShadow.__dict__ — so the guard skips the name. The default still resolves through the MRO, which is what clobbers class-level access. The cls.__dict__ test is there to let a bare re-annotation (which leaves no class attribute, and so stays reactive) through as inert; it needs to look along the MRO for a non-Var class attribute instead of only at the leaf class.

Related gap

A direct BaseState subclass starts its own state root, so get_parent_state() returns None and the check returns early. Nothing inherited exists to shadow at that point, so this is arguably correct, but it means the guard offers no protection for the framework's own root-level vars in that position.

Why this came up

Found while working on #7068 (splitting router into per-field base vars). The framework's rx_router_* vars are protected by exactly this guard, so the mixin route bypasses their protection too:

class RouterMixin(rx.State, mixin=True):
    rx_router_url: str = "hijacked"

class Consumer(RouterMixin, rx.State):
    pass

Consumer.rx_router_url  # -> 'hijacked', not a Var

Consumer.router.url still renders correctly (the switchboard reads the per-field vars directly) and instance-level router access is unaffected, so this is not a router-specific defect — the router is just one more caller that inherits the general gap. Filing separately from #7068 for that reason.

Related: #7074, #7077.

Dominant language
Python
Stars
28.9k
Forks
1.8k
Avg merge
1d 20h
Merged PRs (30d)
173

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from reflex-dev/reflex

All issues in reflex-dev/reflex

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.