Skip to content

Raise a clear error when a hybrid property reads a backend var on a state#6621

Open
masenf wants to merge 1 commit into
mainfrom
claude/hybrid-property-backend-var-guard
Open

Raise a clear error when a hybrid property reads a backend var on a state#6621
masenf wants to merge 1 commit into
mainfrom
claude/hybrid-property-backend-var-guard

Conversation

@masenf

@masenf masenf commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Type of change

  • New feature (non-breaking change which adds functionality)

Note

Stacked on #6619 — base branch is claude/relaxed-cerf-Z110q. This PR contains only the backend-var guard; review it against that base.

Description

A hybrid property's frontend logic (its getter, or a custom @<name>.var function) runs with the state class as self when building the frontend var. Reading a backend (underscore-prefixed) var there previously baked the var's class-level default into the frontend as a frozen literal — a silent leak that never updates and is not reactive.

This PR makes that misuse fail loudly with a clear, actionable error.

Changes:

  1. HybridProperty._get_var guards the state owner (packages/reflex-base/src/reflex_base/vars/hybrid_property.py): when a hybrid property is resolved against a BaseState, the owner is wrapped in a _StateBackendVarGuard. Accessing a backend var while building the frontend var raises, with the traceback pointing at the offending line in the user's getter/.var function. Object-var owners (nested dataclass / pydantic / SQLAlchemy access) have no backend vars and are passed through unchanged.

  2. New HybridPropertyError (packages/reflex-base/src/reflex_base/utils/exceptions.py): a dedicated ReflexError — deliberately not an AttributeError, so it can't be silently swallowed by getattr(..., default) / hasattr — whose message names the property, the state, and the offending backend var, and suggests using a regular var or a separate @<name>.var implementation.

Tests

tests/units/vars/test_hybrid_property.py:

  • backend-var access raises HybridPropertyError from both the getter and a custom .var function
  • frontend-only logic still builds the expected var
  • the guard is state-only — underscore fields accessed through an object var are unaffected

https://claude.ai/code/session_01DKFiYGnWRQG8wMNKFW7obm

@masenf masenf requested a review from a team as a code owner June 5, 2026 22:12
@codspeed-hq

codspeed-hq Bot commented Jun 5, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing claude/hybrid-property-backend-var-guard (9c1894f) with main (590711e)

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@greptile-apps

greptile-apps Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a runtime guard that raises HybridPropertyError when a hybrid property's frontend logic (getter or custom .var() function) tries to read a backend (underscore-prefixed) state var, converting a previously silent data leak into a loud, actionable error.

  • _StateBackendVarGuard wraps the state class during frontend-var construction; its __getattr__ checks the name against state_cls.backend_vars (which includes inherited backend vars per state.py lines 606–608) and raises before any server-side default can be baked into the frontend.
  • HybridPropertyError inherits from ReflexError rather than AttributeError, so it cannot be silently swallowed by getattr(..., default) or hasattr — a deliberate and correct design choice.
  • The guard is correctly scoped: it is only applied in the BaseState branch of __get__; the ObjectVar.__getattr__ path calls _get_var directly and is left unguarded, preserving underscore-field access on plain dataclasses/models.

Confidence Score: 5/5

Safe to merge — the change is additive (raises where it previously silently misbehaved) and is well-scoped to the state-class code path.

The guard logic is correct: backend_vars on a state class already includes inherited backend vars, so no backend var can slip through. The __getattr__-based interception is the right hook point since backend vars are not real class attributes. Tests cover the getter, custom .var(), frontend-only, and ObjectVar paths. No existing behaviour is broken.

No files require special attention.

Important Files Changed

Filename Overview
packages/reflex-base/src/reflex_base/vars/hybrid_property.py Adds _StateBackendVarGuard proxy and wires it into HybridProperty.__get__; logic is correct and the guard is applied only on the State class path, not the ObjectVar path.
packages/reflex-base/src/reflex_base/utils/exceptions.py Adds HybridPropertyError(ReflexError) — minimal, correctly inherits from ReflexError rather than AttributeError so it cannot be silently swallowed.
tests/units/vars/test_hybrid_property.py New test file covering getter backend-var access, custom .var() backend-var access, valid frontend-only access, and the ObjectVar non-guard case.
news/6621.feature.md Changelog entry for the new guard behaviour; content is accurate.
packages/reflex-base/news/6621.feature.md Package-level changelog entry for HybridPropertyError; content is accurate.

Reviews (3): Last reviewed commit: "feat: raise a clear error when a hybrid ..." | Re-trigger Greptile

Comment thread packages/reflex-base/src/reflex_base/vars/object.py Outdated
Comment on lines +56 to +60
class HybridProperty(property):
"""A hybrid property that can also be used in frontend/as var."""

# The optional var function for the property.
_var: Callable[[Any], Var] | None = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _var class attribute is mutable via instance sharing through inheritance

_var is defined as a class attribute (None) and set as an instance attribute by var(). When a HybridProperty is defined on a mixin and multiple classes inherit it without overriding, all subclasses share the exact same descriptor object. Calling var() on that shared object mutates it for all inheritors simultaneously — a subclass cannot independently override just the .var() function of an inherited HybridProperty without redefining the whole property. A short note in the docstring would help users avoid the pitfall.

@masenf masenf force-pushed the claude/hybrid-property-backend-var-guard branch 2 times, most recently from 977c6c2 to c44fc0a Compare June 5, 2026 23:51
@masenf masenf changed the base branch from main to claude/relaxed-cerf-Z110q June 6, 2026 00:16
@masenf masenf changed the title Support hybrid_property on dataclasses, pydantic models, and SQLAlchemy models Raise a clear error when a hybrid property reads a backend var on a state Jun 6, 2026
Base automatically changed from claude/relaxed-cerf-Z110q to main June 19, 2026 16:14
@masenf masenf force-pushed the claude/hybrid-property-backend-var-guard branch from c44fc0a to bff3fa1 Compare June 19, 2026 16:21
…on a state

A hybrid property's frontend logic (its getter, or a custom @<name>.var
function) runs with the state class as `self` when building the frontend var.
Reading a backend (underscore-prefixed) var there previously baked the var's
class-level default into the frontend as a frozen literal — a silent leak that
never updates and is not reactive.

HybridProperty.__get__ now wraps a state owner in a _StateBackendVarGuard while
building the frontend var; reading a backend var raises HybridPropertyError,
pointing at the misuse in the user's getter/.var function. Object-var owners
(nested dataclass / pydantic / SQLAlchemy access) have no backend vars and are
unaffected. The guard lives at the single point where state-ness is determined,
so there is no redundant BaseState lookup.

https://claude.ai/code/session_01DKFiYGnWRQG8wMNKFW7obm
@masenf masenf force-pushed the claude/hybrid-property-backend-var-guard branch from bff3fa1 to 9c1894f Compare June 19, 2026 16:26
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.

2 participants