diff --git a/news/6735.performance.md b/news/6735.performance.md new file mode 100644 index 00000000000..6c8802a453f --- /dev/null +++ b/news/6735.performance.md @@ -0,0 +1 @@ +Auto-memoize compile pass no longer rebuilds duplicate memo definitions: the memo tag is computed up front from the call-site component, so repeated identical stateful subtrees reuse the first definition and skip the event-trigger rewrite and body build entirely (~20% faster stateful-page compiles, ~25% on pages dominated by repeated rows). diff --git a/packages/reflex-base/news/6735.performance.md b/packages/reflex-base/news/6735.performance.md new file mode 100644 index 00000000000..ad6cfa4aff3 --- /dev/null +++ b/packages/reflex-base/news/6735.performance.md @@ -0,0 +1 @@ +`create_passthrough_component_memo` computes the memo tag without copying or rendering the memo body, dedups via the new self-registering `registry` parameter (applying the new `prepare_body` transform only when a body is actually built), and shares one params analysis across all passthrough wrappers; the deterministic component hash walk dispatches on exact types and caches dataclass field lookups. diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 0bb0298feb6..e97abbadce6 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -610,6 +610,11 @@ def _hash_str(value: str) -> str: return md5(f'"{value}"'.encode(), usedforsecurity=False).hexdigest() +# Dataclass field names per class, resolved once — ``dataclasses.fields()`` on +# every VarData/ImportVar instance is measurable inside the hash walk. +_DATACLASS_FIELD_NAMES: dict[type, tuple[str, ...]] = {} + + def _update_deterministic_hash(hasher: Any, value: object) -> None: """Feed ``value`` into ``hasher`` using a self-delimiting, type-tagged encoding. @@ -617,6 +622,9 @@ def _update_deterministic_hash(hasher: Any, value: object) -> None: keeps the encoding injective without building intermediate strings — the nested ``str([...])`` approach this replaces was the dominant cost of ``_deterministic_hash`` (~4x speedup on synthetic, ~2x on real renders). + Exact-type checks front-run the ``isinstance`` ladder because the walk is + dominated by plain ``str``/``dict``/``list`` nodes; subclasses (enums, + ``Var``s, dataclasses) fall through to the ladder and hash identically. Args: hasher: A ``hashlib`` hasher (must accept ``.update(bytes)``). @@ -625,7 +633,24 @@ def _update_deterministic_hash(hasher: Any, value: object) -> None: Raises: TypeError: If the value is not hashable. """ - if value is None: + if type(value) is str: + encoded = value.encode() + hasher.update(b"s") + hasher.update(len(encoded).to_bytes(8, "little")) + hasher.update(encoded) + elif type(value) is dict: + items = sorted(value.items(), key=operator.itemgetter(0)) + hasher.update(b"d") + hasher.update(len(items).to_bytes(8, "little")) + for k, v in items: + _update_deterministic_hash(hasher, k) + _update_deterministic_hash(hasher, v) + elif type(value) is list or type(value) is tuple: + hasher.update(b"l") + hasher.update(len(value).to_bytes(8, "little")) + for item in value: + _update_deterministic_hash(hasher, item) + elif value is None: hasher.update(b"N") elif isinstance(value, bool): hasher.update(b"T" if value else b"F") @@ -654,12 +679,15 @@ def _update_deterministic_hash(hasher: Any, value: object) -> None: _update_deterministic_hash(hasher, value._js_expr) _update_deterministic_hash(hasher, value._get_all_var_data()) elif dataclasses.is_dataclass(value): - fields = dataclasses.fields(value) + field_names = _DATACLASS_FIELD_NAMES.get(type(value)) + if field_names is None: + field_names = tuple(field.name for field in dataclasses.fields(value)) + _DATACLASS_FIELD_NAMES[type(value)] = field_names hasher.update(b"D") - hasher.update(len(fields).to_bytes(8, "little")) - for field in fields: - hasher.update(field.name.encode()) - _update_deterministic_hash(hasher, getattr(value, field.name)) + hasher.update(len(field_names).to_bytes(8, "little")) + for field_name in field_names: + hasher.update(field_name.encode()) + _update_deterministic_hash(hasher, getattr(value, field_name)) elif isinstance(value, BaseComponent): hasher.update(b"C") _update_deterministic_hash(hasher, value.render()) @@ -1491,27 +1519,41 @@ def render(self) -> dict: self._cached_render_result = rendered_dict return rendered_dict - def _get_component_hash(self, shallow: bool = False) -> str: + def _get_component_hash( + self, shallow: bool = False, rendered: dict | None = None + ) -> str: """Get a stable content hash for this component. The hash incorporates the rendered JSX dict plus the component's recursive imports, hooks (including internal lifecycle hooks), - custom code, and app-wrap components, so two components that - compile to semantically distinct JS modules hash differently - even when their ``render()`` output happens to match (e.g. two - components differing only in ``on_mount``, which is excluded - from ``_render`` props but lives in the lifecycle hook). + custom code, dynamic imports, and app-wrap components, so two + components that compile to semantically distinct JS modules hash + differently even when their ``render()`` output happens to match + (e.g. two components differing only in ``on_mount``, which is + excluded from ``_render`` props but lives in the lifecycle hook). + + Coverage contract: auto-memoization dedups memo definitions purely by + this hash (see ``create_passthrough_component_memo``), so every input + that ``compile_experimental_component_memo`` emits into the memo + module must be hashed here — an emitted-but-unhashed input would let + two differing bodies collide on one tag. Args: shallow: If True, only hash the component's own render output and directly defined hooks, imports, custom code, and app-wrap components, excluding any of those from child components. + rendered: Pre-computed render dict to hash in place of + ``self.render()``, for callers hashing a hypothetical render + (e.g. the auto-memoize ``{children}``-holed body) without + mutating this component's render cache. Returns: The hex digest content hash. """ hasher = md5(usedforsecurity=False) - _update_deterministic_hash(hasher, self.render()) + _update_deterministic_hash( + hasher, rendered if rendered is not None else self.render() + ) if shallow: # For non-snapshot strategies, we only hash the component's own hooks, imports, custom code, and app-wrap components _update_deterministic_hash(hasher, dict(self._get_imports())) @@ -1519,18 +1561,20 @@ def _get_component_hash(self, shallow: bool = False) -> str: _update_deterministic_hash(hasher, dict(self._get_added_hooks())) _update_deterministic_hash(hasher, self._get_hooks()) _update_deterministic_hash(hasher, self._get_custom_code()) + _update_deterministic_hash(hasher, self._get_dynamic_imports()) _update_deterministic_hash(hasher, dict(self._get_app_wrap_components())) else: _update_deterministic_hash(hasher, dict(self._get_all_imports())) _update_deterministic_hash(hasher, dict(self._get_all_hooks_internal())) _update_deterministic_hash(hasher, dict(self._get_all_hooks())) _update_deterministic_hash(hasher, dict(self._get_all_custom_code())) + _update_deterministic_hash(hasher, sorted(self._get_all_dynamic_imports())) _update_deterministic_hash( hasher, dict(self._get_all_app_wrap_components()) ) return hasher.hexdigest() - def _compute_memo_tag(self) -> str: + def _compute_memo_tag(self, rendered: dict | None = None) -> str: """Compute a stable tag name for memoizing this component. The class qualname is encoded directly in the tag prefix so that @@ -1541,6 +1585,10 @@ def _compute_memo_tag(self) -> str: providers like ``UploadFilesProvider`` that must reach the app root). + Args: + rendered: Pre-computed render dict forwarded to + ``_get_component_hash`` in place of ``self.render()``. + Returns: The stable tag name. """ @@ -1550,7 +1598,8 @@ def _compute_memo_tag(self) -> str: ) comp_hash = self._get_component_hash( - shallow=get_memoization_strategy(self) == MemoizationStrategy.PASSTHROUGH + shallow=get_memoization_strategy(self) == MemoizationStrategy.PASSTHROUGH, + rendered=rendered, ) return format.format_state_name( f"{type(self).__qualname__}_{self.tag or 'Comp'}_{comp_hash}" diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 979d1501bd5..44a169f57c8 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -5,7 +5,7 @@ import dataclasses import inspect import sys -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Mapping, MutableMapping, Sequence from copy import copy from enum import Enum from functools import cache, update_wrapper @@ -285,12 +285,12 @@ class MemoComponentDefinition(MemoDefinition): export_name: str _component: _LazyBody[Component] # For passthrough wrappers built by the auto-memoize plugin: the - # ``Bare``-wrapped ``{children}`` placeholder used when rendering the memo - # body. The ``component`` keeps its ORIGINAL children so compile-time - # walkers (``Form._get_form_refs`` etc.) can introspect the subtree; the - # compiler swaps to this placeholder only for the JSX render and for - # imports collection, so descendants emit their refs/imports/hooks in the - # page scope rather than being duplicated inside the memo body. + # ``Bare``-wrapped ``{children}`` placeholder that stands in as the memo + # body's only child (the ``component``'s original descendants stay in the + # page scope, which renders them into the hole). Compile-time walkers that + # need the real subtree (``Form._get_form_refs`` etc.) reach it through + # the body's ``_get_all_refs``, which delegates to the source component. + # ``None`` for snapshot bodies and components without structural children. passthrough_hole_child: Component | None = None @property @@ -1678,9 +1678,81 @@ def _create_component_wrapper( return _MemoComponentWrapper(definition) +def _passthrough_signature(children: Var[Component]) -> Component: + """Signature template for auto-memoize passthrough wrappers. + + Never called — ``_analyze_params`` reads only its signature. Every + passthrough wrapper shares this exact signature, so the analysis + (``inspect.signature`` + ``get_type_hints``, which eval-compiles the + stringized annotations) runs once and is shared instead of being + recomputed per memoized component. + + Raises: + NotImplementedError: Always; only the signature is meaningful. + """ + raise NotImplementedError + + +@cache +def _passthrough_params() -> tuple[MemoParam, ...]: + """The analyzed params shared by every passthrough wrapper. + + Returns: + The analyzed ``(children: Var[Component])`` params. + """ + return _analyze_params(_passthrough_signature, for_component=True) + + +@cache +def _passthrough_hole_render() -> dict: + """The rendered dict of the constant ``{children}`` hole child. + + Every passthrough wrapper's hole is ``Bare.create()`` + with the same placeholder Var, so its render output is a compile-wide + constant; tag computation hashes this instead of building and rendering a + fresh ``Bare`` per call site. + + Returns: + The hole child's render dict. + """ + from reflex_components_core.base.bare import Bare + + (children_param,) = _passthrough_params() + return Bare.create(children_param.make_placeholder()).render() + + +def _compute_passthrough_tag(component: Component, render_snapshot: bool) -> str: + """Compute the memo tag for ``component`` without building its memo body. + + For passthrough wrappers with children, this hashes the hypothetical + ``{children}``-holed render — ``component``'s own render dict with the + constant hole child substituted — so call sites differing only in their + children collapse to one tag, without copying the component or touching + its render cache. + + Args: + component: The component being auto-memoized. + render_snapshot: Whether the memo body renders the full subtree + (snapshot strategy) rather than a ``{children}``-holed passthrough. + + Returns: + The stable memo tag. + """ + if render_snapshot or not component.children: + # Snapshot bodies hash the full subtree; components without structural + # children get no hole substituted, so their own render is the body. + return component._compute_memo_tag() + rendered = dict(component._render().set(children=[_passthrough_hole_render()])) + component._replace_prop_names(rendered) + return component._compute_memo_tag(rendered=rendered) + + def create_passthrough_component_memo( component: Component, source_module: str | None = None, + registry: MutableMapping[tuple[str, str | None], MemoComponentDefinition] + | None = None, + prepare_body: Callable[[Component], Component] | None = None, ) -> tuple[ Callable[..., MemoComponent], MemoComponentDefinition, @@ -1691,16 +1763,26 @@ def create_passthrough_component_memo( through the memo pipeline instead of emitting ad-hoc page-local ``React.memo`` declarations. - The exported memo name is derived from ``component._compute_memo_tag()`` - after the ``{children}`` hole has been substituted into the wrapped - component's children (passthrough mode), so two call-sites differing only - in their children — whose generated memo bodies are identical — collapse - to one wrapper. + The exported memo name is a content hash of ``component`` with the + ``{children}`` hole standing in for its children (passthrough mode), so + two call-sites differing only in their children — whose generated memo + bodies are identical — collapse to one wrapper. The tag is computed + before ``prepare_body`` runs, which is sound because ``prepare_body`` + must be a deterministic function of the component's content: equal + inputs produce equal prepared bodies. Args: component: The component to wrap. source_module: The user-app Python module that triggered creation of this memo (typically the page that contained the wrapped subtree). + registry: Definitions built so far this compile, keyed by + ``(tag, source_module)`` (``CompileContext.auto_memo_components``). + On a tag hit the cached definition is reused — skipping + ``prepare_body`` and the body build entirely — and on a miss the + new definition is registered before returning. + prepare_body: Content-deterministic transform applied to ``component`` + only when a memo body is actually built (e.g. rewriting event + triggers to memoized ``useCallback`` forms). Returns: The callable memo wrapper and its component definition. @@ -1716,6 +1798,16 @@ def create_passthrough_component_memo( get_memoization_strategy(component) is MemoizationStrategy.SNAPSHOT ) + tag = _compute_passthrough_tag(component, render_snapshot) + + if registry is not None: + cached = registry.get((tag, source_module)) + if cached is not None: + return _create_component_wrapper(cached), cached + + if prepare_body is not None: + component = prepare_body(component) + captured_hole_child: list[Component] = [] def passthrough(children: Var[Component]) -> Component: @@ -1750,32 +1842,33 @@ 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`` will evaluate again internally; the - # second pass overwrites ``captured_hole_child`` but the captured value - # is identical. - params = _analyze_params(passthrough, for_component=True) - preview = _normalize_component_return(_evaluate_memo_function(passthrough, params)) - if preview is None: + params = _passthrough_params() + body = _normalize_component_return(_evaluate_memo_function(passthrough, params)) + if body is None: msg = ( "`create_passthrough_component_memo` requires a component that " "normalizes to `rx.Component`." ) raise TypeError(msg) - tag = preview._compute_memo_tag() passthrough.__name__ = format.to_snake_case(tag) passthrough.__qualname__ = passthrough.__name__ passthrough.__module__ = __name__ - definition = _create_component_definition(passthrough, Component, source_module) - replacements: dict[str, Any] = {} - if definition.export_name != tag: - replacements["export_name"] = tag - if captured_hole_child: - replacements["passthrough_hole_child"] = captured_hole_child[0] - if replacements: - definition = dataclasses.replace(definition, **replacements) + # No ``_lift_rest_props`` pass: the fixed ``(children)`` signature admits + # no rest param, so a ``RestProp`` child can never appear in the body. + definition = MemoComponentDefinition( + fn=passthrough, + python_name=passthrough.__name__, + params=params, + source_module=source_module, + export_name=tag, + _component=_LazyBody.ready(body), + passthrough_hole_child=captured_hole_child[0] if captured_hole_child else None, + ) + + if registry is not None: + registry[tag, source_module] = definition return _create_component_wrapper(definition), definition diff --git a/pyi_hashes.json b/pyi_hashes.json index f5b97d72d2b..84c55530397 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -120,5 +120,5 @@ "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "58521fcd1b514804f534d97624e82c9a", "reflex/__init__.pyi": "56385a4f0d9431eb0056dbc5553a58f9", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", - "reflex/experimental/memo.pyi": "00c9ab0ff3086150278fb330953eeee8" + "reflex/experimental/memo.pyi": "672bcc92e2b92bfe29751056207c0e9b" } diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 36aee008ae7..858bcb8bca2 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -360,10 +360,9 @@ def _build_wrapper( ) -> BaseComponent | None: """Return the memo wrapper component for ``comp``, or ``None`` if untagged. - Rewrites ``comp.event_triggers`` on a page-local clone via - :func:`fix_event_triggers_for_memo` so the memo body renders the - memoized ``useCallback`` forms, and registers the memo definition on - ``compile_context`` so the memo module compile pass emits it. + Registers the memo definition on ``compile_context`` so the memo + module compile pass emits it; when an identical subtree was already + registered this compile, its definition is reused instead. Args: comp: The component being memoized. @@ -374,23 +373,27 @@ def _build_wrapper( The wrapper instance, or ``None`` if the component's render is empty and has no meaningful tag. """ - comp = fix_event_triggers_for_memo(comp, page_context) - # Passthrough memo definitions capture app-specific event/state vars, so # they must be rebuilt for each compile instead of shared globally. The - # tag is derived from the rendered memo body inside - # ``create_passthrough_component_memo`` after the ``{children}`` hole - # is substituted, so passthrough wrappers that differ only in their - # children collapse to a single definition. + # tag hashes ``comp`` with the ``{children}`` hole standing in for its + # children, so wrappers differing only in their children collapse to a + # single definition. The registry (keyed by (tag, source_module) so + # identical-rendering subtrees from different user modules emit into + # the right mirrored memo file) dedups identical subtrees: repeat call + # sites reuse the first definition, skipping the event-trigger rewrite + # and the body build entirely. ``fix_event_triggers_for_memo`` rewrites + # ``comp.event_triggers`` on a page-local clone into memoized + # ``useCallback`` forms; it runs only when a body is actually built, + # and is deterministic in ``comp``'s content, so equal tags yield + # equal bodies regardless of which call site built the definition. wrapper_factory, definition = create_passthrough_component_memo( - comp, source_module=page_context.source_module + comp, + source_module=page_context.source_module, + registry=compile_context.auto_memo_components, + prepare_body=lambda body: fix_event_triggers_for_memo(body, page_context), ) tag = definition.export_name compile_context.memoize_wrappers[tag] = None - # Key by (tag, source_module) so identical-rendering subtrees from - # different user modules each get their own entry and emit into the - # right mirrored memo file. - compile_context.auto_memo_components[tag, definition.source_module] = definition wrapper = wrapper_factory() # The wrapper has no structural children at the page level, but parents diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index fb537e4d176..95d911327a7 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -390,12 +390,12 @@ def compile_experimental_component_memo( """ hole_child = definition.passthrough_hole_child if hole_child is not None: - # Passthrough memo: shallow-copy the root only — ``render.children`` - # still aliases the user-authored descendants so root-level walkers - # (e.g. ``Form._get_form_refs``) can introspect the real subtree, but - # we skip the O(n) deepcopy + recursive style pass. Descendants are - # rendered AND styled in the page scope, not here, so only the root - # needs app-level style merged. + # Passthrough memo: the body's only child is the ``{children}`` hole + # (the user-authored descendants render in the page scope), so a + # shallow copy of the root suffices — no O(n) deepcopy or recursive + # style pass. Root-level walkers that need the real subtree + # (e.g. ``Form._get_form_refs``) reach it via the body's delegated + # ``_get_all_refs``. Only the root needs app-level style merged. render = copy.copy(definition.component) _apply_root_style(render) @@ -411,9 +411,6 @@ def compile_experimental_component_memo( # aliasing above is fine. all_imports = render._get_all_imports() - # 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() else: render = _apply_component_style_for_compile(copy.deepcopy(definition.component)) diff --git a/tests/benchmarks/fixtures.py b/tests/benchmarks/fixtures.py index 63469330109..67e72d3c646 100644 --- a/tests/benchmarks/fixtures.py +++ b/tests/benchmarks/fixtures.py @@ -418,6 +418,28 @@ def _stateful_page(): ) +N_DUPLICATED_ROWS = 200 + + +def _duplicated_row(i: int): + return rx.hstack( + rx.text(BenchmarkState.counter), + rx.button(f"Row {i}", on_click=BenchmarkState.increment), + ) + + +def _duplicated_rows_page(): + """A page of many identical stateful rows. + + Every row yields the same auto-memoize tag, so this stresses the + compiler's memo-definition dedup path. + + Returns: + The page component. + """ + return rx.vstack(*[_duplicated_row(i) for i in range(N_DUPLICATED_ROWS)]) + + @pytest.fixture(params=[_complicated_page, _stateful_page]) def unevaluated_page(request: pytest.FixtureRequest): return request.param diff --git a/tests/benchmarks/test_compilation.py b/tests/benchmarks/test_compilation.py index af084860859..3e0eec03b49 100644 --- a/tests/benchmarks/test_compilation.py +++ b/tests/benchmarks/test_compilation.py @@ -9,7 +9,7 @@ from reflex.compiler.plugins import DefaultCollectorPlugin, default_page_plugins from reflex.compiler.plugins.memoize import MemoizeStatefulPlugin -from .fixtures import ImportOnlyCollectorPlugin +from .fixtures import ImportOnlyCollectorPlugin, _duplicated_rows_page def import_templates(): @@ -108,6 +108,18 @@ def test_compile_page_full_context( benchmark(lambda: _compile_page_full_context(unevaluated_page)) +def test_compile_page_duplicated_rows(benchmark: BenchmarkFixture): + """Compile a page of many identical stateful rows. + + Stresses auto-memoize dedup: every row produces the same memo tag, so + the compile should pay for each unique memo definition once, not per + call site. + """ + import_templates() + + benchmark(lambda: _compile_page_full_context(_duplicated_rows_page)) + + def test_get_all_imports(evaluated_page: Component, benchmark: BenchmarkFixture): benchmark(lambda: evaluated_page._get_all_imports()) diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index b9fb1c5df51..60d5d87f4c2 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -618,13 +618,18 @@ def test_passthrough_memo_definitions_are_not_shared_globally(monkeypatch) -> No ) def fake_create_passthrough_component_memo( - component: Component, source_module: str | None = None + component: Component, + source_module: str | None = None, + registry: dict | None = None, + prepare_body: Callable[[Component], Component] | None = None, ): definition = SimpleNamespace( export_name=tag, component=component, source_module=source_module, ) + if registry is not None: + registry[tag, source_module] = definition return (lambda definition=definition: definition), definition monkeypatch.setattr( @@ -655,6 +660,162 @@ def fake_create_passthrough_component_memo( assert second_definition is not first_definition +def test_create_passthrough_component_memo_reuses_registered_definition() -> None: + """A tag hit in ``registry`` returns the cached definition. + + Regression: the auto-memoize plugin built a full definition (evaluating + the memo body) for every call site and deduped by tag only afterwards, + so N identical subtrees paid N definition builds for one surviving + definition. + """ + registry: dict[tuple[str, str | None], MemoComponentDefinition] = {} + prepared: list[Component] = [] + + def prepare_body(component: Component) -> Component: + prepared.append(component) + return component + + _wrapper_factory, definition = create_passthrough_component_memo( + WithProp.create(label=STATE_VAR), registry=registry, prepare_body=prepare_body + ) + # A cache miss registers the new definition itself. + assert registry[definition.export_name, None] is definition + assert len(prepared) == 1 + + wrapper_factory, cached = create_passthrough_component_memo( + WithProp.create(label=STATE_VAR), registry=registry, prepare_body=prepare_body + ) + assert cached is definition + # A cache hit skips ``prepare_body`` along with the body build. + assert len(prepared) == 1 + wrapper = wrapper_factory(Plain.create()) + assert isinstance(wrapper, MemoComponent) + assert wrapper.tag == definition.export_name + + # A different source module misses the cache and builds its own definition. + _wrapper_factory, other_module = create_passthrough_component_memo( + WithProp.create(label=STATE_VAR), + source_module="memo_dedup_test.module_b", + registry=registry, + prepare_body=prepare_body, + ) + assert other_module is not definition + assert other_module.export_name == definition.export_name + assert len(prepared) == 2 + assert registry[definition.export_name, "memo_dedup_test.module_b"] is other_module + + +def test_passthrough_memo_body_evaluated_once(monkeypatch) -> None: + """Building a passthrough definition runs the memo body exactly once. + + The body evaluation (``copy`` of the wrapped component + hole + substitution) previously ran twice per definition: once to compute the + tag and again inside the definition build. + """ + import reflex_base.components.memo as memo_mod + + calls = 0 + real_copy = memo_mod.copy + + def counting_copy(value): + nonlocal calls + calls += 1 + return real_copy(value) + + monkeypatch.setattr(memo_mod, "copy", counting_copy) + _wrapper_factory, definition = create_passthrough_component_memo( + WithProp.create(label=STATE_VAR) + ) + assert calls == 1 + assert definition.passthrough_hole_child is None + # The single evaluation is the definition body; the tag is computed from + # the source component directly, without copying it. + assert type(definition.component) is WithProp + + +def _stateful_with_trigger(child_text: str, event_js: str) -> Component: + """Build a stateful component with a child and an ``on_click`` trigger. + + Args: + child_text: Literal text for the (page-scope) child. + event_js: JS expression for the ``on_click`` event chain var. + + Returns: + A component the auto-memoize plugin will wrap. + """ + from reflex_base.event import EventChain + + comp = WithProp.create(Plain.create(LiteralVar.create(child_text)), label=STATE_VAR) + comp.event_triggers["on_click"] = Var(_js_expr=event_js)._replace( + _var_type=EventChain, + merge_var_data=VarData(state="TestState"), + ) + return comp + + +def test_plugin_dedups_identical_subtrees_and_fixes_triggers_once(monkeypatch) -> None: + """Identical subtrees share one definition; trigger rewrite runs per definition. + + Call sites differing only in their children collapse to one memo tag, so + the plugin must reuse the first definition — including skipping the + ``fix_event_triggers_for_memo`` rewrite — while a call site with a + different event trigger gets its own tag and definition. + """ + fix_calls: list[Component] = [] + real_fix = memoize_plugin.fix_event_triggers_for_memo + + def counting_fix(comp: Component, page_context: PageContext) -> Component: + fix_calls.append(comp) + return real_fix(comp, page_context) + + monkeypatch.setattr(memoize_plugin, "fix_event_triggers_for_memo", counting_fix) + + ctx, _page_ctx = _compile_single_page( + lambda: Fragment.create( + _stateful_with_trigger("a", "handlerOne"), + # Differs only in its (page-scope) child: same tag, reused definition. + _stateful_with_trigger("b", "handlerOne"), + # Different event trigger: distinct tag, own definition. + _stateful_with_trigger("a", "handlerTwo"), + ) + ) + + assert len(ctx.auto_memo_components) == 2 + assert len(ctx.memoize_wrappers) == 2 + assert len(fix_calls) == 2 + # The reused definition's body carries the memoized useCallback trigger + # even for the call site that skipped the rewrite. + for definition in ctx.auto_memo_components.values(): + (trigger,) = definition.component.event_triggers.values() + assert isinstance(trigger, Var) + assert trigger._js_expr.startswith("on_click_") + + +def test_memo_tag_covers_dynamic_imports() -> None: + """Components differing only in dynamic imports must not share a tag. + + The dedup in ``create_passthrough_component_memo`` is sound only while + ``_get_component_hash`` covers everything the memo module emission reads; + dynamic imports are emitted (``_root_only_dynamic_imports``) and so must + be hashed. + """ + + class DynImport(Component): + tag = "DynImport" + library = "dyn-import-lib" + + def _get_dynamic_imports(self) -> str | None: + return getattr(self, "_test_dynamic_import", None) + + plain = DynImport.create() + dynamic = DynImport.create() + object.__setattr__(dynamic, "_test_dynamic_import", "import('dyn-import-lib')") + + _f1, plain_definition = create_passthrough_component_memo(plain) + _f2, dynamic_definition = create_passthrough_component_memo(dynamic) + assert plain_definition.export_name != dynamic_definition.export_name + + def test_shared_subtree_across_pages_uses_same_tag() -> None: """The same memoizable subtree on multiple pages gets one shared tag.""" ctx = CompileContext(