Skip to content

A state mixin can silently shadow an inherited base var #7190

Description

@masenf

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.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions