Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6b3d4cf
perf(compiler): speed up _update_deterministic_hash ~2.5x
claude Aug 25, 2026
42c075e
docs: add changelog fragment for the component hash speedup
claude Aug 25, 2026
91c4e26
refactor(compiler): move memo-name hashing into the memo module
claude Aug 25, 2026
0d4be28
fix(compiler): release memo-naming caches once compilation is done
claude Aug 25, 2026
d753c6a
fix(compiler): close two more memo-name collisions, bound the hash bu…
claude Aug 25, 2026
628ce4c
fix(compiler): release naming caches from the compile lifecycle
claude Aug 26, 2026
6921d6d
chore: update pyi_hashes.json for the memo module change
claude Aug 27, 2026
b4a391d
Merge main into claude/optimize-deterministic-hash-u1dl2j
claude Aug 27, 2026
d222d24
perf(compiler): resolve hash encoders per type, fix a fifth memo coll…
claude Sep 1, 2026
c356e1a
Merge remote-tracking branch 'origin/main' into claude/pr-6947-incorp…
claude Sep 1, 2026
d8c2466
docs: tighten the changelog fragments
claude Sep 1, 2026
210e002
Delete news/6947.performance.md
masenf Sep 1, 2026
a014143
refactor: move the deterministic hash into its own module
claude Sep 1, 2026
70c75eb
docs: restore the reflex changelog fragment
claude Sep 1, 2026
0150ec5
docs: note why the shared cached get_type_hints is not used here
claude Sep 1, 2026
99dd019
perf(compiler): hash import library names, not the ImportVars under them
claude Sep 1, 2026
be7d2c2
fix(compiler): encode dataclass and enum identity; trim comments
claude Sep 1, 2026
d0415b3
test: cover values larger than the hash flush buffer
claude Sep 1, 2026
66a360f
fix(compiler): hash ImportVars again; don't crash on an unhashable field
claude Sep 1, 2026
0d12a44
test: isolate the annotation-mismatch test from the shared caches
claude Sep 1, 2026
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/6947.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Clear auto-memoization naming caches after compiling app.
7 changes: 7 additions & 0 deletions packages/reflex-base/news/6947.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Auto-memoized components that render identically no longer share a generated memo name, which silently dropped one of the two compiled bodies. The name now accounts for:

- module-level code emitted by `add_custom_code`
- dynamic imports
- app-wrap components, including `rx.text` and the other `MarkdownComponentMap` components that all hashed alike
- the defining module, so same-named components from different modules stay apart
- the identity of dataclasses and enum members, which previously hashed by shape and by `str()`
1 change: 1 addition & 0 deletions packages/reflex-base/news/6947.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Component content hashing, which auto-memoization runs for every memoized component during a compile, encodes roughly 1.2-1.3x faster on large pages. Generated memo module names change as a result; nothing outside the compiled output refers to them.
149 changes: 0 additions & 149 deletions packages/reflex-base/src/reflex_base/components/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import contextlib
import copy
import dataclasses
import enum
import functools
import json
import logging
Expand All @@ -15,7 +14,6 @@
from abc import ABC, ABCMeta, abstractmethod
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from dataclasses import _MISSING_TYPE, MISSING
from hashlib import md5
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast

Expand Down Expand Up @@ -610,88 +608,6 @@ def _components_from(
return ()


def _hash_str(value: str) -> str:
return md5(f'"{value}"'.encode(), usedforsecurity=False).hexdigest()


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).

Args:
hasher: A ``hashlib`` hasher (must accept ``.update(bytes)``).
value: The value to fold into the hasher.

Raises:
TypeError: If the value is not hashable.
"""
if value is None:
hasher.update(b"N")
elif isinstance(value, bool):
hasher.update(b"T" if value else b"F")
elif isinstance(value, (int, float, enum.Enum)):
hasher.update(b"n")
hasher.update(str(value).encode())
elif isinstance(value, str):
encoded = value.encode()
hasher.update(b"s")
hasher.update(len(encoded).to_bytes(8, "little"))
hasher.update(encoded)
elif isinstance(value, 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 isinstance(value, (tuple, list)):
hasher.update(b"l")
hasher.update(len(value).to_bytes(8, "little"))
for item in value:
_update_deterministic_hash(hasher, item)
elif isinstance(value, Var):
hasher.update(b"v")
_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)
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))
elif isinstance(value, BaseComponent):
hasher.update(b"C")
_update_deterministic_hash(hasher, value.render())
else:
msg = (
f"Cannot hash value `{value}` of type `{type(value).__name__}`. "
"Only BaseComponent, Var, VarData, dict, str, tuple, and enum.Enum are supported."
)
raise TypeError(msg)


def _deterministic_hash(value: object) -> str:
"""Hash a rendered dictionary.

Args:
value: The dictionary to hash.

Returns:
The hash of the dictionary.

Raises:
TypeError: If the value is not hashable.
"""
hasher = md5(usedforsecurity=False)
_update_deterministic_hash(hasher, value)
return hasher.hexdigest()


PROHIBITED_LIBRARY_IMPORTS: dict[str, str] = {
"react-router-dom": (
"React Router 8 removed the `react-router-dom` package and Reflex no "
Expand Down Expand Up @@ -1531,71 +1447,6 @@ def render(self) -> dict:
self._cached_render_result = rendered_dict
return rendered_dict

def _get_component_hash(self, shallow: bool = False) -> 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).

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.

Returns:
The hex digest content hash.
"""
hasher = md5(usedforsecurity=False)
_update_deterministic_hash(hasher, 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, 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, dict(self._get_all_app_wrap_components())
)
return hasher.hexdigest()

