Skip to content

Commit 7c9e10e

Browse files
committed
Make memo tag determination based on memoized component
Calculate the memo component's hash, not the hash of the pre-memoized component. This allows PASSTHROUGH children to memoize to the same underlying function and avoid a recursive .render() call. Updating the `_get_component_hash` function to hash the imports, hooks, custom code, and app wraps ensures that structurally similar component do not collapse to the same memo component if critical aspects differ. Previously the hash was only based on the rendered component itself, so hooks, like on_mount would not be factored in.
1 parent a84e29b commit 7c9e10e

5 files changed

Lines changed: 137 additions & 70 deletions

File tree

packages/reflex-base/src/reflex_base/components/component.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1381,6 +1381,47 @@ def render(self) -> dict:
13811381
self._cached_render_result = rendered_dict
13821382
return rendered_dict
13831383

1384+
def _get_component_hash(self) -> str:
1385+
"""Get a stable content hash for this component.
1386+
1387+
The hash incorporates the rendered JSX dict plus the component's
1388+
recursive imports, hooks (including internal lifecycle hooks),
1389+
custom code, and app-wrap components, so two components that
1390+
compile to semantically distinct JS modules hash differently
1391+
even when their ``render()`` output happens to match (e.g. two
1392+
components differing only in ``on_mount``, which is excluded
1393+
from ``_render`` props but lives in the lifecycle hook).
1394+
1395+
Returns:
1396+
The hex digest content hash.
1397+
"""
1398+
hasher = md5(usedforsecurity=False)
1399+
_update_deterministic_hash(hasher, self.render())
1400+
_update_deterministic_hash(hasher, dict(self._get_all_imports()))
1401+
_update_deterministic_hash(hasher, dict(self._get_all_hooks_internal()))
1402+
_update_deterministic_hash(hasher, dict(self._get_all_hooks()))
1403+
_update_deterministic_hash(hasher, dict(self._get_all_custom_code()))
1404+
_update_deterministic_hash(hasher, dict(self._get_all_app_wrap_components()))
1405+
return hasher.hexdigest()
1406+
1407+
def _compute_memo_tag(self) -> str:
1408+
"""Compute a stable tag name for memoizing this component.
1409+
1410+
The class qualname is encoded directly in the tag prefix so that
1411+
distinct classes which happen to render identically never collide
1412+
on a tag. Tag collision would silently share a single cached memo
1413+
wrapper across classes and drop the later class's class-level
1414+
metadata (e.g. ``_get_app_wrap_components``, which carries
1415+
providers like ``UploadFilesProvider`` that must reach the app
1416+
root).
1417+
1418+
Returns:
1419+
The stable tag name.
1420+
"""
1421+
return format.format_state_name(
1422+
f"{type(self).__qualname__}_{self.tag or 'Comp'}_{self._get_component_hash()}"
1423+
).capitalize()
1424+
13841425
def _replace_prop_names(self, rendered_dict: dict) -> None:
13851426
"""Replace the prop names in the render dictionary.
13861427

pyi_hashes.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,5 +120,5 @@
120120
"packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "2c5fadcc014056f041cd4d916137d9e7",
121121
"reflex/__init__.pyi": "3a9bb8544cbc338ffaf0a5927d9156df",
122122
"reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e",
123-
"reflex/experimental/memo.pyi": "9946d9b757f7cef5f53d599194d6e50e"
123+
"reflex/experimental/memo.pyi": "82d8699470071df80886a4a6ba8dccfe"
124124
}

reflex/compiler/plugins/memoize.py

Lines changed: 9 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,7 @@
2222
import dataclasses
2323
from typing import Any
2424

25-
from reflex_base.components.component import (
26-
BaseComponent,
27-
Component,
28-
_deterministic_hash,
29-
_hash_str,
30-
)
25+
from reflex_base.components.component import BaseComponent, Component
3126
from reflex_base.components.memoize_helpers import (
3227
MemoizationStrategy,
3328
fix_event_triggers_for_memo,
@@ -37,40 +32,10 @@
3732
from reflex_base.constants.compiler import MemoizationDisposition
3833
from reflex_base.plugins import ComponentAndChildren, PageContext
3934
from reflex_base.plugins.base import Plugin
40-
from reflex_base.utils import format
4135

4236
from reflex.experimental.memo import create_passthrough_component_memo
4337

4438

45-
def _compute_memo_tag(component: Component) -> str | None:
46-
"""Compute a stable tag name for a memoizable component.
47-
48-
Returns ``None`` for components that render empty (non-visual components
49-
are never memoized).
50-
51-
The class qualname is encoded directly in the tag prefix so that distinct
52-
classes which render identically never collide on a tag. Tag collision
53-
would silently share a single cached memo wrapper across classes and drop
54-
the later class's class-level metadata (e.g. ``_get_app_wrap_components``,
55-
which carries providers like ``UploadFilesProvider`` that must reach the
56-
app root). Baking the qualname into the prefix avoids re-concatenating
57-
the rendered JSX into the hash input on every call.
58-
59-
Args:
60-
component: The component to name.
61-
62-
Returns:
63-
The stable tag name, or ``None`` if the component renders empty.
64-
"""
65-
rendered_code = component.render()
66-
if not rendered_code:
67-
return None
68-
code_hash = _hash_str(_deterministic_hash(rendered_code))
69-
return format.format_state_name(
70-
f"{type(component).__qualname__}_{component.tag or 'Comp'}_{code_hash}"
71-
).capitalize()
72-
73-
7439
def _subtree_has_reactive_data(
7540
component: Component, _cache: dict[int, bool] | None = None
7641
) -> bool:
@@ -374,16 +339,17 @@ def _build_wrapper(
374339
The wrapper instance, or ``None`` if the component's render is
375340
empty and has no meaningful tag.
376341
"""
377-
tag = _compute_memo_tag(comp)
378-
if tag is None:
379-
return None
380-
381342
comp = fix_event_triggers_for_memo(comp, page_context)
382343

