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/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/+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/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 302130b25d2..49b8457c328 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 c35183d39cb..2a9e5726897 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -29,9 +29,14 @@ from reflex_base import constants from reflex_base.components.app_wraps import collect_subtree_app_wraps -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 ( @@ -44,7 +49,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 @@ -1911,6 +1916,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. @@ -1969,9 +2038,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: @@ -1996,6 +2074,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, @@ -2068,36 +2160,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 5c8f714465a..ba629a36cce 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,12 +150,37 @@ 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 +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/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index f5962b18150..7874b7b2ff5 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -920,6 +920,22 @@ 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 per + # 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) + 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)) + 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)): value = [value] @@ -959,12 +975,16 @@ 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_handler is not None: + RegistrationContext.ensure_context()._bound_event_chains[ + id(bound_handler), id(args_spec), key + ] = (bound_handler, 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..d95c620d442 100644 --- a/packages/reflex-base/src/reflex_base/registry.py +++ b/packages/reflex-base/src/reflex_base/registry.py @@ -11,12 +11,15 @@ 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.components.memo import _MemoBodyAnalysis 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 def _default_bundled_libraries() -> list[str]: @@ -72,6 +75,24 @@ 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) + ) + # (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) + _memo_body_analyses: dict[str, _MemoBodyAnalysis] = dataclasses.field( + default_factory=dict, repr=False + ) + + def _reset_compile_caches(self) -> None: + """Drop the memo and event caches that only need to outlive one compile.""" + self._memoized_event_triggers.clear() + self._bound_event_chains.clear() + self._memo_body_analyses.clear() @property def app(self) -> App: diff --git a/pyi_hashes.json b/pyi_hashes.json index b712a2f2913..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": "27a73a66e238746e5da5accf99a8fdfd" + "reflex/experimental/memo.pyi": "3d05a929d95fd6dd3bcf608bbf716d15" } diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 54f8d90d296..8a2ad648d36 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -1278,6 +1278,7 @@ def compile_app( # ``library`` from the current module layout (handles a module flipping to # a package across hot reloads). reset_memo_component_classes() + RegistrationContext.ensure_context()._reset_compile_caches() for plugin in compiler_plugins: for dependency in plugin.get_frontend_dependencies(): _bundle_library(dependency) diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index c6408166d91..6dccaaf11c2 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,20 @@ 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) + analysis = None + if (key := definition.component.__dict__.get("_memo_analysis_key")) is not None: + analysis = RegistrationContext.ensure_context()._memo_body_analyses.get(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 +491,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 +554,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 +565,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 f5afda90746..846f1e14e84 100644 --- a/tests/benchmarks/fixtures.py +++ b/tests/benchmarks/fixtures.py @@ -481,11 +481,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..32456a36b3d --- /dev/null +++ b/tests/units/reflex_base/components/test_memo.py @@ -0,0 +1,169 @@ +"""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 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"] 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/reflex_base/test_registry.py b/tests/units/reflex_base/test_registry.py index 4740b544f7d..82490effd6c 100644 --- a/tests/units/reflex_base/test_registry.py +++ b/tests/units/reflex_base/test_registry.py @@ -2,6 +2,7 @@ import sys from textwrap import dedent +from typing import Any, cast import pytest from reflex_base.config import Config, get_config, reload_config @@ -421,3 +422,17 @@ def test_bundled_libraries_isolated_between_contexts(): with RegistrationContext() as ctx_b: assert "some-extra-lib" not in ctx_b.bundled_libraries + + +def test_reset_compile_caches_empties_per_compile_maps( + clean_registration_context: RegistrationContext, +): + """A compile starts without entries retained from the previous compile.""" + entry = cast("Any", object()) + clean_registration_context._memo_body_analyses["digest"] = entry + clean_registration_context._memoized_event_triggers["on_click", 1] = entry, entry + clean_registration_context._bound_event_chains[1, 2, None] = entry, entry, entry + clean_registration_context._reset_compile_caches() + assert not clean_registration_context._memo_body_analyses + assert not clean_registration_context._memoized_event_triggers + assert not clean_registration_context._bound_event_chains diff --git a/tests/units/test_event.py b/tests/units/test_event.py index bf3c537bb7c..de47a546cca 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, @@ -1399,3 +1400,89 @@ 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_cache_lives_on_the_registration_context( + forked_registration_context: RegistrationContext, +): + """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 forked_registration_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.""" + + 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 + ) + 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 + )