From 9300edf1ecfb830e739be754620da6a591e1134b Mon Sep 17 00:00:00 2001 From: Farhan Date: Sat, 12 Sep 2026 04:01:29 +0500 Subject: [PATCH 1/9] perf(compile): read only set props and cache literal Var dispatch Component render, Var collection, and the prop-component scan walked every declared prop through the field descriptor to find the few that are set. Iterate the instance dict plus class-level defaults instead. Cache the literal Var class per exact value type, short-circuit app-wrap dedupe on identity, skip the generic tag protocol for plain tags, and hoist the memoize plugin's component imports. Docs site dry compile (511 pages): 47 s to 40 s. Claude-Session: https://claude.ai/code/session_01PmizE1eQhtYZyVs1RK2ke3 --- .../+compile-prop-hot-paths.performance.md | 1 + .../src/reflex_base/components/component.py | 58 +++++++-- .../src/reflex_base/components/tags/tag.py | 3 + .../reflex-base/src/reflex_base/vars/base.py | 52 ++++++-- reflex/compiler/plugins/memoize.py | 7 +- tests/units/components/test_component.py | 49 ++++++++ tests/units/components/test_tag.py | 26 ++++ tests/units/reflex_base/vars/test_base.py | 116 ++++++++++++++++++ 8 files changed, 286 insertions(+), 26 deletions(-) create mode 100644 packages/reflex-base/news/+compile-prop-hot-paths.performance.md diff --git a/packages/reflex-base/news/+compile-prop-hot-paths.performance.md b/packages/reflex-base/news/+compile-prop-hot-paths.performance.md new file mode 100644 index 00000000000..8342a4a07b8 --- /dev/null +++ b/packages/reflex-base/news/+compile-prop-hot-paths.performance.md @@ -0,0 +1 @@ +Speed up compilation by reading only the props a component sets, caching literal Var dispatch by value type, and trimming render and app-wrap bookkeeping. diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index adf8d36fd0e..8cf95482f9c 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -24,6 +24,7 @@ from reflex_base.components.dynamic import load_dynamic_serializer from reflex_base.components.field import BaseField, FieldBasedMeta from reflex_base.components.tags import Tag +from reflex_base.components.tags.tag import render_prop from reflex_base.constants import Dirs, EventTriggers, Hooks, Imports, MemoizationMode from reflex_base.constants.compiler import SpecialAttributes from reflex_base.event import ( @@ -1161,7 +1162,7 @@ def _render(self, props: dict[str, Any] | None = None) -> Tag: if props is None: # Add component props to the tag. props = { - attr.removesuffix("_"): getattr(self, attr) for attr in self.get_props() + prop.removesuffix("_"): value for prop, value in self._iter_set_props() } # Add ref to element if `ref` is None and `id` is not None. @@ -1201,6 +1202,39 @@ def get_props(cls) -> Iterable[str]: """ return cls.get_js_fields() + @classmethod + @functools.cache + def _get_defaulted_props(cls) -> frozenset[str]: + """Get the props whose field supplies a value when unset. + + Returns: + The props with a default other than ``None`` or a default factory. + """ + return frozenset( + prop + for prop, field_ in cls.get_js_fields().items() + if field_.default_factory is not None + or (field_.default is not MISSING and field_.default is not None) + ) + + def _iter_set_props(self) -> Iterator[tuple[str, Any]]: + """Walk the props that carry a value, in declaration order. + + An unset prop resolves to ``None`` through its field descriptor and + every consumer drops ``None``, so only props present on the instance + or backed by a class default are read. + + Yields: + Each prop name with its value. + """ + values = self.__dict__ + defaulted = self._get_defaulted_props() + for prop in self.get_props(): + if prop in values: + yield prop, values[prop] + elif prop in defaulted: + yield prop, getattr(self, prop) + @classmethod @functools.cache def get_initial_props(cls) -> set[str]: @@ -1215,9 +1249,8 @@ def get_initial_props(cls) -> set[str]: def _get_component_prop_property(self) -> Sequence[BaseComponent]: return [ component - for prop in self.get_props() - if (value := getattr(self, prop)) is not None - and isinstance(value, (BaseComponent, Var)) + for _, value in self._iter_set_props() + if isinstance(value, (BaseComponent, Var)) for component in _components_from(value) ] @@ -1438,11 +1471,15 @@ def render(self) -> dict: except AttributeError: pass tag = self._render() - rendered_dict = dict( - tag.set( - children=[child.render() for child in self.children], - ) - ) + children = [child.render() for child in self.children] + if type(tag) is Tag: + rendered_dict = {} + if (name := render_prop(tag.name)) is not None: + rendered_dict["name"] = name + rendered_dict["props"] = tag.format_props() + rendered_dict["children"] = children + else: + rendered_dict = dict(tag.set(children=children)) self._replace_prop_names(rendered_dict) self._cached_render_result = rendered_dict return rendered_dict @@ -1581,8 +1618,7 @@ def _get_vars( vars.extend(event_vars) # Get Vars associated with component props. - for prop in self.get_props(): - prop_var = getattr(self, prop) + for _, prop_var in self._iter_set_props(): if isinstance(prop_var, Var): vars.append(prop_var) diff --git a/packages/reflex-base/src/reflex_base/components/tags/tag.py b/packages/reflex-base/src/reflex_base/components/tags/tag.py index 6921121c4fa..cc607b90c82 100644 --- a/packages/reflex-base/src/reflex_base/components/tags/tag.py +++ b/packages/reflex-base/src/reflex_base/components/tags/tag.py @@ -20,6 +20,9 @@ def render_prop(value: Any) -> Any: Returns: The rendered value. """ + if type(value) in (str, dict): + return value + from reflex_base.components.component import BaseComponent if isinstance(value, BaseComponent): diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index 79ebe8bd283..ef3de2d9877 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -13,7 +13,6 @@ import logging import re import string -import uuid import warnings from abc import ABCMeta from collections.abc import Callable, Coroutine, Iterable, Mapping, Sequence @@ -115,6 +114,37 @@ class VarSubclassEntry: _var_subclasses: list[VarSubclassEntry] = [] _var_literal_subclasses: list[tuple[type[LiteralVar], VarSubclassEntry]] = [] +# Exact value type -> the literal class claiming it, or None when no literal +# class does. Reset whenever a literal subclass registers. +_literal_var_by_type: dict[type, type[LiteralVar] | None] = {} + + +def _literal_var_for(value: Any) -> type[LiteralVar] | None: + """Find the literal Var class claiming ``value``'s type. + + Args: + value: The python value to wrap. + + Returns: + The matching literal class, or None if no registered class claims it. + """ + value_type = type(value) + try: + return _literal_var_by_type[value_type] + except KeyError: + pass + literal_subclass = next( + ( + literal + for literal, var_subclass in reversed(_var_literal_subclasses) + if isinstance(value, var_subclass.python_types) + ), + None, + ) + # A class object's type is its metaclass, which other classes share. + if not isinstance(value, type): + _literal_var_by_type[value_type] = literal_subclass + return literal_subclass @functools.cache @@ -236,7 +266,7 @@ def insert_app_wraps( if seen is None: seen = target.get(key) if seen is not None: - if seen != wrapper: + if seen is not wrapper and seen != wrapper: msg = ( f"Conflicting app wraps for {key!r}: two different " "components claim the same (priority, tag) slot." @@ -1651,6 +1681,7 @@ def __init_subclass__(cls, **kwargs): _var_literal_subclasses.remove(var_literal_subclass) _var_literal_subclasses.append((cls, var_subclass)) + _literal_var_by_type.clear() @classmethod def _create_literal_var( @@ -1678,9 +1709,8 @@ def _create_literal_var( return value return value._replace(merge_var_data=_var_data) - for literal_subclass, var_subclass in _var_literal_subclasses[::-1]: - if isinstance(value, var_subclass.python_types): - return literal_subclass.create(value, _var_data=_var_data) + if (literal_subclass := _literal_var_for(value)) is not None: + return literal_subclass.create(value, _var_data=_var_data) if ( (as_var_method := getattr(value, "_as_var", None)) is not None @@ -1760,9 +1790,8 @@ def _get_all_var_data_without_creating_var_dispatch( if isinstance(value, Var): return value._get_all_var_data() - for literal_subclass, var_subclass in _var_literal_subclasses[::-1]: - if isinstance(value, var_subclass.python_types): - return literal_subclass._get_all_var_data_without_creating_var(value) + if (literal_subclass := _literal_var_for(value)) is not None: + return literal_subclass._get_all_var_data_without_creating_var(value) if ( (as_var_method := getattr(value, "_as_var", None)) is not None @@ -2020,6 +2049,8 @@ def __set_name__(self, owner: Any, name: str): """ if self._attrname is None: self._attrname = name + self._cached_field_name = "_reflex_cache_" + name + cached_field_name = self._cached_field_name original_del = getattr(owner, "__del__", None) @@ -2029,7 +2060,6 @@ def delete_property(this: Any): Args: this: The object to delete the cached property from. """ - cached_field_name = "_reflex_cache_" + name try: unique_id = object.__getattribute__(this, cached_field_name) except AttributeError: @@ -2067,11 +2097,11 @@ def __get__(self, instance: Any, owner: type | None = None): if self._attrname is None: msg = "Cannot use cached_property on a class without __set_name__." raise TypeError(msg) - cached_field_name = "_reflex_cache_" + self._attrname + cached_field_name = self._cached_field_name try: unique_id = object.__getattribute__(instance, cached_field_name) except AttributeError: - unique_id = uuid.uuid4().int + unique_id = object() object.__setattr__(instance, cached_field_name, unique_id) if unique_id not in GLOBAL_CACHE: try: diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index a50e3e30569..3f71eb1409b 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -35,6 +35,9 @@ from reflex_base.constants.compiler import MemoizationDisposition from reflex_base.plugins import ComponentAndChildren, PageContext from reflex_base.plugins.base import Plugin +from reflex_components_core.base.bare import Bare +from reflex_components_core.core.cond import Cond +from reflex_components_core.core.match import Match from reflex.compiler.plugins.builtin import ( collect_var_app_wraps_for_component, @@ -146,10 +149,6 @@ def _should_memoize(component: Component) -> bool: Returns: True if the component should be wrapped in a memo definition. """ - from reflex_components_core.base.bare import Bare - from reflex_components_core.core.cond import Cond - from reflex_components_core.core.match import Match - strategy = get_memoization_strategy(component) if component._memoization_mode.disposition == MemoizationDisposition.NEVER: diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index 687157d9725..592c5591433 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -5,6 +5,7 @@ import pytest from reflex_base.components.component import Component, field +from reflex_base.components.tags import Tag from reflex_base.constants import EventTriggers from reflex_base.constants.state import FIELD_MARKER from reflex_base.event import ( @@ -45,6 +46,33 @@ from reflex.utils import imports +@pytest.mark.parametrize("name", ["div", "", None]) +def test_plain_tag_render_matches_tag_protocol(name, monkeypatch): + """Direct rendering preserves names, props, children, and render caching.""" + tag = Tag(name=name).add_props(title="hello") + component = Component._create(children=[Bare.create("child")]) + monkeypatch.setattr(component, "_render", lambda: tag) + expected = dict(tag.set(children=[child.render() for child in component.children])) + assert component.render() == expected + assert component.render() is component.render() + assert not tag.children + + +def test_custom_tag_render_uses_subclass_protocol(monkeypatch): + """Custom tag iteration can depend on its supplied children.""" + + class ChildrenTag(Tag): + """A tag with custom child-dependent rendering.""" + + def __iter__(self): + """Yield a value derived from the child list.""" + yield "child_count", len(self.children) + + component = Component._create(children=[Bare.create("child")]) + monkeypatch.setattr(component, "_render", lambda: ChildrenTag()) + assert component.render() == {"child_count": 1} + + class TestState(BaseState): """A test state with various methods for event handling.""" @@ -2398,3 +2426,24 @@ def test_get_all_hooks_internal_does_not_mutate_hooks_cache(): assert dict(parent._get_hooks_internal()) == parent_own_hooks # And repeated collection yields the same result. assert parent._get_all_hooks_internal() == combined + + +def test_set_props_iteration_skips_unset_props_and_keeps_defaults(): + """Only set props and class defaults are visited, in declaration order.""" + + class DefaultedProps(Component): + first: Var[str] + second: Var[str] = LiteralVar.create("second-default") + third: Var[str] + + component = DefaultedProps._create(children=(), third="set") + assert [(prop, str(value)) for prop, value in component._iter_set_props()] == [ + ("second", '"second-default"'), + ("third", '"set"'), + ] + assert [str(var) for var in component._get_vars()] == ['"second-default"', '"set"'] + assert {prop: str(value) for prop, value in component._render().props.items()} == { + "second": '"second-default"', + "third": '"set"', + } + assert "first" not in vars(component) diff --git a/tests/units/components/test_tag.py b/tests/units/components/test_tag.py index f79065d5a02..47a117a8a35 100644 --- a/tests/units/components/test_tag.py +++ b/tests/units/components/test_tag.py @@ -1,5 +1,6 @@ import pytest from reflex_base.components.tags import CondTag, Tag, tagless +from reflex_base.components.tags.tag import render_prop from reflex_base.vars.base import LiteralVar, Var @@ -127,3 +128,28 @@ def test_tagless_string_representation(): tag = tagless.Tagless(contents="Hello world") expected_output = "Hello world" assert str(tag) == expected_output + + +def test_render_prop_preserves_plain_values_and_subclass_dispatch(): + """Already-rendered dictionaries pass through; callable subclasses do not.""" + + class CallableString(str): + """A string whose callability must still be inspected.""" + + def __call__(self): + """Return a marker value.""" + return "called" + + class CallableDict(dict): + """A mapping whose callability must still be inspected.""" + + def __call__(self): + """Return a marker value.""" + return "called" + + rendered = {"name": "div", "children": []} + assert render_prop(rendered) is rendered + assert render_prop("text") == "text" + assert render_prop(CallableString("text")) is None + assert render_prop(CallableDict(rendered)) is None + assert render_prop(("text", rendered)) == ["text", rendered] diff --git a/tests/units/reflex_base/vars/test_base.py b/tests/units/reflex_base/vars/test_base.py index 4a2a72a4347..5575b1222c9 100644 --- a/tests/units/reflex_base/vars/test_base.py +++ b/tests/units/reflex_base/vars/test_base.py @@ -1,9 +1,12 @@ """Tests for reflex_base.vars.base state metaclass field handling.""" import dataclasses +import gc +import pickle import threading import traceback import typing +import weakref from typing import Any, Literal, TypeVar import pytest @@ -11,11 +14,13 @@ from reflex_base.utils.exceptions import ReflexRuntimeError from reflex_base.utils.types import get_field_type from reflex_base.vars.base import ( + GLOBAL_CACHE, CachedVarOperation, EvenMoreBasicBaseState, LiteralVar, Var, _linearize_bases, + cached_property, cached_property_no_lock, field, ) @@ -302,3 +307,114 @@ def _cached_get_all_var_data(self): BrokenVar(_js_expr="")._get_all_var_data() assert isinstance(exc_info.value.__cause__, AttributeError) assert str(exc_info.value.__cause__) == "the real error message" + + +class _CachedValue: + """A mutable input with an explicitly resettable derived value.""" + + _reflex_cache_result: object + + def __init__(self, value: str): + """Store the input. + + Args: + value: The value to cache. + """ + self.value = value + + @cached_property + def result(self) -> list[str]: + """Return the derived value. + + Returns: + A fresh list containing the input. + """ + return [self.value] + + +def test_cached_property_identity_and_reset(): + """Local keys isolate instances and survive explicit cache resets.""" + first = _CachedValue("first") + second = _CachedValue("second") + result = first.result + assert first.result is result + assert second.result == ["second"] + first.value = "changed" + assert first.result is result + GLOBAL_CACHE.clear() + assert first.result == ["changed"] + assert first.result is not result + + +def test_cached_property_pickle_does_not_reuse_another_instances_key(): + """Deserialized keys must not collide with live cache entries.""" + original = _CachedValue("original") + assert original.result == ["original"] + restored = pickle.loads(pickle.dumps(original)) + restored.value = "restored" + assert restored.result == ["restored"] + assert original.result == ["original"] + + +def test_cached_property_releases_entry_with_instance(): + """Destroying an instance removes its cached value.""" + value = _CachedValue("temporary") + assert value.result == ["temporary"] + key = value._reflex_cache_result + reference = weakref.ref(value) + del value + gc.collect() + assert reference() is None + assert key not in GLOBAL_CACHE + + +def test_literal_var_dispatch_follows_later_registrations(): + """A literal class registered after a lookup wins the next lookup for its type.""" + + class Coordinate: + """A value no literal Var claims yet.""" + + def __init__(self, x: int): + """Store the coordinate. + + Args: + x: The coordinate value. + """ + self.x = x + + from reflex_base.utils import serializers + + @serializers.serializer + def serialize_coordinate(value: Coordinate) -> str: + """Serialize a coordinate. + + Args: + value: The coordinate. + + Returns: + Its string form. + """ + return f"coordinate-{value.x}" + + assert str(LiteralVar.create(Coordinate(1))) == '"coordinate-1"' + + class CoordinateVar(Var[Coordinate], python_types=Coordinate): + """A Var holding a coordinate.""" + + class LiteralCoordinateVar(LiteralVar, CoordinateVar): + """A literal coordinate Var.""" + + @classmethod + def create(cls, value: Coordinate, _var_data=None): + """Create the literal. + + Args: + value: The coordinate. + _var_data: Unused metadata. + + Returns: + A Var with the coordinate's expression. + """ + return Var(_js_expr=f"[{value.x}]", _var_type=Coordinate) + + assert str(LiteralVar.create(Coordinate(2))) == "[2]" From 4715789340a746d5b5ef1f593dd2dae47b35776d Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 15 Sep 2026 18:48:20 +0500 Subject: [PATCH 2/9] test(vars): restore registries after the literal dispatch test and add the root news fragment --- news/+compile-prop-hot-paths.performance.md | 1 + tests/units/reflex_base/vars/test_base.py | 64 +++++++++++++-------- 2 files changed, 40 insertions(+), 25 deletions(-) create mode 100644 news/+compile-prop-hot-paths.performance.md diff --git a/news/+compile-prop-hot-paths.performance.md b/news/+compile-prop-hot-paths.performance.md new file mode 100644 index 00000000000..8342a4a07b8 --- /dev/null +++ b/news/+compile-prop-hot-paths.performance.md @@ -0,0 +1 @@ +Speed up compilation by reading only the props a component sets, caching literal Var dispatch by value type, and trimming render and app-wrap bookkeeping. diff --git a/tests/units/reflex_base/vars/test_base.py b/tests/units/reflex_base/vars/test_base.py index 5575b1222c9..f2186cd5f9e 100644 --- a/tests/units/reflex_base/vars/test_base.py +++ b/tests/units/reflex_base/vars/test_base.py @@ -383,38 +383,52 @@ def __init__(self, x: int): self.x = x from reflex_base.utils import serializers + from reflex_base.vars import base - @serializers.serializer - def serialize_coordinate(value: Coordinate) -> str: - """Serialize a coordinate. + var_subclasses = len(base._var_subclasses) + literal_subclasses = len(base._var_literal_subclasses) + try: - Args: - value: The coordinate. + @serializers.serializer + def serialize_coordinate(value: Coordinate) -> str: + """Serialize a coordinate. - Returns: - Its string form. - """ - return f"coordinate-{value.x}" + Args: + value: The coordinate. + + Returns: + Its string form. + """ + return f"coordinate-{value.x}" - assert str(LiteralVar.create(Coordinate(1))) == '"coordinate-1"' + assert str(LiteralVar.create(Coordinate(1))) == '"coordinate-1"' - class CoordinateVar(Var[Coordinate], python_types=Coordinate): - """A Var holding a coordinate.""" + class CoordinateVar(Var[Coordinate], python_types=Coordinate): + """A Var holding a coordinate.""" - class LiteralCoordinateVar(LiteralVar, CoordinateVar): - """A literal coordinate Var.""" + class LiteralCoordinateVar(LiteralVar, CoordinateVar): + """A literal coordinate Var.""" - @classmethod - def create(cls, value: Coordinate, _var_data=None): - """Create the literal. + @classmethod + def create(cls, value: Coordinate, _var_data=None): + """Create the literal. - Args: - value: The coordinate. - _var_data: Unused metadata. + Args: + value: The coordinate. + _var_data: Unused metadata. - Returns: - A Var with the coordinate's expression. - """ - return Var(_js_expr=f"[{value.x}]", _var_type=Coordinate) + Returns: + A Var with the coordinate's expression. + """ + return Var(_js_expr=f"[{value.x}]", _var_type=Coordinate) - assert str(LiteralVar.create(Coordinate(2))) == "[2]" + assert str(LiteralVar.create(Coordinate(2))) == "[2]" + finally: + serializers.SERIALIZERS.pop(Coordinate) + serializers.SERIALIZER_TYPES.pop(Coordinate) + serializers.get_serializer.cache_clear() + serializers.get_serializer_type.cache_clear() + del base._var_subclasses[var_subclasses:] + del base._var_literal_subclasses[literal_subclasses:] + base._clear_var_subclass_lookup_caches() + base._literal_var_by_type.clear() From 53b069f68d0e26d788503e56911b07ffa6aa3f58 Mon Sep 17 00:00:00 2001 From: Farhan Date: Sat, 12 Sep 2026 04:02:17 +0500 Subject: [PATCH 3/9] perf(events): share one chain per handler and trigger across call sites EventChain.create rebuilt an identical chain for every component that bound the same handler to the same trigger, and the memoize pass then rendered each chain again to name its useCallback wrapper. Intern the chain on the handler keyed by args spec and trigger, and key the wrapper cache by chain identity so repeated call sites reuse the wrapper without rendering. Claude-Session: https://claude.ai/code/session_01PmizE1eQhtYZyVs1RK2ke3 --- .../+event-chain-interning.performance.md | 1 + .../reflex_base/components/memoize_helpers.py | 28 ++-- .../src/reflex_base/event/__init__.py | 16 +- .../reflex-base/src/reflex_base/registry.py | 4 + .../components/test_memoize_helpers.py | 143 ++++++++++++++++++ tests/units/test_event.py | 45 ++++++ 6 files changed, 226 insertions(+), 11 deletions(-) create mode 100644 packages/reflex-base/news/+event-chain-interning.performance.md create mode 100644 tests/units/reflex_base/components/test_memoize_helpers.py diff --git a/packages/reflex-base/news/+event-chain-interning.performance.md b/packages/reflex-base/news/+event-chain-interning.performance.md new file mode 100644 index 00000000000..417d948848b --- /dev/null +++ b/packages/reflex-base/news/+event-chain-interning.performance.md @@ -0,0 +1 @@ +Share one event chain per handler and trigger across call sites, and reuse memoized event wrappers by chain identity during compilation. diff --git a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py index 5c8f714465a..05dacd43c0f 100644 --- a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py +++ b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py @@ -26,6 +26,7 @@ from reflex_base.components.component import BaseComponent, Component from reflex_base.constants import EventTriggers from reflex_base.event import EventChain, EventSpec +from reflex_base.registry import RegistrationContext from reflex_base.utils.imports import ImportVar from reflex_base.vars import VarData from reflex_base.vars.base import LiteralVar, Var @@ -100,6 +101,9 @@ def get_memoized_event_triggers( A dict mapping event trigger name to memoized_triger. """ trigger_memo: dict[str, Var] = {} + if not component.event_triggers: + return trigger_memo + cache = RegistrationContext.ensure_context()._memoized_event_triggers for event_trigger, event_args in component._get_vars_from_event_triggers( component.event_triggers ): @@ -112,8 +116,17 @@ def get_memoized_event_triggers( continue event = component.event_triggers[event_trigger] - rendered_chain = LiteralVar.create(event) + cache_key = (event_trigger, id(event)) + cached = cache.get(cache_key) + if cached is not None and cached[0] is event: + trigger_memo[event_trigger] = cached[1] + continue + rendered_chain = LiteralVar.create(event) + rendered_data = rendered_chain._get_all_var_data() + event_var_data = [ + data for arg in event_args if (data := arg._get_all_var_data()) is not None + ] chain_hash = md5( str(rendered_chain).encode("utf-8"), usedforsecurity=False ).hexdigest() @@ -122,18 +135,13 @@ def get_memoized_event_triggers( var_deps = ["addEvents", "ReflexEvent"] var_deps.extend(_get_deps_from_event_trigger(event)) - event_var_data = [] - for arg in event_args: - var_data = arg._get_all_var_data() - if var_data is None: - continue - event_var_data.append(var_data) + for var_data in event_var_data: for hook in var_data.hooks: var_deps.extend(_get_hook_deps(hook)) memo_var_data = VarData.merge( *event_var_data, - rendered_chain._get_all_var_data(), + rendered_data, VarData( hooks=[ f"const {memo_name} = useCallback({rendered_chain!s}, [{', '.join(var_deps)}])" @@ -142,9 +150,11 @@ def get_memoized_event_triggers( ), ) - trigger_memo[event_trigger] = Var( + trigger_memo[event_trigger] = memo_var = Var( _js_expr=memo_name, _var_type=EventChain, _var_data=memo_var_data ) + # Hold the chain so its id cannot be recycled while the entry lives. + cache[cache_key] = event, memo_var return trigger_memo diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index b191bd93349..2b837afed8b 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -913,6 +913,16 @@ def create( # Trust that the caller knows what they're doing passing an EventChain directly return value + # A handler bound to one trigger always produces the same chain, so + # every call site sharing the handler shares one instance. The cache + # lives on the handler, which is never pickled or copied. + bound_chains = None + if not event_chain_kwargs and isinstance(value, EventHandler): + bound_chains = value.__dict__.setdefault("_bound_chains", {}) + bound = bound_chains.get((id(args_spec), key)) + if bound is not None and bound[0] is args_spec: + return bound[1] + # If the input is a single event handler, wrap it in a list. if isinstance(value, (EventHandler, EventSpec)): value = [value] @@ -952,12 +962,14 @@ def create( for e in events ] - # Return the event chain. - return cls( + chain = cls( events=events, args_spec=args_spec, **event_chain_kwargs, ) + if bound_chains is not None: + bound_chains[id(args_spec), key] = args_spec, chain + return chain @dataclasses.dataclass( diff --git a/packages/reflex-base/src/reflex_base/registry.py b/packages/reflex-base/src/reflex_base/registry.py index fe337669286..527b57ffbba 100644 --- a/packages/reflex-base/src/reflex_base/registry.py +++ b/packages/reflex-base/src/reflex_base/registry.py @@ -17,6 +17,7 @@ from reflex.state import BaseState from reflex_base.config import Config from reflex_base.event import EventHandler + from reflex_base.vars.base import Var def _default_bundled_libraries() -> list[str]: @@ -72,6 +73,9 @@ class RegistrationContext(BaseContext): default_factory=dict, repr=False ) _app: App | None = dataclasses.field(default=None, repr=False) + _memoized_event_triggers: dict[tuple[str, int], tuple[Any, Var]] = ( + dataclasses.field(default_factory=dict, repr=False) + ) @property def app(self) -> App: diff --git a/tests/units/reflex_base/components/test_memoize_helpers.py b/tests/units/reflex_base/components/test_memoize_helpers.py new file mode 100644 index 00000000000..e207bb71438 --- /dev/null +++ b/tests/units/reflex_base/components/test_memoize_helpers.py @@ -0,0 +1,143 @@ +"""Tests for sharing prepared event wrappers within a registration context.""" + +import dataclasses + +import pytest +from reflex_base.components.component import Component +from reflex_base.components.memoize_helpers import get_memoized_event_triggers +from reflex_base.event import EventChain, EventHandler, no_args_event_spec +from reflex_base.registry import RegistrationContext +from reflex_base.utils.imports import ImportVar +from reflex_base.vars.base import LiteralVar, Var, VarData + + +def test_event_wrappers_are_reused_and_reset_with_context(): + """Identical wrappers share work only within their owning context.""" + component = Component._create( + children=(), event_triggers={"on_click": Var("handler", EventChain)} + ) + with RegistrationContext.ensure_context().fork() as context: + first = get_memoized_event_triggers(component)["on_click"] + assert get_memoized_event_triggers(component)["on_click"] is first + with context.fork() as fork: + assert not fork._memoized_event_triggers + assert get_memoized_event_triggers(component)["on_click"] is not first + context._memoized_event_triggers.clear() + assert get_memoized_event_triggers(component)["on_click"] is not first + + +@pytest.mark.parametrize( + ("first_data", "second_data"), + [ + (VarData(state="first"), VarData(state="second")), + ( + VarData(hooks=["const first = useFirst()"]), + VarData(hooks=["const second = useSecond()"]), + ), + ( + VarData(imports={"first": [ImportVar("value")]}), + VarData(imports={"second": [ImportVar("value")]}), + ), + (VarData(deps=[Var("first")]), VarData(deps=[Var("second")])), + ], +) +def test_event_wrapper_cache_preserves_dependencies( + first_data: VarData, second_data: VarData +): + """Identical expressions with different metadata must keep their dependencies.""" + with RegistrationContext.ensure_context().fork(): + first = get_memoized_event_triggers( + Component._create( + children=(), + event_triggers={"on_click": Var("handler", EventChain, first_data)}, + ) + )["on_click"] + second = get_memoized_event_triggers( + Component._create( + children=(), + event_triggers={"on_click": Var("handler", EventChain, second_data)}, + ) + )["on_click"] + assert first is not second + assert repr(first._get_all_var_data()) != repr(second._get_all_var_data()) + + +def test_event_wrapper_cache_preserves_provider_identity(): + """Providers sharing a role can still carry distinct component props.""" + first_provider = Component._create( + children=(), tag="Provider", custom_attrs={"value": "first"} + ) + second_provider = Component._create( + children=(), tag="Provider", custom_attrs={"value": "second"} + ) + with RegistrationContext.ensure_context().fork(): + for provider in (first_provider, second_provider): + event = Var("handler", EventChain, VarData(app_wraps=[(10, provider)])) + wrapper = get_memoized_event_triggers( + Component._create(children=(), event_triggers={"on_click": event}) + )["on_click"] + data = wrapper._get_all_var_data() + assert data is not None + assert data.app_wraps[0][1] is provider + + +def test_event_wrapper_cache_does_not_compare_vars_as_python_booleans(): + """Equivalent dependency expressions may belong to different Var objects.""" + with RegistrationContext.ensure_context().fork(): + for _ in range(2): + event = Var("handler", EventChain, VarData(deps=[Var("dependency")])) + wrapper = get_memoized_event_triggers( + Component._create(children=(), event_triggers={"on_click": event}) + )["on_click"] + data = wrapper._get_all_var_data() + assert data is not None + assert {str(dep) for dep in data.deps} == {"dependency"} + + +def test_event_wrapper_reflects_captured_arguments_and_actions(): + """Chains differing in nested data compile to different wrappers.""" + + def handler(value: str): + """Accept an event argument.""" + + def chain(argument: str, **actions: bool) -> EventChain: + """Build a chain for one handler call. + + Args: + argument: The captured handler argument. + **actions: Event actions applied to the nested event. + + Returns: + The chain wrapping the handler call. + """ + spec = EventHandler(fn=handler)(argument) + if actions: + spec = dataclasses.replace(spec, event_actions=actions) + return EventChain(events=[spec], args_spec=no_args_event_spec) + + component = Component._create(children=(), event_triggers={}) + with RegistrationContext.ensure_context().fork(): + rendered = [] + for event in ( + chain("first"), + dataclasses.replace(chain("first"), event_actions={"preventDefault": True}), + chain("first", stopPropagation=True), + chain("second"), + ): + component.event_triggers["on_click"] = event + rendered.append(str(get_memoized_event_triggers(component)["on_click"])) + assert len(set(rendered)) == len(rendered) + + +def test_event_wrappers_are_shared_by_chain_identity(monkeypatch): + """Components bound to one chain object share one wrapper without rendering it.""" + chain = Var("handler", EventChain) + first = Component._create(children=(), event_triggers={"on_click": chain}) + second = Component._create(children=(), event_triggers={"on_click": chain}) + other_trigger = Component._create(children=(), event_triggers={"on_blur": chain}) + with RegistrationContext.ensure_context().fork(): + wrapper = get_memoized_event_triggers(first)["on_click"] + monkeypatch.setattr(LiteralVar, "create", pytest.fail) + assert get_memoized_event_triggers(second)["on_click"] is wrapper + monkeypatch.undo() + assert get_memoized_event_triggers(other_trigger)["on_blur"] is not wrapper diff --git a/tests/units/test_event.py b/tests/units/test_event.py index ad964ab821e..90dd36ca206 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -1368,3 +1368,48 @@ def handle_submit(form_data: dict[str, str]): log._reset() assert "expects (dict[str, typing.Any]) -> () but got (dict[str, str]) -> ()" in out assert "\\" not in out + + +def test_event_chain_create_shares_chains_bound_from_one_handler(): + """A handler bound to one trigger yields one chain for every call site.""" + + class ChainState(BaseState): + @event + def handler(self): + pass + + def args_spec(): + return () + + chain = EventChain.create(ChainState.handler, args_spec=args_spec, key="on_click") + assert isinstance(chain, EventChain) + assert ( + EventChain.create(ChainState.handler, args_spec=args_spec, key="on_click") + is chain + ) + assert ( + EventChain.create(ChainState.handler, args_spec=args_spec, key="on_blur") + is not chain + ) + assert ( + EventChain.create(ChainState.handler, args_spec=lambda: (), key="on_click") + is not chain + ) + with_actions = EventChain.create( + ChainState.handler, args_spec=args_spec, key="on_click", event_actions={"x": 1} + ) + assert with_actions is not chain + assert ( + EventChain.create(ChainState.handler, args_spec=args_spec, key="on_click") + is chain + ) + assert ( + EventChain.create( + ChainState.handler.prevent_default, args_spec=args_spec, key="on_click" + ) + is not chain + ) + assert ( + EventChain.create([ChainState.handler], args_spec=args_spec, key="on_click") + is not chain + ) From 10ebca4891c0de3cf625a295b5e3b050bbbb3b2b Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 15 Sep 2026 18:26:35 +0500 Subject: [PATCH 4/9] perf(events): keep bound chain cache on the RegistrationContext Deep-copying a component walked into the handler's chain cache and copied every chain bound to it. The cache now lives on the RegistrationContext, keyed by handler, args spec and trigger, so handlers carry no state and a forked context starts with its own chains. --- .../src/reflex_base/event/__init__.py | 21 ++++++----- .../reflex-base/src/reflex_base/registry.py | 11 +++++- tests/units/test_event.py | 37 +++++++++++++++++++ 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 2b837afed8b..7b80808e37c 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -914,14 +914,15 @@ def create( return value # A handler bound to one trigger always produces the same chain, so - # every call site sharing the handler shares one instance. The cache - # lives on the handler, which is never pickled or copied. - bound_chains = None + # every call site sharing the handler shares one instance per + # registration context. + bound_handler = None if not event_chain_kwargs and isinstance(value, EventHandler): - bound_chains = value.__dict__.setdefault("_bound_chains", {}) - bound = bound_chains.get((id(args_spec), key)) - if bound is not None and bound[0] is args_spec: - return bound[1] + bound_handler = value + bound_chains = RegistrationContext.ensure_context()._bound_event_chains + bound = bound_chains.get((id(value), id(args_spec), key)) + if bound is not None and bound[0] is value and bound[1] is args_spec: + return bound[2] # If the input is a single event handler, wrap it in a list. if isinstance(value, (EventHandler, EventSpec)): @@ -967,8 +968,10 @@ def create( args_spec=args_spec, **event_chain_kwargs, ) - if bound_chains is not None: - bound_chains[id(args_spec), key] = args_spec, chain + if bound_handler is not None: + RegistrationContext.ensure_context()._bound_event_chains[ + id(bound_handler), id(args_spec), key + ] = (bound_handler, args_spec, chain) return chain diff --git a/packages/reflex-base/src/reflex_base/registry.py b/packages/reflex-base/src/reflex_base/registry.py index 527b57ffbba..27393504505 100644 --- a/packages/reflex-base/src/reflex_base/registry.py +++ b/packages/reflex-base/src/reflex_base/registry.py @@ -11,12 +11,13 @@ from reflex_base.utils.exceptions import ReflexRuntimeError, StateValueError if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Sequence from reflex.app import App from reflex.state import BaseState from reflex_base.config import Config - from reflex_base.event import EventHandler + from reflex_base.event import EventChain, EventHandler + from reflex_base.utils.types import ArgsSpec from reflex_base.vars.base import Var @@ -76,6 +77,12 @@ class RegistrationContext(BaseContext): _memoized_event_triggers: dict[tuple[str, int], tuple[Any, Var]] = ( dataclasses.field(default_factory=dict, repr=False) ) + # (handler id, args_spec id, trigger key) -> the handler, spec and their + # bound chain. The referents keep the ids valid for the map's lifetime. + _bound_event_chains: dict[ + tuple[int, int, str | None], + tuple[EventHandler, ArgsSpec | Sequence[ArgsSpec], EventChain], + ] = dataclasses.field(default_factory=dict, repr=False) @property def app(self) -> App: diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 90dd36ca206..a56c34b921a 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -19,6 +19,7 @@ on_submit_event, on_submit_string_event, ) +from reflex_base.registry import RegistrationContext from reflex_base.utils import format, log from reflex_base.utils.exceptions import ( EventHandlerArgTypeMismatchError, @@ -1370,6 +1371,42 @@ def handle_submit(form_data: dict[str, str]): assert "\\" not in out +def test_event_chain_cache_lives_on_the_registration_context(): + """Bound chains are shared per context and leave the handler stateless.""" + + class ChainState(BaseState): + @event + def handler(self): + pass + + def args_spec(): + return () + + chain = EventChain.create(ChainState.handler, args_spec=args_spec, key="on_click") + with RegistrationContext.ensure_context().fork(): + forked = EventChain.create( + ChainState.handler, args_spec=args_spec, key="on_click" + ) + assert forked is not chain + assert ( + EventChain.create(ChainState.handler, args_spec=args_spec, key="on_click") + is forked + ) + assert ( + EventChain.create(ChainState.handler, args_spec=args_spec, key="on_click") + is chain + ) + + def retains(value: Any) -> bool: + if isinstance(value, dict): + value = tuple(value.values()) + if isinstance(value, (tuple, list)): + return any(retains(item) for item in value) + return value is chain + + assert not any(retains(value) for value in vars(ChainState.handler).values()) + + def test_event_chain_create_shares_chains_bound_from_one_handler(): """A handler bound to one trigger yields one chain for every call site.""" From db57926951d58d3fb08770f876c6d4d94b9a5d7e Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 15 Sep 2026 18:48:39 +0500 Subject: [PATCH 5/9] perf(events): skip the chain cache for handlers carrying event actions Those handlers are fresh copies at every call site, so a cached entry can never be hit again and would only retain the copy. Also add the root news fragment. --- news/+event-chain-interning.performance.md | 1 + packages/reflex-base/src/reflex_base/event/__init__.py | 9 +++++++-- tests/units/test_event.py | 3 +++ 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 news/+event-chain-interning.performance.md diff --git a/news/+event-chain-interning.performance.md b/news/+event-chain-interning.performance.md new file mode 100644 index 00000000000..417d948848b --- /dev/null +++ b/news/+event-chain-interning.performance.md @@ -0,0 +1 @@ +Share one event chain per handler and trigger across call sites, and reuse memoized event wrappers by chain identity during compilation. diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 7b80808e37c..310f8a7a747 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -915,9 +915,14 @@ def create( # A handler bound to one trigger always produces the same chain, so # every call site sharing the handler shares one instance per - # registration context. + # registration context. Handlers carrying event actions are fresh + # copies at every call site, so caching them would only retain them. bound_handler = None - if not event_chain_kwargs and isinstance(value, EventHandler): + if ( + not event_chain_kwargs + and isinstance(value, EventHandler) + and not value.event_actions + ): bound_handler = value bound_chains = RegistrationContext.ensure_context()._bound_event_chains bound = bound_chains.get((id(value), id(args_spec), key)) diff --git a/tests/units/test_event.py b/tests/units/test_event.py index a56c34b921a..e1174d339ac 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -1440,12 +1440,15 @@ def args_spec(): EventChain.create(ChainState.handler, args_spec=args_spec, key="on_click") is chain ) + bound_chains = RegistrationContext.ensure_context()._bound_event_chains + cached = len(bound_chains) assert ( EventChain.create( ChainState.handler.prevent_default, args_spec=args_spec, key="on_click" ) is not chain ) + assert len(bound_chains) == cached assert ( EventChain.create([ChainState.handler], args_spec=args_spec, key="on_click") is not chain From bf0d02181192056e390001e15f773b9f909a56fe Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 15 Sep 2026 18:49:01 +0500 Subject: [PATCH 6/9] test(events): run the chain cache test on a forked context --- tests/units/test_event.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/units/test_event.py b/tests/units/test_event.py index e1174d339ac..2e2263c7f9b 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -1371,7 +1371,9 @@ def handle_submit(form_data: dict[str, str]): assert "\\" not in out -def test_event_chain_cache_lives_on_the_registration_context(): +def test_event_chain_cache_lives_on_the_registration_context( + forked_registration_context: RegistrationContext, +): """Bound chains are shared per context and leave the handler stateless.""" class ChainState(BaseState): @@ -1383,7 +1385,7 @@ def args_spec(): return () chain = EventChain.create(ChainState.handler, args_spec=args_spec, key="on_click") - with RegistrationContext.ensure_context().fork(): + with forked_registration_context.fork(): forked = EventChain.create( ChainState.handler, args_spec=args_spec, key="on_click" ) From 49ebbd05c2fcb551e88fed5aa95e37c4428fa01f Mon Sep 17 00:00:00 2001 From: Farhan Date: Sat, 12 Sep 2026 04:03:09 +0500 Subject: [PATCH 7/9] perf(memo): evaluate passthrough bodies once and reuse their analysis Passthrough memo wrappers evaluated the wrapped body twice to derive the tag, and module emission rendered it a third time to collect hooks, imports, custom code, and dynamic imports. Build the passthrough definition directly from the fixed children signature, retain the rendered body and its artifacts keyed by content hash, and let emission reuse them when the styled root still matches. Claude-Session: https://claude.ai/code/session_01PmizE1eQhtYZyVs1RK2ke3 --- news/+memo-body-analysis.performance.md | 1 + .../news/+memo-body-analysis.performance.md | 1 + .../src/reflex_base/components/component.py | 1 + .../src/reflex_base/components/memo.py | 140 ++++++++++++--- .../reflex_base/components/memoize_helpers.py | 23 +++ .../reflex-base/src/reflex_base/registry.py | 4 + pyi_hashes.json | 2 +- reflex/compiler/utils.py | 64 +++++-- tests/benchmarks/fixtures.py | 21 ++- .../units/reflex_base/components/test_memo.py | 170 ++++++++++++++++++ 10 files changed, 383 insertions(+), 44 deletions(-) create mode 100644 news/+memo-body-analysis.performance.md create mode 100644 packages/reflex-base/news/+memo-body-analysis.performance.md create mode 100644 tests/units/reflex_base/components/test_memo.py diff --git a/news/+memo-body-analysis.performance.md b/news/+memo-body-analysis.performance.md new file mode 100644 index 00000000000..8bd47d5e26a --- /dev/null +++ b/news/+memo-body-analysis.performance.md @@ -0,0 +1 @@ +Reuse unchanged memo-body analysis during module emission to reduce repeated rendering and artifact collection. diff --git a/packages/reflex-base/news/+memo-body-analysis.performance.md b/packages/reflex-base/news/+memo-body-analysis.performance.md new file mode 100644 index 00000000000..c1862b92ca8 --- /dev/null +++ b/packages/reflex-base/news/+memo-body-analysis.performance.md @@ -0,0 +1 @@ +Evaluate generated passthrough memo bodies once and retain their render and artifacts so module emission does not repeat the work. diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 8cf95482f9c..b2f87d30803 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -324,6 +324,7 @@ def _finalize_fields( _COMPILE_CACHE_ATTRS = ( + "_memo_analysis_key", "_cached_render_result", "_vars_cache", "_imports_cache", diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index a1a9d2aaf8b..56d746931f4 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -28,9 +28,14 @@ from reflex_components_core.base.fragment import Fragment from reflex_base import constants -from reflex_base.components.component import Component +from reflex_base.components.component import ( + BaseComponent, + Component, + _field_values_equal, +) from reflex_base.components.memoize_helpers import ( MemoizationStrategy, + _var_data_key, get_memoization_strategy, ) from reflex_base.constants.compiler import ( @@ -43,7 +48,7 @@ from reflex_base.registry import RegistrationContext from reflex_base.utils import console, format, memo_paths from reflex_base.utils.deterministic_hash import deterministic_hash -from reflex_base.utils.imports import ImportVar +from reflex_base.utils.imports import ImportVar, ParsedImportDict from reflex_base.utils.types import safe_issubclass, typehint_issubclass from reflex_base.vars import VarData from reflex_base.vars.base import LiteralVar, Var @@ -1835,6 +1840,70 @@ def _create_component_wrapper( return _MemoComponentWrapper(definition) +@dataclasses.dataclass(frozen=True, slots=True) +class _MemoBodyAnalysis: + """Artifacts of a memo body, reusable until its compilation caches are cleared.""" + + component_type: type[Component] + rendered: dict + style: Any + style_data_key: tuple | None + imports: ParsedImportDict + internal_hooks: dict[str, VarData | None] + hook: str | None + added_hooks: dict[str, VarData | None] + custom_code: str | None + added_custom_code: tuple[list[str], ...] + dynamic_import: str | None + app_wraps: dict[tuple[int, str], Component] + + def can_reuse(self, styled: Component) -> bool: + """Check whether root styling and copying preserved the analyzed inputs. + + Args: + styled: Its copy after applying the current app's root style. + + Returns: + Whether emission can use the recorded render and artifacts. + """ + return ( + type(styled) is self.component_type + and type(styled).__copy__ is BaseComponent.__copy__ + and _var_data_key(styled.style._var_data) == self.style_data_key + and _field_values_equal(styled.style, self.style) + ) + + +def _analyze_memo_body( + component: Component, rendered: dict, artifacts: tuple[Any, ...] +) -> _MemoBodyAnalysis: + """Retain the already-collected passthrough artifacts for module emission. + + Args: + component: The body whose children have been replaced by a hole. + rendered: The body's rendered JSX representation. + artifacts: The existing content-hash inputs from ``_component_artifacts``. + + Returns: + Analysis shared by content hashing and module emission. + """ + _, imports, internal, hook, added, custom, *remaining = artifacts + return _MemoBodyAnalysis( + component_type=type(component), + rendered=rendered, + style=copy(component.style), + style_data_key=_var_data_key(component.style._var_data), + imports=imports, + internal_hooks=internal, + hook=hook, + added_hooks=added, + custom_code=custom, + added_custom_code=tuple(remaining[:-2]), + dynamic_import=remaining[-2], + app_wraps=remaining[-1], + ) + + def _component_artifacts(component: Component, *, recursive: bool) -> Iterator[Any]: """Yield everything besides the render that identifies a memo body. @@ -1893,9 +1962,18 @@ def component_hash(component: Component, *, recursive: bool) -> str: Returns: The hex digest content hash. """ - return deterministic_hash( - component.render(), *_component_artifacts(component, recursive=recursive) - ) + if recursive or not component.children: + return deterministic_hash( + component.render(), *_component_artifacts(component, recursive=recursive) + ) + rendered = component.render() + artifacts = tuple(_component_artifacts(component, recursive=False)) + digest = deterministic_hash(rendered, *artifacts) + analyses = RegistrationContext.ensure_context()._memo_body_analyses + if digest not in analyses: + analyses[digest] = _analyze_memo_body(component, rendered, artifacts) + vars(component)["_memo_analysis_key"] = digest + return digest def memo_tag(component: Component) -> str: @@ -1920,6 +1998,20 @@ def memo_tag(component: Component) -> str: ).capitalize() +_PASSTHROUGH_PARAMS = ( + MemoParam( + name="children", + kind=MemoParamKind.CHILDREN, + annotation=Var[Component], + parameter_kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, + js_prop_name="children", + placeholder_name="children", + kind_data=None, + default=inspect.Parameter.empty, + ), +) + + def create_passthrough_component_memo( component: Component, source_module: str | None = None, @@ -1992,36 +2084,30 @@ def passthrough(children: Var[Component]) -> Component: object.__setattr__(new_component, "_get_all_refs", component._get_all_refs) return new_component - # Evaluate once to compute the tag from the rendered memo body shape. - # ``_create_component_definition`` evaluates again internally; that second - # pass appends another, identical hole to ``captured_hole_child``, and the - # ``captured_hole_child[0]`` read below picks up the first. - params = _analyze_params(passthrough, for_component=True) - preview = _normalize_component_return(_evaluate_memo_function(passthrough, params)) - if preview is None: - msg = ( - "`create_passthrough_component_memo` requires a component that " - "normalizes to `rx.Component`." - ) - raise TypeError(msg) + # The compiler owns this fixed signature; no user annotations need resolving. + params = _PASSTHROUGH_PARAMS + rest_target_fields: set[str] = set() + preview = _evaluate_component_body(passthrough, params, rest_target_fields) tag = memo_tag(preview) passthrough.__name__ = format.to_snake_case(tag) passthrough.__qualname__ = passthrough.__name__ passthrough.__module__ = __name__ - definition = _create_component_definition(passthrough, Component, source_module) # ``export_name`` is the content-hashed tag, which reads as noise in the # React DevTools tree. Name the memo after the Python class it wraps. - replacements: dict[str, Any] = { - "auto_memo_wrapper": True, - "display_name": type(component).__qualname__, - } - if definition.export_name != tag: - replacements["export_name"] = tag - if captured_hole_child: - replacements["passthrough_hole_child"] = captured_hole_child[0] - definition = dataclasses.replace(definition, **replacements) + definition = MemoComponentDefinition( + fn=passthrough, + python_name=passthrough.__name__, + params=params, + source_module=source_module, + export_name=tag, + _component=_LazyBody.ready(preview), + _rest_target_fields=rest_target_fields, + auto_memo_wrapper=True, + display_name=type(component).__qualname__, + passthrough_hole_child=captured_hole_child[0] if captured_hole_child else None, + ) return _create_component_wrapper(definition), definition diff --git a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py index 05dacd43c0f..ba629a36cce 100644 --- a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py +++ b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py @@ -158,6 +158,29 @@ def get_memoized_event_triggers( return trigger_memo +def _var_data_key(data: VarData | None) -> tuple | None: + """Identify compilation metadata without invoking JavaScript equality on Vars. + + Args: + data: The metadata used to compile a component or event wrapper. + + Returns: + A key preserving dependency and provider identity, or None. + """ + if not data: + return None + return ( + data.state, + data.field_name, + data.imports, + data.hooks, + tuple(id(dep) for dep in data.deps), + data.position, + tuple(id(component) for component in data.components), + tuple((priority, id(component)) for priority, component in data.app_wraps), + ) + + def fix_event_triggers_for_memo( component: Component, page_context: PageContext ) -> Component: diff --git a/packages/reflex-base/src/reflex_base/registry.py b/packages/reflex-base/src/reflex_base/registry.py index 27393504505..bd4d5b0fe83 100644 --- a/packages/reflex-base/src/reflex_base/registry.py +++ b/packages/reflex-base/src/reflex_base/registry.py @@ -15,6 +15,7 @@ from reflex.app import App from reflex.state import BaseState + from reflex_base.components.memo import _MemoBodyAnalysis from reflex_base.config import Config from reflex_base.event import EventChain, EventHandler from reflex_base.utils.types import ArgsSpec @@ -83,6 +84,9 @@ class RegistrationContext(BaseContext): tuple[int, int, str | None], tuple[EventHandler, ArgsSpec | Sequence[ArgsSpec], EventChain], ] = dataclasses.field(default_factory=dict, repr=False) + _memo_body_analyses: dict[str, _MemoBodyAnalysis] = dataclasses.field( + default_factory=dict, repr=False + ) @property def app(self) -> App: diff --git a/pyi_hashes.json b/pyi_hashes.json index b712a2f2913..27123d06da7 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -120,5 +120,5 @@ "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", "reflex/__init__.pyi": "a3e1782fab4a9aed55f66cc98af8c217", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", - "reflex/experimental/memo.pyi": "27a73a66e238746e5da5accf99a8fdfd" + "reflex/experimental/memo.pyi": "9aa59c177ddab519a30fa6b93380ef90" } diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index b5d2beb2f74..b0f6ff14465 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -13,7 +13,7 @@ from collections.abc import Iterable, Mapping, Sequence from datetime import datetime from pathlib import Path -from typing import Any, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict from urllib.parse import urlparse from reflex_base import constants @@ -44,6 +44,9 @@ from reflex.utils import path_ops from reflex.utils.prerequisites import get_web_dir +if TYPE_CHECKING: + from reflex_base.components.memo import _MemoBodyAnalysis + # To re-export this function. merge_imports = imports.merge_imports write_file = path_ops.write_file @@ -462,9 +465,22 @@ def compile_experimental_component_memo( render = copy.copy(definition.component) _apply_root_style(render) - hooks = _root_only_hooks(render) - custom_code = _root_only_custom_code(render) - dynamic_imports = _root_only_dynamic_imports(render) + # Older reflex-base versions do not provide shared body analysis. + analyses = getattr( + RegistrationContext.ensure_context(), "_memo_body_analyses", {} + ) + analysis = analyses.get(definition.component.__dict__.get("_memo_analysis_key")) + if analysis is not None and not analysis.can_reuse(render): + analysis = None + hooks = _root_only_hooks(render, analysis=analysis) + custom_code = _root_only_custom_code(render, analysis=analysis) + if analysis is None: + dynamic_imports = _root_only_dynamic_imports(render) + else: + dynamic_imports = ( + {analysis.dynamic_import} if analysis.dynamic_import else set() + ) + render._imports_cache = analysis.imports # Strings returned by the root's ``add_hooks`` can reference symbols # (``refs``, ``StateContexts``, etc.) that normally reach this module # through descendants' ``_get_hooks_imports`` / ``_get_imports``. JS @@ -477,7 +493,7 @@ def compile_experimental_component_memo( # Swap children for JSX render: the memo body template emits a # ``{children}`` hole in place of the real descendants. render.children = [hole_child] - rendered = render.render() + rendered = render.render() if analysis is None else analysis.rendered else: render = _apply_component_style_for_compile(copy.deepcopy(definition.component)) hooks = render._get_all_hooks() @@ -540,7 +556,9 @@ def compile_experimental_component_memo( ) -def _root_only_hooks(component: Component) -> dict[str, VarData | None]: +def _root_only_hooks( + component: Component, *, analysis: _MemoBodyAnalysis | None = None +) -> dict[str, VarData | None]: """Return hooks contributed by ``component`` itself, not its subtree. Used by the passthrough memo compile path where descendants render in the @@ -549,34 +567,52 @@ def _root_only_hooks(component: Component) -> dict[str, VarData | None]: Args: component: The root component whose own hooks to collect. + analysis: Previously collected artifacts for an unchanged root. Returns: The root-level hook map, keyed by hook source string. """ - code: dict[str, VarData | None] = {} - code.update(component._get_hooks_internal()) - explicit = component._get_hooks() + if analysis is None: + internal = component._get_hooks_internal() + explicit = component._get_hooks() + added = component._get_added_hooks() + else: + internal = analysis.internal_hooks + explicit = analysis.hook + added = analysis.added_hooks + code: dict[str, VarData | None] = dict(internal) if explicit is not None: code[explicit] = None - code.update(component._get_added_hooks()) + code.update(added) return code -def _root_only_custom_code(component: Component) -> dict[str, None]: +def _root_only_custom_code( + component: Component, *, analysis: _MemoBodyAnalysis | None = None +) -> dict[str, None]: """Return custom code contributed by ``component`` itself, not its subtree. Args: component: The root component whose own custom code to collect. + analysis: Previously collected artifacts for an unchanged root. Returns: The root-level custom code snippets. """ code: dict[str, None] = {} - own = component._get_custom_code() + if analysis is None: + own = component._get_custom_code() + additions = ( + clz.add_custom_code(component) + for clz in component._iter_parent_classes_with_method("add_custom_code") + ) + else: + own = analysis.custom_code + additions = analysis.added_custom_code if own is not None: code[own] = None - for clz in component._iter_parent_classes_with_method("add_custom_code"): - for item in clz.add_custom_code(component): + for items in additions: + for item in items: code[item] = None return code diff --git a/tests/benchmarks/fixtures.py b/tests/benchmarks/fixtures.py index 63469330109..72496fa4de1 100644 --- a/tests/benchmarks/fixtures.py +++ b/tests/benchmarks/fixtures.py @@ -418,11 +418,28 @@ def _stateful_page(): ) -@pytest.fixture(params=[_complicated_page, _stateful_page]) +def _repeated_stateful_page() -> Component: + """Build repeated memo bodies with distinct call-site children. + + Returns: + A page containing 100 repeated stateful rows. + """ + return rx.vstack( + *( + rx.hstack( + rx.text(BenchmarkState.counter), + rx.button(f"Increment {index}", on_click=BenchmarkState.increment), + ) + for index in range(100) + ) + ) + + +@pytest.fixture(params=[_complicated_page, _stateful_page, _repeated_stateful_page]) def unevaluated_page(request: pytest.FixtureRequest): return request.param -@pytest.fixture(params=[_complicated_page, _stateful_page]) +@pytest.fixture(params=[_complicated_page, _stateful_page, _repeated_stateful_page]) def evaluated_page(request: pytest.FixtureRequest): return request.param() diff --git a/tests/units/reflex_base/components/test_memo.py b/tests/units/reflex_base/components/test_memo.py new file mode 100644 index 00000000000..b956de6e7f7 --- /dev/null +++ b/tests/units/reflex_base/components/test_memo.py @@ -0,0 +1,170 @@ +"""Tests for compiler-generated memo definitions.""" + +from unittest.mock import patch + +import pytest +from reflex_base.components import memo +from reflex_base.components.component import Component +from reflex_base.constants.compiler import MemoizationMode +from reflex_base.registry import RegistrationContext +from reflex_base.utils.imports import ImportVar +from reflex_base.vars.base import Var, VarData +from reflex_components_core.base.bare import Bare +from reflex_components_core.el.elements.typography import Div + +from reflex.compiler import utils + + +@pytest.mark.parametrize("snapshot", [False, True]) +@pytest.mark.parametrize("has_children", [False, True]) +def test_auto_memo_evaluates_body_once(snapshot: bool, has_children: bool): + """Generated wrappers reuse their body and fixed parameter metadata.""" + component = Div.create("child") if has_children else Div.create() + original_children = list(component.children) + component._memoization_mode = MemoizationMode(recursive=not snapshot) + with patch.object( + memo, "_evaluate_memo_function", wraps=memo._evaluate_memo_function + ) as evaluate: + factory, definition = memo.create_passthrough_component_memo(component) + wrapper = factory() + assert definition.component is definition.component + assert evaluate.call_count == 1 + + assert definition.params == memo._analyze_params(definition.fn, for_component=True) + assert isinstance(wrapper, memo.MemoComponent) + assert definition.component is not component + assert component.children == original_children + if has_children and not snapshot: + assert definition.passthrough_hole_child is definition.component.children[0] + assert isinstance(definition.passthrough_hole_child, Bare) + assert isinstance(component.children[0], Bare) + assert str(definition.passthrough_hole_child.contents) == "children" + assert str(component.children[0].contents) != "children" + else: + assert definition.passthrough_hole_child is None + assert definition.component.children == component.children + assert isinstance(definition.fn(Var(_js_expr="children", _var_type=Component)), Div) + + +def test_auto_memo_snapshot_renders_lifted_rest_props(): + """The retained memo body must render props lifted out of its children.""" + component = Div.create(Bare.create(memo._rest_placeholder("rest"))) + component._memoization_mode = MemoizationMode(recursive=False) + _, definition = memo.create_passthrough_component_memo(component) + rendered = definition.component.render() + assert rendered["children"] == [] + assert "...rest" in rendered["props"] + assert component.children + + +def test_memo_emission_reuses_unchanged_body_analysis(): + """Hashing and emission share a render when root styling changes nothing.""" + with RegistrationContext.ensure_context().fork() as context: + _, definition = memo.create_passthrough_component_memo(Div.create("child")) + analysis = context._memo_body_analyses[ + definition.component.__dict__["_memo_analysis_key"] + ] + with patch.object( + Div, "render", autospec=True, side_effect=Div.render + ) as render: + compiled, _ = utils.compile_experimental_component_memo(definition) + render.assert_not_called() + assert compiled["render"] is analysis.rendered + + +def test_memo_analysis_is_reset_with_registration_context(): + """A definition carried into another context is analyzed there afresh.""" + with RegistrationContext.ensure_context().fork() as context: + _, definition = memo.create_passthrough_component_memo(Div.create("child")) + assert context._memo_body_analyses + with context.fork() as fork: + assert not fork._memo_body_analyses + with patch.object( + Div, "render", autospec=True, side_effect=Div.render + ) as render: + utils.compile_experimental_component_memo(definition) + render.assert_called_once() + + +def test_identical_memo_bodies_share_one_analysis(): + """Repeated bodies retain one analysis that can serve each equivalent copy.""" + with RegistrationContext.ensure_context().fork() as context: + first = Div.create("child") + second = Div.create("child") + digest = memo.component_hash(first, recursive=False) + analysis = context._memo_body_analyses[digest] + assert memo.component_hash(second, recursive=False) == digest + assert context._memo_body_analyses[digest] is analysis + assert analysis.can_reuse(second) + + +def test_memo_analysis_is_invalidated_with_component_caches(): + """Explicitly invalidating a mutated body also invalidates its analysis.""" + with RegistrationContext.ensure_context().fork(): + _, definition = memo.create_passthrough_component_memo(Div.create("child")) + definition.component.style["color"] = "blue" + definition.component._clear_compile_caches() + assert "_memo_analysis_key" not in definition.component.__dict__ + compiled, _ = utils.compile_experimental_component_memo(definition) + assert any("blue" in prop for prop in compiled["render"]["props"]) + + +def test_memo_analysis_is_not_reused_when_app_style_changes(monkeypatch): + """Applying a new app style must render and collect its new dependencies.""" + with RegistrationContext.ensure_context().fork(): + _, definition = memo.create_passthrough_component_memo(Div.create("child")) + monkeypatch.setattr(utils, "_app_style", lambda: {Div: {"color": "red"}}) + compiled, _ = utils.compile_experimental_component_memo(definition) + assert any("red" in prop for prop in compiled["render"]["props"]) + + +def test_memo_analysis_checks_style_dependencies_even_when_css_matches(): + """Equivalent CSS can still acquire additional imports during root styling.""" + + class StyledDiv(Div): + """A component whose default style contributes an extra import.""" + + def add_style(self): + """Return a style with additional metadata. + + Returns: + The style and its import-bearing Var. + """ + return { + "color": Var( + "sharedColor", + str, + VarData(imports={"extra": [ImportVar("useExtra")]}), + ) + } + + with RegistrationContext.ensure_context().fork(): + component = StyledDiv.create("child", color=Var("sharedColor", str)) + _, definition = memo.create_passthrough_component_memo(component) + _, imports = utils.compile_experimental_component_memo(definition) + assert "extra" in imports + + +def test_memo_analysis_does_not_bypass_custom_copy(): + """A custom copy can change fields besides the root's style.""" + + class CopyDiv(Div): + """A component whose copies carry an increasing marker.""" + + def __copy__(self): + """Copy the component and advance its marker. + + Returns: + A component with the next marker value. + """ + clone = super().__copy__() + assert isinstance(clone, CopyDiv) + count = self.custom_attrs.get("data-copy", 0) + assert isinstance(count, int) + clone.custom_attrs = {"data-copy": count + 1} + return clone + + with RegistrationContext.ensure_context().fork(): + _, definition = memo.create_passthrough_component_memo(CopyDiv.create("child")) + compiled, _ = utils.compile_experimental_component_memo(definition) + assert '"data-copy":2' in compiled["render"]["props"] From 17cc3980fa28dc580d14d91296fddbb214c2d317 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 15 Sep 2026 18:12:15 +0500 Subject: [PATCH 8/9] test(memo): drop tautological assert in passthrough evaluation test --- tests/units/reflex_base/components/test_memo.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/units/reflex_base/components/test_memo.py b/tests/units/reflex_base/components/test_memo.py index b956de6e7f7..32456a36b3d 100644 --- a/tests/units/reflex_base/components/test_memo.py +++ b/tests/units/reflex_base/components/test_memo.py @@ -27,7 +27,6 @@ def test_auto_memo_evaluates_body_once(snapshot: bool, has_children: bool): ) as evaluate: factory, definition = memo.create_passthrough_component_memo(component) wrapper = factory() - assert definition.component is definition.component assert evaluate.call_count == 1 assert definition.params == memo._analyze_params(definition.fn, for_component=True) From 4313e7cd06c3351cbbb60f622933b08765d5e6f6 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 15 Sep 2026 18:58:52 +0500 Subject: [PATCH 9/9] chore: regenerate pyi hashes after rebase --- pyi_hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index 27123d06da7..65b6aef7cec 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -120,5 +120,5 @@ "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", "reflex/__init__.pyi": "a3e1782fab4a9aed55f66cc98af8c217", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", - "reflex/experimental/memo.pyi": "9aa59c177ddab519a30fa6b93380ef90" + "reflex/experimental/memo.pyi": "3d05a929d95fd6dd3bcf608bbf716d15" }