Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/6735.performance.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions packages/reflex-base/news/6735.performance.md
Original file line number Diff line number Diff line change
@@ -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.
79 changes: 64 additions & 15 deletions packages/reflex-base/src/reflex_base/components/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,13 +610,21 @@ 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.

Each branch writes a distinct type tag plus length-prefixed payload, which
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)``).
Expand All @@ -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")
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -1491,46 +1519,62 @@ 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()))
_update_deterministic_hash(hasher, dict(self._get_hooks_internal()))
_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
Expand All @@ -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.
"""
Expand All @@ -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}"
Expand Down
149 changes: 121 additions & 28 deletions packages/reflex-base/src/reflex_base/components/memo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(<children placeholder>)``
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,
Expand All @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion pyi_hashes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Loading
Loading