Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/6757.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Speed up state attribute access: framework bookkeeping fields (dirty_vars, parent_state, substates, _backend_vars) resolve via the fast path, and get_skip_vars() plus get_delta's frontend computed var set are cached per class.
80 changes: 63 additions & 17 deletions reflex/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,10 @@ def _bump_state_hierarchy_generation() -> None:
_state_hierarchy_generation += 1


# Attribute names resolved directly via object.__getattribute__, bypassing the
# inherited-var / event-handler / proxying logic in _get_attribute. Class-level
# tracking dicts as well as per-instance framework bookkeeping fields that are
# never state vars belong here.
CLASS_VAR_NAMES = frozenset({
"vars",
"base_vars",
Expand All @@ -391,6 +395,11 @@ def _bump_state_hierarchy_generation() -> None:
"_always_dirty_substates",
"_potentially_dirty_states",
"_interval_computed_vars",
"parent_state",
"substates",
"dirty_vars",
"dirty_substates",
"_backend_vars",
})


Expand Down Expand Up @@ -434,6 +443,12 @@ class BaseState(EvenMoreBasicBaseState):
# computed_vars changes. Empty for most classes, which lets dirty
# propagation skip the per-mutation expiry scan.
_interval_computed_vars: ClassVar[tuple[str, ...]] = ()
# Cached result of get_skip_vars(), rebuilt lazily after inherited_vars changes.
_skip_var_names: ClassVar[frozenset[str] | None] = None

# Cached names of non-backend computed vars, rebuilt lazily after
# computed_vars changes.
_frontend_computed_vars: ClassVar[frozenset[str] | None] = None

# The parent state.
parent_state: BaseState | None = field(default=None, is_var=False)
Expand Down Expand Up @@ -878,6 +893,7 @@ def computed_var_func(state: Self):
setattr(cls, unique_var_name, computed_var_func_arg)
cls.computed_vars[unique_var_name] = computed_var_func_arg
cls._refresh_interval_computed_vars()
cls._frontend_computed_vars = None
cls.vars[unique_var_name] = computed_var_func_arg
cls._update_substate_inherited_vars({unique_var_name: computed_var_func_arg})
cls._always_dirty_computed_vars.add(unique_var_name)
Expand Down Expand Up @@ -1042,23 +1058,42 @@ def _check_overridden_computed_vars(cls) -> None:
raise ComputedVarShadowsStateVarError(msg)

@classmethod
def get_skip_vars(cls) -> set[str]:
def get_skip_vars(cls) -> frozenset[str]:
"""Get the vars to skip when serializing.

Returns:
The vars to skip when serializing.
"""
return (
set(cls.inherited_vars)
| {
"parent_state",
"substates",
"dirty_vars",
"dirty_substates",
"router_data",
}
| types.RESERVED_BACKEND_VAR_NAMES
)
# Cached per class; invalidated when inherited_vars changes.
if (skip_vars := cls.__dict__.get("_skip_var_names")) is None:
skip_vars = (
frozenset(cls.inherited_vars)
| {
"parent_state",
"substates",
"dirty_vars",
"dirty_substates",
"router_data",
}
| types.RESERVED_BACKEND_VAR_NAMES
)
cls._skip_var_names = skip_vars
return skip_vars

@classmethod
def _get_frontend_computed_var_names(cls) -> frozenset[str]:
"""Get the names of computed vars that are sent to the frontend.

Returns:
The names of non-backend computed vars.
"""
# Cached per class; invalidated when computed_vars changes.
if (cached := cls.__dict__.get("_frontend_computed_vars")) is None:
cached = frozenset(
name for name, cv in cls.computed_vars.items() if not cv._backend
)
cls._frontend_computed_vars = cached
return cached

@classmethod
@functools.lru_cache
Expand Down Expand Up @@ -1259,9 +1294,16 @@ def add_var(cls, name: str, type_: Any, default_value: Any = None):
cls.base_vars.update({name: var})
cls.vars.update({name: var})

# let substates know about the new variable
for substate_class in cls.get_substates():
# Let all descendants know about the new variable: grandchildren see
# it through their aliased inherited_vars dicts, so their cached
# skip-var frozensets must be invalidated too, not just the direct
# children's.
descendants = [*cls.get_substates()]
while descendants:
substate_class = descendants.pop()
descendants.extend(substate_class.get_substates())
substate_class.vars.setdefault(name, var)
substate_class._skip_var_names = None
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
Alek99 marked this conversation as resolved.
Comment thread
Alek99 marked this conversation as resolved.

