Skip to content

Commit 9f3c9e8

Browse files
authored
Make memo tag determination based on memoized component (#6453)
* 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. * avoid recursive calls for PASSTHROUGH memo components * fix _get_all_refs for form on_submit
1 parent 095e687 commit 9f3c9e8

5 files changed

Lines changed: 169 additions & 70 deletions

File tree

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

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

1384+
def _get_component_hash(self, shallow: bool = False) -> 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+
Args:
1396+
shallow: If True, only hash the component's own render output and
1397+
directly defined hooks, imports, custom code, and app-wrap
1398+
components, excluding any of those from child components.
1399+
1400+
Returns:
1401+
The hex digest content hash.
1402+
"""
1403+
hasher = md5(usedforsecurity=False)
1404+
_update_deterministic_hash(hasher, self.render())
1405+
if shallow:
1406+
# For non-snapshot strategies, we only hash the component's own hooks, imports, custom code, and app-wrap components
1407+
_update_deterministic_hash(hasher, dict(self._get_imports()))
1408+
_update_deterministic_hash(hasher, dict(self._get_hooks_internal()))
1409+
_update_deterministic_hash(hasher, dict(self._get_added_hooks()))
1410+
_update_deterministic_hash(hasher, self._get_hooks())
1411+
_update_deterministic_hash(hasher, self._get_custom_code())
1412+
_update_deterministic_hash(hasher, dict(self._get_app_wrap_components()))
1413+
else:
1414+
_update_deterministic_hash(hasher, dict(self._get_all_imports()))
1415+
_update_deterministic_hash(hasher, dict(self._get_all_hooks_internal()))
1416+
_update_deterministic_hash(hasher, dict(self._get_all_hooks()))
1417+
_update_deterministic_hash(hasher, dict(self._get_all_custom_code()))
1418+
_update_deterministic_hash(
1419+
hasher, dict(self._get_all_app_wrap_components())
1420+
)
1421+
return hasher.hexdigest()
1422+
1423+
def _compute_memo_tag(self) -> str:
1424+
"""Compute a stable tag name for memoizing this component.
1425+
1426+
The class qualname is encoded directly in the tag prefix so that
1427+
distinct classes which happen to render identically never collide
1428+
on a tag. Tag collision would silently share a single cached memo
1429+
wrapper across classes and drop the later class's class-level
1430+
metadata (e.g. ``_get_app_wrap_components``, which carries
1431+
providers like ``UploadFilesProvider`` that must reach the app
1432+
root).
1433+
1434+
Returns:
1435+
The stable tag name.
1436+
"""
1437+
from reflex_base.components.memoize_helpers import (
1438+
MemoizationStrategy,
1439+
get_memoization_strategy,
1440+
)
1441+
1442+
comp_hash = self._get_component_hash(
1443+
shallow=get_memoization_strategy(self) == MemoizationStrategy.PASSTHROUGH
1444+
)
1445+
return format.format_state_name(
1446+
f"{type(self).__qualname__}_{self.tag or 'Comp'}_{comp_hash}"
1447+
).capitalize()
1448+
13841449
def _replace_prop_names(self, rendered_dict: dict) -> None:
13851450
"""Replace the prop names in the render dictionary.
13861451

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
_is_structural_memoization_child,
@@ -38,40 +33,10 @@
3833
from reflex_base.constants.compiler import MemoizationDisposition
3934
from reflex_base.plugins import ComponentAndChildren, PageContext
4035
from reflex_base.plugins.base import Plugin
41-
from reflex_base.utils import format
4236

4337
from reflex.experimental.memo import create_passthrough_component_memo
4438

4539

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

392-
compile_context.memoize_wrappers[tag] = None
393353
# Passthrough memo definitions capture app-specific event/state vars, so
394-
# they must be rebuilt for each compile instead of shared globally.
395-
wrapper_factory, definition = create_passthrough_component_memo(tag, comp)
354+
# they must be rebuilt for each compile instead of shared globally. The
355+
# tag is derived from the rendered memo body inside
356+
# ``create_passthrough_component_memo`` after the ``{children}`` hole
357+
# is substituted, so passthrough wrappers that differ only in their
358+
# children collapse to a single definition.
359+
wrapper_factory, definition = create_passthrough_component_memo(comp)
360+
tag = definition.export_name
361+
compile_context.memoize_wrappers[tag] = None
396362
compile_context.auto_memo_components[tag] = definition
397363

398364
wrapper = wrapper_factory()

reflex/experimental/memo.py

Lines changed: 39 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,46 @@ 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]
1059+
# Compile-time walkers that need the real subtree (notably
1060+
# ``Form._get_form_refs`` collecting id-based input refs into the
1061+
# generated ``handleSubmit`` JS) call ``self._get_all_refs()`` while
1062+
# the memo body's hooks are computed. With the hole substituted in,
1063+
# that walk would return nothing and the form handler would emit an
1064+
# empty ``field_ref_mapping``. Delegate ref collection back to the
1065+
# source component so descendants behind the hole remain visible.
1066+
object.__setattr__(new_component, "_get_all_refs", component._get_all_refs)
10531067
return new_component
10541068

