Warn when a base var shadows a var inherited from a parent state - #7077
Warn when a base var shadows a var inherited from a parent state#7077blockgroot wants to merge 2 commits into
Conversation
A substate field whose name matches an inherited base var is dropped silently: get_skip_vars() includes inherited_vars, so base_vars filters the field out. _init_var never runs for it, class access returns the raw default instead of a Var, and reads and writes are delegated to the parent. A component built from it renders a static value rather than a reactive binding, with no diagnostic anywhere. Emit a deduped console.warn at class creation naming the var and both states. A redeclaration whose purpose is to win over a descriptor reached through a non-state base is exempt, since re-annotating is how that MRO conflict is resolved (see test_hybrid_property_shadowed_by_closer_base_stays_a_field). Warning rather than raising, per the repository's policy of not breaking downstream users; the shadowing computed-var guards raise, so this can be escalated later if preferred. Also drops a redundant `is_hydrated: bool = False` from DynamicState in tests/units/test_app.py: it shadowed the identical root State default, so it was already a no-op. Fixes reflex-dev#7074
|
There was a problem hiding this comment.
1 issue found across 4 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="reflex/state.py">
<violation number="1" location="reflex/state.py:1129">
P2: When a parent state already overrides a descriptor from a non-state mixin, a child redeclaration is still a shadow, but this scan suppresses its warning. Stop at the first ancestor defining `name` so only the effective non-state descriptor exempts the redeclaration.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| return any( | ||
| not issubclass(base, BaseState) | ||
| and _is_user_descriptor(base.__dict__[name], include_properties=True) | ||
| for base in cls.__mro__ | ||
| if name in base.__dict__ | ||
| ) | ||
|
|
There was a problem hiding this comment.
P2: When a parent state already overrides a descriptor from a non-state mixin, a child redeclaration is still a shadow, but this scan suppresses its warning. Stop at the first ancestor defining name so only the effective non-state descriptor exempts the redeclaration.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At reflex/state.py, line 1129:
<comment>When a parent state already overrides a descriptor from a non-state mixin, a child redeclaration is still a shadow, but this scan suppresses its warning. Stop at the first ancestor defining `name` so only the effective non-state descriptor exempts the redeclaration.</comment>
<file context>
@@ -1110,6 +1116,63 @@ def _check_overridden_computed_vars(cls) -> None:
+ Returns:
+ True if a non-state base in the MRO defines name as a user descriptor.
+ """
+ return any(
+ not issubclass(base, BaseState)
+ and _is_user_descriptor(base.__dict__[name], include_properties=True)
</file context>
| return any( | |
| not issubclass(base, BaseState) | |
| and _is_user_descriptor(base.__dict__[name], include_properties=True) | |
| for base in cls.__mro__ | |
| if name in base.__dict__ | |
| ) | |
| for base in cls.__mro__[1:]: | |
| if name not in base.__dict__: | |
| continue | |
| return ( | |
| not issubclass(base, BaseState) | |
| and _is_user_descriptor( | |
| base.__dict__[name], include_properties=True | |
| ) | |
| ) | |
| return False |
The first version exempted any redeclaration whose name appeared as a descriptor on a non-state base anywhere in the MRO. That is broader than the pattern it exists for: in Child(DescriptorMixin, ParentState) the descriptor precedes the state field, the child's declaration is still discarded, and class access still returns the raw default rather than a Var -- yet the warning was suppressed. Exempt only when a state base declaring the name precedes the same-named non-state descriptor, which is the ordering in which the field already wins and the re-annotation is therefore inert. Both conditions are required: with no descriptor in the MRO at all, an ordinary redeclaration still warns. This tracks whether the declaration actually breaks class access: warnings now fire exactly in the cases where the shadowed name no longer resolves to a Var. Adds test_base_var_shadowing_warns_when_descriptor_outranks_state_field.
|
Thanks both — Greptile's P1 was valid and is fixed in The exemption was too broad: it accepted a same-named descriptor anywhere in the MRO without checking whether that descriptor was actually the reason for the redeclaration. In @cubic-dev-ai — same root cause, but I didn't apply the suggested patch, because stopping at the first ancestor defining the name inverts two of the three cases:
The third column is the invariant that fell out of this: the warning fires exactly when the shadowed name stops resolving to a Regression test added for row 2; |
@blockgroot You're right, and that's a fair correction to my earlier suggestion. The "stop at the first ancestor that defines the name" rule I proposed was too coarse — it keys off MRO position alone, not on whether the redeclaration is actually doing anything. Your table shows exactly where it breaks: it can't distinguish "the descriptor swallowed the field" (row 2, should warn) from "the field still wins and the re-annotation is inert" (row 3, shouldn't). I checked the implemented logic against your three rows and it lands where you say:
And the invariant you landed on is the meaningful one: warn iff the shadowed name stops resolving to a Only thing I'd flag as a residual edge, not a blocker: the exemption keys entirely on ordering in the MRO, so a same-named descriptor that appears before the state field but doesn't actually swallow the field (e.g. a non-data descriptor) would still get a warning even though Tip: get faster answers by chatting with cubic’s review copilot in the review UI |
|
|
||
|
|
||
| def _is_user_descriptor(value: Any) -> bool: | ||
| def _is_user_descriptor(value: Any, *, include_properties: bool = False) -> bool: |
There was a problem hiding this comment.
something about this phrasing reads as confusing to me, because calling the function with include_properties=True is like saying "include properties in the list of explicitly excluded descriptors that should NOT be considered user descriptors".
i think it's more clear to name this parameter as exclude_properties: bool = True. that way the new call site reads as _is_user_descriptor(base.__dict__[name], exclude_properties=False), which is interpreted as "do not exclude properties when determining if a value is a user descriptor". it seems like the more natural expression of what is being requested.
also generally speaking, i prefer to add optional kwargs with behavior-preserving defaults in the affirmative unless there's a clear reason to deviate.
Problem
Fixes #7074. When a substate declares a field whose name matches a base var it inherits, the declaration is discarded with no warning and no error.
get_skip_vars()includesset(cls.inherited_vars)andbase_varsfilters on it, so the field stays inget_fields()but never becomes a base var:_init_varnever runs, class-level access returns pydantic's raw default instead of aVar, and instance reads and writes are delegated to the parent.The reporter's repro, unchanged:
child.xis1(anint), a write tochild.xlands onparent.x,child.dirty_varsstays empty, andrepr(Child.x)is a plainstr— sorx.text(Child.x)compiles a literal into the page instead of a reactive binding.Change
BaseState._check_overridden_inherited_vars()runs at class creation and emits a dedupedconsole.warnnaming the var and both states. A field redeclared on a class is a distinctFieldobject from the parent's, while a merely inherited one is the same object — that identity check is the discriminator, since on Python 3.14 a bare annotation carries no marker distinguishing it from an explicit assignment (__annotations__is not even populated incls.__dict__).Why warn instead of raise
The issue asks for a raise, and the shadowing computed var guards do raise (
ComputedVarShadowsStateVarError). I went with a warning becauseCLAUDE.mdsays "Reflex has downstream users — don't break them", and raising turns code that runs today into an import-time crash. The warning fully addresses the reported failure mode (silence), and escalating to an exception later is a small change. Happy to switch it if you'd rather have the hard error.One deliberate exemption
tests/units/vars/test_hybrid_property.py::test_hybrid_property_shadowed_by_closer_base_stays_a_fieldre-annotates an inherited var on purpose, in a hierarchy where the state field already outranks ahybrid_propertyreached through a non-state base. A redeclaration is therefore skipped only when both a same-named non-state descriptor exists in the MRO and a state base declaring the name precedes it — the ordering in which the field wins anyway, making the re-annotation inert. With no descriptor in the MRO, an ordinary redeclaration still warns._is_user_descriptorgained aninclude_propertieskeyword for this (defaultFalse, so the existing call site is unchanged) becauseHybridPropertysubclassesproperty, which that helper otherwise excludes.The resulting invariant: the warning fires exactly when the shadowed name stops resolving to a
Varat class level, which is the case that actually breaks a component. See the table in the review thread for the three hierarchies this was measured against.Incidental
DynamicStateintests/units/test_app.pydeclaredis_hydrated: bool = False, shadowing the rootStatevar of the same name and identical default — already a no-op, and the first thing the new warning flagged. Removed.Testing
Three tests in
tests/units/test_state.py:test_base_var_shadowing_inherited_var_warns— fails before this change, passes after.test_base_var_shadowing_non_state_descriptor_does_not_warn— guards the exemption so the warning can't over-fire on the legitimate MRO pattern.test_base_var_shadowing_warns_when_descriptor_outranks_state_field— added after review, locks out the false negative where a descriptor precedes the state field.uv run pytest tests/units→ 8442 passed, 18 skipped.ruff check,ruff format --checkandpyright reflex/state.py tests/units/test_state.pyare clean. News fragment added atnews/7074.bugfix.md.