def _compute_memo_tag(self) -> str:
"""Compute a stable tag name for memoizing this component.

The class qualname is encoded directly in the tag prefix so that
distinct classes which happen to render identically never collide
on a tag. Tag collision would silently share a single cached memo
wrapper across classes and drop the later class's class-level
metadata (e.g. ``_get_app_wrap_components``, which carries
providers like ``UploadFilesProvider`` that must reach the app
root).

Returns:
The stable tag name.
"""
from reflex_base.components.memoize_helpers import (
MemoizationStrategy,
get_memoization_strategy,
)

comp_hash = self._get_component_hash(
shallow=get_memoization_strategy(self) == MemoizationStrategy.PASSTHROUGH
)
return format.format_state_name(
f"{type(self).__qualname__}_{self.tag or 'Comp'}_{comp_hash}"
).capitalize()

def _replace_prop_names(self, rendered_dict: dict) -> None:
"""Replace the prop names in the render dictionary.

Expand Down
92 changes: 89 additions & 3 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, Iterator, Mapping, Sequence
from copy import copy
from enum import Enum
from functools import cache, partial, update_wrapper
Expand Down Expand Up @@ -42,6 +42,7 @@
from reflex_base.event import EventChain, EventHandler, no_args_event_spec, run_script
from reflex_base.registry import RegistrationContext
from reflex_base.utils import console, format, memo_paths
from reflex_base.utils.deterministic_hash import deterministic_hash
Comment thread
masenf marked this conversation as resolved.
from reflex_base.utils.imports import ImportVar
from reflex_base.utils.types import safe_issubclass, typehint_issubclass
from reflex_base.vars import VarData
Expand Down Expand Up @@ -1834,6 +1835,91 @@ def _create_component_wrapper(
return _MemoComponentWrapper(definition)


def _component_artifacts(component: Component, *, recursive: bool) -> Iterator[Any]:
"""Yield everything besides the render that identifies a memo body.

Two components can render identical JSX and still compile to different
modules -- the classic case is a differing ``on_mount``, which ``_render``
omits but which shows up as a lifecycle hook -- so everything else
:func:`~reflex.compiler.utils.compile_experimental_component_memo` puts in
the body has to be part of the hash too: imports, hooks, custom code,
dynamic imports, and app-wrap components.


Args:
component: The component whose memo body is being hashed.
recursive: Whether descendants' artifacts belong to this memo body.
False for a passthrough memo, whose descendants render at the call
site behind the ``{children}`` hole, so only the component's own
artifacts identify the body.

Yields:
Each artifact, in a fixed order.
"""
# The tag prefix carries the qualname but not the module, and a dotted
# module path in the prefix would stretch every generated filename.
cls = type(component)
yield f"{cls.__module__}.{cls.__qualname__}"
if recursive:
yield component._get_all_imports()
yield component._get_all_hooks_internal()
yield component._get_all_hooks()
yield component._get_all_custom_code()
# A set: sort it so the encoding does not ride on iteration order.
yield sorted(component._get_all_dynamic_imports())
yield component._get_all_app_wrap_components()
else:
yield component._get_imports()
yield component._get_hooks_internal()
yield component._get_hooks()
yield component._get_added_hooks()
yield component._get_custom_code()
# ``_get_all_custom_code`` folds in ``add_custom_code`` on the
# recursive side; the own-node side has to ask for it explicitly.
for clz in component._iter_parent_classes_with_method("add_custom_code"):
yield clz.add_custom_code(component)
yield component._get_dynamic_imports()
yield component._get_app_wrap_components()


def component_hash(component: Component, *, recursive: bool) -> str:
"""Get a stable content hash for a component's memo body.

Args:
component: The component being memoized.
recursive: Whether the memo body carries the component's whole subtree
(a snapshot memo) rather than a ``{children}`` hole.

Returns:
The hex digest content hash.
"""
return deterministic_hash(
component.render(), *_component_artifacts(component, recursive=recursive)
)


def memo_tag(component: Component) -> str:
"""Compute a stable tag name for the memo wrapping ``component``.

The class qualname is in the tag prefix so distinct classes that render
identically never share a tag. Sharing one would reuse a single cached memo
wrapper across classes and drop the later class's class-level metadata,
such as the ``_get_app_wrap_components`` providers that must reach the app
root.

Args:
component: The component being memoized.

Returns:
The stable tag name.
"""
recursive = get_memoization_strategy(component) is MemoizationStrategy.SNAPSHOT
return format.format_state_name(
f"{type(component).__qualname__}_{component.tag or 'Comp'}_"
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
f"{component_hash(component, recursive=recursive)}"
).capitalize()


def create_passthrough_component_memo(
component: Component,
source_module: str | None = None,
Expand All @@ -1847,7 +1933,7 @@ 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()``
The exported memo name is derived from :func:`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
Expand Down Expand Up @@ -1918,7 +2004,7 @@ def passthrough(children: Var[Component]) -> Component:
"normalizes to `rx.Component`."
)
raise TypeError(msg)
tag = preview._compute_memo_tag()
tag = memo_tag(preview)

passthrough.__name__ = format.to_snake_case(tag)
passthrough.__qualname__ = passthrough.__name__
Expand Down
Loading
Loading