# Reinitialize dependency tracking dicts.
cls._init_var_dependency_dicts()
Expand Down Expand Up @@ -1389,6 +1431,8 @@ def _update_substate_inherited_vars(cls, vars_to_add: builtins.dict[str, Var]):
substate_class.vars.setdefault(name, var)
substate_class.inherited_vars.setdefault(name, var)
substate_class._update_substate_inherited_vars(vars_to_add)
# The skip vars cache incorporates inherited_vars.
substate_class._skip_var_names = None
# Reinitialize dependency tracking dicts.
cls._init_var_dependency_dicts()

Expand Down Expand Up @@ -1459,6 +1503,7 @@ def inner_func(self: BaseState) -> list[str]:
# Update tracking dicts.
cls.computed_vars.update(dynamic_vars)
cls._refresh_interval_computed_vars()
cls._frontend_computed_vars = None
cls.vars.update(dynamic_vars)
cls._update_substate_inherited_vars(dynamic_vars)

Expand Down Expand Up @@ -1532,6 +1577,9 @@ def _get_attribute(self, name: str) -> Any:
if parent_state is not None:
return getattr(parent_state, name)

# is_mutable_type first: it is an lru_cache hit and False for the
# scalar reads that dominate this path, so it short-circuits before
# the base_vars fetch + membership tests.
if is_mutable_type(type(value)) and (
name in super().__getattribute__("base_vars") or name in backend_vars
):
Expand Down Expand Up @@ -1952,9 +2000,7 @@ def get_delta(self) -> Delta:
delta = {}

self._mark_dirty_computed_vars()
frontend_computed_vars: set[str] = {
name for name, cv in self.computed_vars.items() if not cv._backend
}
frontend_computed_vars = type(self)._get_frontend_computed_var_names()

# Return the dirty vars for this instance, any cached/dependent computed vars,
# and always dirty computed vars (cache=False)
Expand Down
84 changes: 84 additions & 0 deletions tests/units/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -1510,6 +1510,90 @@ def timed(self) -> int:
assert IntervalState._interval_computed_vars == ("timed",)


def test_get_skip_vars_cached_per_class():
"""get_skip_vars is cached per class and includes inherited vars."""

class SkipVarsParentState(BaseState):
p: int = 0

class SkipVarsChildState(SkipVarsParentState):
c: int = 0

parent_skip = SkipVarsParentState.get_skip_vars()
assert SkipVarsParentState.get_skip_vars() is parent_skip
assert "parent_state" in parent_skip
child_skip = SkipVarsChildState.get_skip_vars()
assert child_skip is not parent_skip
assert "p" in child_skip
assert "p" not in parent_skip
Comment thread
Alek99 marked this conversation as resolved.


def test_get_skip_vars_invalidated_on_add_var_recursively():
"""add_var refreshes the skip-vars cache for every descendant.

Grandchildren observe the new var through their aliased inherited_vars
dicts, so a stale per-class frozenset would make get_skip_vars() disagree
with inherited_vars and route the var down the wrong __setattr__ path.
"""

class SkipInvalidationRootState(BaseState):
r: int = 0

class SkipInvalidationChildState(SkipInvalidationRootState):
c: int = 0

class SkipInvalidationGrandchildState(SkipInvalidationChildState):
g: int = 0

# Populate every cache before the dynamic var exists.
for klass in (
SkipInvalidationRootState,
SkipInvalidationChildState,
SkipInvalidationGrandchildState,
):
assert "dyn_added" not in klass.get_skip_vars()

SkipInvalidationRootState.add_var("dyn_added", str, "")

assert "dyn_added" in SkipInvalidationChildState.inherited_vars
assert "dyn_added" in SkipInvalidationChildState.get_skip_vars()
assert "dyn_added" in SkipInvalidationGrandchildState.inherited_vars
assert "dyn_added" in SkipInvalidationGrandchildState.get_skip_vars()


def test_frontend_computed_var_names_cached_per_class():
"""Frontend computed var names exclude backend vars and cache per class."""

class FrontendCvarState(BaseState):
@rx.var
def visible(self) -> int:
return 1

@rx.var(backend=True)
def _hidden(self) -> int:
return 2

names = FrontendCvarState._get_frontend_computed_var_names()
assert "visible" in names
assert "_hidden" not in names
assert FrontendCvarState._get_frontend_computed_var_names() is names


def test_framework_bookkeeping_fields_not_proxied():
"""Framework bookkeeping containers are returned raw, not proxied."""

class BookkeepingState(BaseState):
values: list[int] = []

state = BookkeepingState()
assert type(state.dirty_vars) is set
assert type(state.substates) is dict
assert type(state._backend_vars) is dict
assert state.parent_state is None
# Actual state vars still get proxied for mutation tracking.
assert isinstance(state.values, MutableProxy)


def test_computed_var_cached_depends_on_non_cached():
"""Test that a cached var is recalculated if it depends on non-cached ComputedVar."""

Expand Down
Loading