383-
compile_context.memoize_wrappers[tag] = None
384344
# Passthrough memo definitions capture app-specific event/state vars, so
385-
# they must be rebuilt for each compile instead of shared globally.
386-
wrapper_factory, definition = create_passthrough_component_memo(tag, comp)
345+
# they must be rebuilt for each compile instead of shared globally. The
346+
# tag is derived from the rendered memo body inside
347+
# ``create_passthrough_component_memo`` after the ``{children}`` hole
348+
# is substituted, so passthrough wrappers that differ only in their
349+
# children collapse to a single definition.
350+
wrapper_factory, definition = create_passthrough_component_memo(comp)
351+
tag = definition.export_name
352+
compile_context.memoize_wrappers[tag] = None
387353
compile_context.auto_memo_components[tag] = definition
388354

389355
wrapper = wrapper_factory()

reflex/experimental/memo.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,7 +1008,6 @@ def _create_component_wrapper(
10081008

10091009

10101010
def create_passthrough_component_memo(
1011-
export_name: str,
10121011
component: Component,
10131012
) -> tuple[
10141013
Callable[..., ExperimentalMemoComponent],
@@ -1020,8 +1019,13 @@ def create_passthrough_component_memo(
10201019
through the experimental memo pipeline instead of emitting ad-hoc page-local
10211020
``React.memo`` declarations.
10221021
1022+
The exported memo name is derived from ``component._compute_memo_tag()``
1023+
after the ``{children}`` hole has been substituted into the wrapped
1024+
component's children (passthrough mode), so two call-sites differing only
1025+
in their children — whose generated memo bodies are identical — collapse
1026+
to one wrapper.
1027+
10231028
Args:
1024-
export_name: The exported memo component name.
10251029
component: The component to wrap.
10261030
10271031
Returns:
@@ -1044,22 +1048,38 @@ def passthrough(children: Var[Component]) -> Component:
10441048
new_component = copy(component)
10451049
if render_snapshot:
10461050
return new_component
1047-
# Keep ``new_component.children`` as the ORIGINAL children so
1048-
# compile-time walkers that introspect the subtree (e.g. Form's
1049-
# ``_get_form_refs``) see the real descendants. The ``{children}``
1050-
# hole lives on the definition and the compiler swaps it in only for
1051-
# JSX render / imports collection.
1052-
captured_hole_child.append(Bare.create(children))
1051+
hole_bare = Bare.create(children)
1052+
captured_hole_child.append(hole_bare)
1053+
# Substitute the ``{children}`` hole for the original descendants so
1054+
# the memo body's hash and JSX both reflect the placeholder, not the
1055+
# specific children at any given call site. Original descendants stay
1056+
# reachable on the page-level wrapper via the plugin's
1057+
# ``_get_all_refs`` delegation back to the source component.
1058+
new_component.children = [hole_bare]
10531059
return new_component
10541060

1055-
passthrough.__name__ = format.to_snake_case(export_name)
1061+
# Evaluate once to compute the tag from the rendered memo body shape.
1062+
# ``_create_component_definition`` will evaluate again internally; the
1063+
# second pass overwrites ``captured_hole_child`` but the captured value
1064+
# is identical.
1065+
params = _analyze_params(passthrough, for_component=True)
1066+
preview = _normalize_component_return(_evaluate_memo_function(passthrough, params))
1067+
if preview is None:
1068+
msg = (
1069+
"`create_passthrough_component_memo` requires a component that "
1070+
"normalizes to `rx.Component`."
1071+
)
1072+
raise TypeError(msg)
1073+
tag = preview._compute_memo_tag()
1074+
1075+
passthrough.__name__ = format.to_snake_case(tag)
10561076
passthrough.__qualname__ = passthrough.__name__
10571077
passthrough.__module__ = __name__
10581078

10591079
definition = _create_component_definition(passthrough, Component)
10601080
replacements: dict[str, Any] = {}
1061-
if definition.export_name != export_name:
1062-
replacements["export_name"] = export_name
1081+
if definition.export_name != tag:
1082+
replacements["export_name"] = tag
10631083
if captured_hole_child:
10641084
replacements["passthrough_hole_child"] = captured_hole_child[0]
10651085
if replacements:

tests/units/compiler/test_memoize_plugin.py

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,50 @@ def test_memoize_wrapper_deduped_across_repeated_subtrees() -> None:
192192
) == 1
193193

194194

195+
def test_memoize_wrappers_distinct_for_different_on_mount() -> None:
196+
"""Two components differing only in ``on_mount`` must NOT dedupe.
197+
198+
``on_mount`` is excluded from ``_render``'s props, so its handler does not
199+
appear in ``component.render()``. The memo wrapper body, however, includes
200+
a ``useEffect`` lifecycle hook whose body invokes the ``on_mount`` handler
201+
— two distinct handlers produce two distinct memo bodies and must compile
202+
to two distinct memo wrappers. Hashing only ``render()`` lets them collide
203+
on a single tag, silently dropping one handler's logic.
204+
"""
205+
ctx, _page_ctx = _compile_single_page(
206+
lambda: Fragment.create(
207+
Plain.create(on_mount=rx.console_log("a")),
208+
Plain.create(on_mount=rx.console_log("b")),
209+
)
210+
)
211+
assert len(ctx.memoize_wrappers) == 2, (
212+
"Components with different on_mount handlers must produce distinct "
213+
f"memo wrappers, got: {list(ctx.memoize_wrappers)}"
214+
)
215+
216+
217+
def test_memoize_wrappers_dedupe_passthrough_with_different_children() -> None:
218+
"""Passthrough memos with identical props but different children must dedupe.
219+
220+
Passthrough memo bodies render a ``{children}`` placeholder rather than
221+
the captured child JSX — the actual children flow through at the page-side
222+
call site. Two components with identical props but different children
223+
therefore generate identical memo body code and must share one wrapper
224+
tag. Hashing the rendered children into the tag splits them needlessly,
225+
bloating the generated component module with duplicate definitions.
226+
"""
227+
ctx, _page_ctx = _compile_single_page(
228+
lambda: Fragment.create(
229+
WithProp.create("apple", label=STATE_VAR),
230+
WithProp.create("banana", label=STATE_VAR),
231+
)
232+
)
233+
assert len(ctx.memoize_wrappers) == 1, (
234+
"Passthrough memos differing only in children must collapse to one "
235+
f"wrapper, got: {list(ctx.memoize_wrappers)}"
236+
)
237+
238+
195239
@pytest.mark.parametrize(
196240
("special_form", "body_marker"),
197241
[
@@ -305,9 +349,7 @@ def test_common_memoization_snapshot_helper_classifies_snapshot_cases() -> None:
305349

306350
def test_generated_memo_component_is_not_itself_memoized() -> None:
307351
"""The generated memo component instance itself is skipped by the heuristic."""
308-
wrapper_factory, _definition = create_passthrough_component_memo(
309-
"MyTag", Fragment.create()
310-
)
352+
wrapper_factory, _definition = create_passthrough_component_memo(Fragment.create())
311353
wrapper = wrapper_factory(Plain.create())
312354
assert isinstance(wrapper, ExperimentalMemoComponent)
313355
assert not _should_memoize(wrapper)
@@ -338,14 +380,16 @@ def test_event_trigger_memoization_not_emit_usecallback_in_page_hooks() -> None:
338380

339381
def test_generated_memo_component_renders_as_its_exported_tag() -> None:
340382
"""The generated experimental memo component renders as its exported tag."""
341-
wrapper_factory, definition = create_passthrough_component_memo(
342-
"MyWrapper_abc", Fragment.create()
343-
)
383+
wrapper_factory, definition = create_passthrough_component_memo(Fragment.create())
344384
wrapper = wrapper_factory(Plain.create())
345385
assert isinstance(wrapper, ExperimentalMemoComponent)
346-
assert wrapper.tag == "MyWrapper_abc"
347-
assert definition.export_name == "MyWrapper_abc"
348-
assert wrapper.render()["name"] == "MyWrapper_abc"
386+
tag = definition.export_name
387+
assert tag.startswith("Fragment_"), (
388+
f"Expected the wrapped class qualname to be encoded in the tag prefix; "
389+
f"got {tag!r}"
390+
)
391+
assert wrapper.tag == tag
392+
assert wrapper.render()["name"] == tag
349393

350394

351395
def test_passthrough_memo_definitions_are_not_shared_globally(monkeypatch) -> None:
@@ -359,18 +403,14 @@ def test_passthrough_memo_definitions_are_not_shared_globally(monkeypatch) -> No
359403
first_component = Plain.create(STATE_VAR)
360404
second_component = Plain.create(STATE_VAR)
361405

362-
monkeypatch.setattr(memoize_plugin, "_compute_memo_tag", lambda comp: tag)
363406
monkeypatch.setattr(
364407
memoize_plugin,
365408
"fix_event_triggers_for_memo",
366409
lambda comp, page_context: comp,
367410
)
368411

369-
def fake_create_passthrough_component_memo(
370-
export_name: str,
371-
component: Component,
372-
):
373-
definition = SimpleNamespace(export_name=export_name, component=component)
412+
def fake_create_passthrough_component_memo(component: Component):
413+
definition = SimpleNamespace(export_name=tag, component=component)
374414
return (lambda definition=definition: definition), definition
375415

376416
monkeypatch.setattr(

0 commit comments

Comments
 (0)