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/6742.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`Var.to()` and `Var.guess_type()` resolve their target Var subclass through cached registry lookups instead of scanning the full registry with `safe_issubclass` on every call.
1 change: 1 addition & 0 deletions packages/reflex-base/news/6742.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`Var.to()` and `Var.guess_type()` resolve their target Var subclass through cached registry lookups instead of scanning the full registry with `safe_issubclass` on every call (~70% of the cost of constructing a var operation).
134 changes: 109 additions & 25 deletions packages/reflex-base/src/reflex_base/vars/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,93 @@ class VarSubclassEntry:
_var_literal_subclasses: list[tuple[type[LiteralVar], VarSubclassEntry]] = []


@functools.cache
def _var_subclass_for_conversion(python_type: GenericType) -> VarSubclassEntry | None:
"""Find the registry entry ``Var.to`` maps a python type to.

Later-registered entries take priority, matching the reversed scan the
cache replaces. The registry only grows at import time; registration
clears this cache (see ``Var.__init_subclass__``).

Args:
python_type: The (origin-normalized) python type to look up.

Returns:
The matching entry, or ``None`` if no entry matches.
"""
for var_subclass in reversed(_var_subclasses):
if python_type in var_subclass.python_types or safe_issubclass(
python_type, var_subclass.python_types
):
return var_subclass
return None


@functools.cache
def _var_subclass_matching_python_types(
python_types: tuple[GenericType, ...],
) -> VarSubclassEntry | None:
"""Find the registry entry whose python types cover all ``python_types``.

Used by ``Var.guess_type`` with the (origin-normalized) inner types of
the var type — a 1-tuple for plain types, the union members otherwise.
Later-registered entries take priority; registration clears this cache.

Args:
python_types: The python types that must all match one entry.

Returns:
The matching entry, or ``None`` if no entry matches.
"""
for var_subclass in reversed(_var_subclasses):
if all(
safe_issubclass(python_type, var_subclass.python_types)
for python_type in python_types
):
return var_subclass
return None


@functools.cache
def _var_subclass_for_var_output(output: type) -> VarSubclassEntry | None:
"""Find the registry entry for a ``Var``-subclass conversion target.

Later-registered entries take priority; registration clears this cache.

Args:
output: The ``Var`` subclass passed to ``Var.to``.

Returns:
The matching entry, or ``None`` if no entry matches.
"""
for var_subclass in reversed(_var_subclasses):
if safe_issubclass(output, var_subclass.var_subclass):
return var_subclass
return None


def _clear_var_subclass_lookup_caches() -> None:
"""Drop cached registry lookups after a new Var subclass registers."""
_var_subclass_for_conversion.cache_clear()
_var_subclass_matching_python_types.cache_clear()
_var_subclass_for_var_output.cache_clear()


def _register_var_subclass_entry(entry: VarSubclassEntry) -> None:
"""Register a Var subclass entry and invalidate cached lookups.

Every append to ``_var_subclasses`` must go through here — including
manual registrations like ``ReflexURLVar`` — since a bare append would
leave previously cached lookups returning stale results for types the
new entry claims.

Args:
entry: The entry to append to the registry.
"""
_var_subclasses.append(entry)
_clear_var_subclass_lookup_caches()


_AppWrap = TypeVar("_AppWrap", bound="BaseComponent")


Expand Down Expand Up @@ -586,7 +673,9 @@ class ToVarOperation(ToOperation, cls):
)
ToVarOperation.__name__ = new_to_var_operation_name
Comment thread
greptile-apps[bot] marked this conversation as resolved.

_var_subclasses.append(VarSubclassEntry(cls, ToVarOperation, python_types))
_register_var_subclass_entry(
VarSubclassEntry(cls, ToVarOperation, python_types)
)