1055-
passthrough.__name__ = format.to_snake_case(export_name)
1069+
# Evaluate once to compute the tag from the rendered memo body shape.
1070+
# ``_create_component_definition`` will evaluate again internally; the
1071+
# second pass overwrites ``captured_hole_child`` but the captured value
1072+
# is identical.
1073+
params = _analyze_params(passthrough, for_component=True)
1074+
preview = _normalize_component_return(_evaluate_memo_function(passthrough, params))
1075+
if preview is None:
1076+
msg = (
1077+
"`create_passthrough_component_memo` requires a component that "
1078+
"normalizes to `rx.Component`."
1079+
)
1080+
raise TypeError(msg)
1081+
tag = preview._compute_memo_tag()
1082+
1083+
passthrough.__name__ = format.to_snake_case(tag)
10561084
passthrough.__qualname__ = passthrough.__name__
10571085
passthrough.__module__ = __name__
10581086

10591087
definition = _create_component_definition(passthrough, Component)
10601088
replacements: dict[str, Any] = {}
1061-
if definition.export_name != export_name:
1062-
replacements["export_name"] = export_name
1089+
if definition.export_name != tag:
1090+
replacements["export_name"] = tag
10631091
if captured_hole_child:
10641092
replacements["passthrough_hole_child"] = captured_hole_child[0]
10651093
if replacements:

tests/units/compiler/test_memoize_plugin.py

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

198198

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

368412
def test_generated_memo_component_is_not_itself_memoized() -> None:
369413
"""The generated memo component instance itself is skipped by the heuristic."""
370-
wrapper_factory, _definition = create_passthrough_component_memo(
371-
"MyTag", Fragment.create()
372-
)
414+
wrapper_factory, _definition = create_passthrough_component_memo(Fragment.create())
373415
wrapper = wrapper_factory(Plain.create())
374416
assert isinstance(wrapper, ExperimentalMemoComponent)
375417
assert not _should_memoize(wrapper)
@@ -400,14 +442,16 @@ def test_event_trigger_memoization_not_emit_usecallback_in_page_hooks() -> None:
400442

401443
def test_generated_memo_component_renders_as_its_exported_tag() -> None:
402444
"""The generated experimental memo component renders as its exported tag."""
403-
wrapper_factory, definition = create_passthrough_component_memo(
404-
"MyWrapper_abc", Fragment.create()
405-
)
445+
wrapper_factory, definition = create_passthrough_component_memo(Fragment.create())
406446
wrapper = wrapper_factory(Plain.create())
407447
assert isinstance(wrapper, ExperimentalMemoComponent)
408-
assert wrapper.tag == "MyWrapper_abc"
409-
assert definition.export_name == "MyWrapper_abc"
410-
assert wrapper.render()["name"] == "MyWrapper_abc"
448+
tag = definition.export_name
449+
assert tag.startswith("Fragment_"), (
450+
f"Expected the wrapped class qualname to be encoded in the tag prefix; "
451+
f"got {tag!r}"
452+
)
453+
assert wrapper.tag == tag
454+
assert wrapper.render()["name"] == tag
411455

412456

413457
def test_passthrough_memo_definitions_are_not_shared_globally(monkeypatch) -> None:
@@ -421,18 +465,14 @@ def test_passthrough_memo_definitions_are_not_shared_globally(monkeypatch) -> No
421465
first_component = Plain.create(STATE_VAR)
422466
second_component = Plain.create(STATE_VAR)
423467

424-
monkeypatch.setattr(memoize_plugin, "_compute_memo_tag", lambda comp: tag)
425468
monkeypatch.setattr(
426469
memoize_plugin,
427470
"fix_event_triggers_for_memo",
428471
lambda comp, page_context: comp,
429472
)
430473

431-
def fake_create_passthrough_component_memo(
432-
export_name: str,
433-
component: Component,
434-
):
435-
definition = SimpleNamespace(export_name=export_name, component=component)
474+
def fake_create_passthrough_component_memo(component: Component):
475+
definition = SimpleNamespace(export_name=tag, component=component)
436476
return (lambda definition=definition: definition), definition
437477

438478
monkeypatch.setattr(

0 commit comments

Comments
 (0)