def __post_init__(self):
"""Post-initialize the var.
Expand Down Expand Up @@ -913,11 +1002,9 @@ def to(
fixed_output_type = get_origin(output) or output

# If the first argument is a python type, we map it to the corresponding Var type.
for var_subclass in _var_subclasses[::-1]:
if fixed_output_type in var_subclass.python_types or safe_issubclass(
fixed_output_type, var_subclass.python_types
):
return self.to(var_subclass.var_subclass, output)
conversion_entry = _var_subclass_for_conversion(fixed_output_type)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard cached lookups against unhashable types

When output is a real class whose metaclass defines equality but no hash, this call now raises TypeError before reaching the existing object-var fallback or _var_type replacement, because _var_subclass_for_conversion is wrapped in functools.cache and hashes fixed_output_type. The previous reversed scan did not require type objects to be hashable, and the same cached-key regression can also hit guess_type() via _var_subclass_matching_python_types; please fall back to the uncached scan or otherwise handle unhashable type keys.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not a regression, so no fallback added: every one of these call sites is preceded by get_origin(...) on the same key (fixed_output_type = get_origin(output) or output, and guess_type normalizes via value_inside_optional/get_origin first). types.get_origin falls through to _get_origin_cached, which is @lru_cache-wrapped — so a class whose metaclass defines __eq__ without __hash__ already raised TypeError there on main, before ever reaching the registry scan. The new caches don't change the set of types that can pass through to()/guess_type().


Generated by Claude Code

if conversion_entry is not None:
return self.to(conversion_entry.var_subclass, output)

if fixed_output_type is None:
return get_to_operation(NoneVar).create(self) # pyright: ignore [reportReturnType]
Expand All @@ -927,16 +1014,16 @@ def to(
return self.to(ObjectVar, output)

if isinstance(output, type):
for var_subclass in _var_subclasses[::-1]:
if safe_issubclass(output, var_subclass.var_subclass):
current_var_type = self._var_type
if current_var_type is Any:
new_var_type = var_type
else:
new_var_type = var_type or current_var_type
return var_subclass.to_var_subclass.create( # pyright: ignore [reportReturnType]
value=self, _var_type=new_var_type
)
output_entry = _var_subclass_for_var_output(output)
if output_entry is not None:
current_var_type = self._var_type
if current_var_type is Any:
new_var_type = var_type
else:
new_var_type = var_type or current_var_type
return output_entry.to_var_subclass.create( # pyright: ignore [reportReturnType]
value=self, _var_type=new_var_type
)

# If we can't determine the first argument, we just replace the _var_type.
if not safe_issubclass(output, Var) or var_type is None:
Expand Down Expand Up @@ -1003,12 +1090,9 @@ def guess_type(self) -> Var:
for inner_type in non_optional_inner_types
]

for var_subclass in _var_subclasses[::-1]:
if all(
safe_issubclass(t, var_subclass.python_types)
for t in fixed_inner_types
):
return self.to(var_subclass.var_subclass, self._var_type)
union_entry = _var_subclass_matching_python_types(tuple(fixed_inner_types))
if union_entry is not None:
return self.to(union_entry.var_subclass, self._var_type)

if can_use_in_object_var(var_type):
return self.to(ObjectVar, self._var_type)
Expand All @@ -1026,9 +1110,9 @@ def guess_type(self) -> Var:
if fixed_type is None:
return self.to(None)

for var_subclass in _var_subclasses[::-1]:
if safe_issubclass(fixed_type, var_subclass.python_types):
return self.to(var_subclass.var_subclass, self._var_type)
guessed_entry = _var_subclass_matching_python_types((fixed_type,))
if guessed_entry is not None:
return self.to(guessed_entry.var_subclass, self._var_type)

if can_use_in_object_var(fixed_type):
return self.to(ObjectVar, self._var_type)
Expand Down
4 changes: 2 additions & 2 deletions reflex/istate/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
Var,
VarData,
VarSubclassEntry,
_var_subclasses,
_register_var_subclass_entry,
cached_property_no_lock,
)
from reflex_base.vars.object import ObjectItemOperation, ObjectVar
Expand Down Expand Up @@ -333,7 +333,7 @@ def create(
# than ToOperation so _js_expr can render as {original}?.["href"]. The registry
# entry still accepts it because .to()/guess_type() only call .create(...),
# which has a compatible signature.
_var_subclasses.append(
_register_var_subclass_entry(
VarSubclassEntry(ReflexURLVar, ReflexURLCastedVar, (ReflexURL,)) # pyright: ignore[reportArgumentType]
)

Expand Down
24 changes: 24 additions & 0 deletions tests/units/vars/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,30 @@ def test_figure_out_type(value, expected):
assert figure_out_type(value) == expected


def test_var_subclass_registration_invalidates_lookup_caches() -> None:
"""A Var subclass registered after lookups were cached takes priority.

``Var.to`` / ``Var.guess_type`` dispatch through cached registry lookups;
registering a new Var subclass must drop those caches so the new (higher
priority) entry wins for types it claims.
"""
from reflex_base.vars.base import Var
from reflex_base.vars.sequence import StringVar

class FancyTestStr(str):
"""A str subtype that later gets its own Var subclass."""

assert isinstance(Var(_js_expr="a").to(FancyTestStr), StringVar)

class FancyTestStrVar(Var, python_types=FancyTestStr):
"""Var subclass claiming FancyTestStr."""

assert isinstance(Var(_js_expr="a").to(FancyTestStr), FancyTestStrVar)
assert isinstance(
Var(_js_expr="a", _var_type=FancyTestStr).guess_type(), FancyTestStrVar
)


def test_computed_var_replace() -> None:
class StateTest(State):
@computed_var(cache=True)
Expand Down
Loading