From 6b3d4cfb24faad0bab49883535cd83c14e0f5d20 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 19:27:37 +0000 Subject: [PATCH 1/7] perf(compiler): speed up _update_deterministic_hash ~2.5x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hash feeds every value one `hasher.update()` call at a time and walks the full `isinstance` ladder per node, so a single component hash costs tens of thousands of C calls. On a foreach/cond-heavy page, `_get_component_hash` is ~50% of compile wall time. Encode into a `bytearray` flushed to the hasher in 64KB chunks instead of per node, dispatch on the exact type before falling back to the `isinstance` ladder for subclasses, cache each dataclass type's field layout with pre-encoded names, and cache the encoded form of short strings and of `ImportVar` instances (a frozen dataclass of `str`/`bool`/`None` fields, so its generated equality means exactly "same encoding", and it accounts for most of what a component hash consumes: 5664 visits across just 12 distinct values on one benchmark page). The byte stream is unchanged, so every digest is identical to before — verified against a copy of the previous implementation over all values hashed while compiling four benchmark pages. 2.3-2.6x faster on the large pages, 1.8-1.9x on the small ones. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr --- .../src/reflex_base/components/component.py | 213 ++++++++++++++---- tests/units/components/test_component.py | 113 +++++++++- 2 files changed, 286 insertions(+), 40 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index c94e0198c68..49fa8ae70d8 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -614,65 +614,202 @@ 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. +_HASH_BUFFER_FLUSH_SIZE = 1 << 16 +_HASH_MAX_CACHED_STR = 128 +_HASH_MAX_CACHE_ENTRIES = 4096 + +# Encoded forms of the values that recur across every component hashed during a +# compile: short strings (dict keys, tags, module paths) and ``ImportVar`` +# instances, which make up the bulk of what ``_get_component_hash`` feeds in. +# ``ImportVar`` is a frozen dataclass whose fields are all ``str``/``bool``/ +# ``None``, so its generated equality means exactly "same encoding" and is safe +# to key a cache on. Both caches stop admitting new entries at +# ``_HASH_MAX_CACHE_ENTRIES`` so an app rendering many one-off strings can't +# grow them without bound; the recurring values get in first and stay. +_hash_str_encodings: dict[str, bytes] = {} +_hash_import_var_encodings: dict[ImportVar, bytes] = {} +_hash_dataclass_layouts: dict[type, tuple[bytes, tuple[tuple[bytes, str], ...]]] = {} + + +def _hash_dataclass_layout(cls: type) -> tuple[bytes, tuple[tuple[bytes, str], ...]]: + """Get the cached type tag and pre-encoded field names for a dataclass. - 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: + cls: The dataclass type to describe. + + Returns: + The type tag plus field count, and each field's encoded and plain name. + """ + layout = _hash_dataclass_layouts.get(cls) + if layout is None: + fields = dataclasses.fields(cls) # pyright: ignore [reportArgumentType] + layout = ( + b"D" + len(fields).to_bytes(8, "little"), + tuple((field.name.encode(), field.name) for field in fields), + ) + _hash_dataclass_layouts[cls] = layout + return layout + + +def _encode_str_for_hash(value: str) -> bytes: + """Encode a string as a type-tagged, length-prefixed payload. Args: - hasher: A ``hashlib`` hasher (must accept ``.update(bytes)``). - value: The value to fold into the hasher. + value: The string to encode. + + Returns: + The encoded string. + """ + encoded = value.encode() + return b"s" + len(encoded).to_bytes(8, "little") + encoded + + +def _encode_deterministic(value: Any, out: bytearray, hasher: Any | None) -> None: + """Append ``value``'s self-delimiting encoding to ``out``. + + Dispatch is on the exact type so the common leaves (strings, bools, + containers, imports) skip the ``isinstance`` ladder in + :func:`_encode_deterministic_subclass`, which handles everything else. + ``out`` is flushed into ``hasher`` at container boundaries once it grows + past ``_HASH_BUFFER_FLUSH_SIZE``, so encoding a large subtree never + buffers the whole thing. + + Args: + value: The value to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into when it grows too large, or + ``None`` when ``out`` is a sub-buffer whose full contents the caller + needs and so must not be drained mid-encoding. + """ + value_type = type(value) + if value_type is str: + encoded = _hash_str_encodings.get(value) + if encoded is None: + encoded = _encode_str_for_hash(value) + if ( + len(value) <= _HASH_MAX_CACHED_STR + and len(_hash_str_encodings) < _HASH_MAX_CACHE_ENTRIES + ): + _hash_str_encodings[value] = encoded + out += encoded + elif value_type is bool: + out += b"T" if value else b"F" + elif value_type is dict: + out += b"d" + out += len(value).to_bytes(8, "little") + for k, v in sorted(value.items(), key=operator.itemgetter(0)): + _encode_deterministic(k, out, hasher) + _encode_deterministic(v, out, hasher) + if len(out) > _HASH_BUFFER_FLUSH_SIZE and hasher is not None: + hasher.update(out) + del out[:] + elif value_type is ImportVar: + encoded = _hash_import_var_encodings.get(value) + if encoded is None: + header, fields = _hash_dataclass_layout(ImportVar) + buffer = bytearray(header) + for encoded_name, name in fields: + buffer += encoded_name + _encode_deterministic(getattr(value, name), buffer, None) + encoded = bytes(buffer) + if len(_hash_import_var_encodings) < _HASH_MAX_CACHE_ENTRIES: + _hash_import_var_encodings[value] = encoded + out += encoded + elif value_type is list or value_type is tuple: + out += b"l" + out += len(value).to_bytes(8, "little") + for item in value: + _encode_deterministic(item, out, hasher) + if len(out) > _HASH_BUFFER_FLUSH_SIZE and hasher is not None: + hasher.update(out) + del out[:] + elif value is None: + out += b"N" + elif value_type is int or value_type is float: + out += b"n" + out += str(value).encode() + else: + _encode_deterministic_subclass(value, out, hasher) + + +def _encode_deterministic_subclass( + value: Any, out: bytearray, hasher: Any | None +) -> None: + """Append the encoding of a value whose exact type has no fast path. + + Covers subclasses of the fast-path types — notably ``str``-based enums, + which must encode as enums rather than as strings — plus ``Var``, + dataclasses, and components. + + Args: + value: The value to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into when it grows too large, or + ``None`` when ``out`` must not be drained mid-encoding. 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") + if isinstance(value, bool): + out += b"T" if value else b"F" elif isinstance(value, (int, float, enum.Enum)): - hasher.update(b"n") - hasher.update(str(value).encode()) + out += b"n" + out += 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) + out += _encode_str_for_hash(value) 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) + out += b"d" + out += len(value).to_bytes(8, "little") + for k, v in sorted(value.items(), key=operator.itemgetter(0)): + _encode_deterministic(k, out, hasher) + _encode_deterministic(v, out, hasher) elif isinstance(value, (tuple, list)): - hasher.update(b"l") - hasher.update(len(value).to_bytes(8, "little")) + out += b"l" + out += len(value).to_bytes(8, "little") for item in value: - _update_deterministic_hash(hasher, item) + _encode_deterministic(item, out, hasher) elif isinstance(value, Var): - hasher.update(b"v") - _update_deterministic_hash(hasher, value._js_expr) - _update_deterministic_hash(hasher, value._get_all_var_data()) + out += b"v" + _encode_deterministic(value._js_expr, out, hasher) + _encode_deterministic(value._get_all_var_data(), out, hasher) 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)) + header, fields = _hash_dataclass_layout( + value if isinstance(value, type) else type(value) + ) + out += header + for encoded_name, name in fields: + out += encoded_name + _encode_deterministic(getattr(value, name), out, hasher) elif isinstance(value, BaseComponent): - hasher.update(b"C") - _update_deterministic_hash(hasher, value.render()) + out += b"C" + _encode_deterministic(value.render(), out, hasher) 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) + if len(out) > _HASH_BUFFER_FLUSH_SIZE and hasher is not None: + hasher.update(out) + del out[:] + + +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 + encoding is buffered in a ``bytearray`` and handed to the hasher in large + chunks instead of one ``update`` per node, since a single component hash + covers tens of thousands of nodes. + + Args: + hasher: A ``hashlib`` hasher (must accept ``.update(bytes)``). + value: The value to fold into the hasher. + """ + buffer = bytearray() + _encode_deterministic(value, buffer, hasher) + hasher.update(buffer) def _deterministic_hash(value: object) -> str: diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index 3325e11ac4f..6339cb0ecf9 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -4,8 +4,13 @@ from typing import Any, ClassVar, TypedDict import pytest -from reflex_base.components.component import Component, field -from reflex_base.constants import EventTriggers +from reflex_base.components.component import ( + _HASH_MAX_CACHE_ENTRIES, + Component, + _deterministic_hash, + field, +) +from reflex_base.constants import EventTriggers, Hooks from reflex_base.constants.state import FIELD_MARKER from reflex_base.event import ( EventChain, @@ -2341,3 +2346,107 @@ def test_get_all_hooks_internal_does_not_mutate_hooks_cache(): assert dict(parent._get_hooks_internal()) == parent_own_hooks # And repeated collection yields the same result. assert parent._get_all_hooks_internal() == combined + + +def test_deterministic_hash_is_stable(): + """The same value must hash identically across calls and dict orderings.""" + value = {"b": [1, "x"], "a": {"k": None}} + reordered = {"a": {"k": None}, "b": [1, "x"]} + + assert _deterministic_hash(value) == _deterministic_hash(value) + assert _deterministic_hash(value) == _deterministic_hash(reordered) + + +@pytest.mark.parametrize( + ("left", "right"), + [ + # Type tags must keep values of different types apart. + ("1", 1), + (1, True), + (0, False), + (None, "None"), + ({"a": "b"}, [["a", "b"]]), + # Length prefixes must keep concatenations apart. + (["ab", "c"], ["a", "bc"]), + ([[], []], [[[]]]), + ({"a": "", "b": ""}, {"ab": ""}), + # Nested containers must not flatten into their contents. + ([1, [2]], [1, 2]), + # str-keyed enums encode as enums, not as their string value. + (Hooks.HookPosition.PRE_TRIGGER, Hooks.HookPosition.PRE_TRIGGER.value), + # Dataclasses of the same shape but different types stay distinct. + (ImportVar(tag="a"), ImportVar(tag="a", alias="a")), + ], +) +def test_deterministic_hash_distinguishes(left: Any, right: Any): + """Distinct values must not collide under the type-tagged encoding.""" + assert _deterministic_hash(left) != _deterministic_hash(right) + + +def test_deterministic_hash_treats_lists_and_tuples_alike(): + """Sequences share one type tag, so a list and tuple of equal items match.""" + assert _deterministic_hash([1, "a"]) == _deterministic_hash((1, "a")) + + +def test_deterministic_hash_import_var_cache_is_by_value(): + """Equal ``ImportVar`` instances hash the same; unequal ones do not. + + ``ImportVar`` encodings are cached by value, so a stale or over-eager cache + entry would show up as two unequal imports hashing alike. + """ + a = ImportVar(tag="useState", is_default=False, install=True) + b = ImportVar(tag="useState", is_default=False, install=True) + c = ImportVar(tag="useState", is_default=True, install=True) + + assert _deterministic_hash(a) == _deterministic_hash(b) + assert _deterministic_hash(a) != _deterministic_hash(c) + assert _deterministic_hash({"react": (a, c)}) != _deterministic_hash({ + "react": (c, a) + }) + + +def test_deterministic_hash_long_strings(): + """Strings past the encoding cache's size limit still hash correctly.""" + long_a = "a" * 10_000 + long_b = "a" * 9_999 + "b" + + assert _deterministic_hash(long_a) == _deterministic_hash("a" * 10_000) + assert _deterministic_hash(long_a) != _deterministic_hash(long_b) + + +def test_deterministic_hash_beyond_string_cache_capacity(): + """Strings that arrive after the encoding cache fills still hash correctly.""" + values = [f"cache_capacity_probe_{i}" for i in range(_HASH_MAX_CACHE_ENTRIES + 500)] + digests = [_deterministic_hash(value) for value in values] + + assert len(set(digests)) == len(values) + assert [_deterministic_hash(value) for value in values] == digests + + +def test_deterministic_hash_flushes_large_payloads(): + """A payload larger than the buffer flush size hashes deterministically.""" + payload = {f"key_{i}": "v" * 200 for i in range(2000)} + + assert _deterministic_hash(payload) == _deterministic_hash(dict(payload)) + mutated = {**payload, "key_0": "w" * 200} + assert _deterministic_hash(payload) != _deterministic_hash(mutated) + + +def test_deterministic_hash_components_and_vars(): + """Components and Vars hash by rendered content, not by identity.""" + assert _deterministic_hash(Bare.create(contents="a")) == _deterministic_hash( + Bare.create(contents="a") + ) + assert _deterministic_hash(Bare.create(contents="a")) != _deterministic_hash( + Bare.create(contents="b") + ) + assert _deterministic_hash(Var("a")) == _deterministic_hash(Var("a")) + assert _deterministic_hash(Var("a")) != _deterministic_hash(Var("b")) + # A Var and the bare string it renders to must not collide. + assert _deterministic_hash(Var("a")) != _deterministic_hash("a") + + +def test_deterministic_hash_rejects_unsupported_types(): + """Values with no encoding raise rather than hashing to a shared digest.""" + with pytest.raises(TypeError): + _deterministic_hash(object()) From 42c075ebdf3bcd9a058ffeca69e96b15ebff9f3a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 21:18:26 +0000 Subject: [PATCH 2/7] docs: add changelog fragment for the component hash speedup Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr --- packages/reflex-base/news/6947.performance.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/reflex-base/news/6947.performance.md diff --git a/packages/reflex-base/news/6947.performance.md b/packages/reflex-base/news/6947.performance.md new file mode 100644 index 00000000000..bd38a025a5e --- /dev/null +++ b/packages/reflex-base/news/6947.performance.md @@ -0,0 +1 @@ +Component content hashing, which auto-memoization runs for every memoized component during a compile, is 2.3-2.6x faster on large pages. The hash now buffers its encoding instead of feeding the hasher one value at a time, dispatches on the exact type, and caches the encoded form of the strings and `ImportVar`s that recur across every component. Digests are byte-for-byte unchanged, so generated memo names stay stable. From 91c4e26eaf1634c2aed12e8061da5c44a5977491 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 22:15:36 +0000 Subject: [PATCH 3/7] refactor(compiler): move memo-name hashing into the memo module The deterministic hash exists for exactly one purpose: giving an auto-memoized component a stable, non-colliding export name. It lived in `component.py` as `Component._get_component_hash` and `Component._compute_memo_tag`, but nothing outside `memo.py` ever called either, and neither is a property of a component the way `render()` or `_get_imports()` is. Move the encoder and both entry points into `memo.py` as `component_hash(component, *, recursive=...)` and `memo_tag(component)`, next to the `create_passthrough_component_memo` call site, and drop the two methods from `Component`. The `shallow` flag becomes `recursive`, named for what it means at the call site: a snapshot memo body carries its whole subtree, a passthrough body carries a `{children}` hole. Also drops the unused `_hash_str` helper. The own-node artifact set was missing `add_custom_code`: `_get_custom_code` was hashed but the classmethod extension point was not, while the recursive side picked it up through `_get_all_custom_code`. Two passthrough bodies that rendered identically and differed only in the module-level code they emit therefore shared one memo module, and one of the two code blocks was dropped. Fed explicitly now, with a regression test. Compile wall time is unchanged; this is a structural change plus the collision fix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr --- packages/reflex-base/news/6947.bugfix.md | 1 + .../src/reflex_base/components/component.py | 286 ---------------- .../src/reflex_base/components/memo.py | 308 +++++++++++++++++- tests/units/components/test_component.py | 113 +------ tests/units/components/test_memo.py | 187 +++++++++++ 5 files changed, 495 insertions(+), 400 deletions(-) create mode 100644 packages/reflex-base/news/6947.bugfix.md diff --git a/packages/reflex-base/news/6947.bugfix.md b/packages/reflex-base/news/6947.bugfix.md new file mode 100644 index 00000000000..267ec73a90d --- /dev/null +++ b/packages/reflex-base/news/6947.bugfix.md @@ -0,0 +1 @@ +Auto-memoized components whose module-level code came from `add_custom_code` no longer collide on a generated memo name. Two otherwise-identical components emitting different custom code shared one memo module, so one of their two code blocks was dropped from the compiled output. diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 49fa8ae70d8..5d142bd4f1d 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -6,7 +6,6 @@ import contextlib import copy import dataclasses -import enum import functools import logging import operator @@ -14,7 +13,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 @@ -610,225 +608,6 @@ def _components_from( return () -def _hash_str(value: str) -> str: - return md5(f'"{value}"'.encode(), usedforsecurity=False).hexdigest() - - -_HASH_BUFFER_FLUSH_SIZE = 1 << 16 -_HASH_MAX_CACHED_STR = 128 -_HASH_MAX_CACHE_ENTRIES = 4096 - -# Encoded forms of the values that recur across every component hashed during a -# compile: short strings (dict keys, tags, module paths) and ``ImportVar`` -# instances, which make up the bulk of what ``_get_component_hash`` feeds in. -# ``ImportVar`` is a frozen dataclass whose fields are all ``str``/``bool``/ -# ``None``, so its generated equality means exactly "same encoding" and is safe -# to key a cache on. Both caches stop admitting new entries at -# ``_HASH_MAX_CACHE_ENTRIES`` so an app rendering many one-off strings can't -# grow them without bound; the recurring values get in first and stay. -_hash_str_encodings: dict[str, bytes] = {} -_hash_import_var_encodings: dict[ImportVar, bytes] = {} -_hash_dataclass_layouts: dict[type, tuple[bytes, tuple[tuple[bytes, str], ...]]] = {} - - -def _hash_dataclass_layout(cls: type) -> tuple[bytes, tuple[tuple[bytes, str], ...]]: - """Get the cached type tag and pre-encoded field names for a dataclass. - - Args: - cls: The dataclass type to describe. - - Returns: - The type tag plus field count, and each field's encoded and plain name. - """ - layout = _hash_dataclass_layouts.get(cls) - if layout is None: - fields = dataclasses.fields(cls) # pyright: ignore [reportArgumentType] - layout = ( - b"D" + len(fields).to_bytes(8, "little"), - tuple((field.name.encode(), field.name) for field in fields), - ) - _hash_dataclass_layouts[cls] = layout - return layout - - -def _encode_str_for_hash(value: str) -> bytes: - """Encode a string as a type-tagged, length-prefixed payload. - - Args: - value: The string to encode. - - Returns: - The encoded string. - """ - encoded = value.encode() - return b"s" + len(encoded).to_bytes(8, "little") + encoded - - -def _encode_deterministic(value: Any, out: bytearray, hasher: Any | None) -> None: - """Append ``value``'s self-delimiting encoding to ``out``. - - Dispatch is on the exact type so the common leaves (strings, bools, - containers, imports) skip the ``isinstance`` ladder in - :func:`_encode_deterministic_subclass`, which handles everything else. - ``out`` is flushed into ``hasher`` at container boundaries once it grows - past ``_HASH_BUFFER_FLUSH_SIZE``, so encoding a large subtree never - buffers the whole thing. - - Args: - value: The value to encode. - out: The buffer to append the encoding to. - hasher: The hasher ``out`` is flushed into when it grows too large, or - ``None`` when ``out`` is a sub-buffer whose full contents the caller - needs and so must not be drained mid-encoding. - """ - value_type = type(value) - if value_type is str: - encoded = _hash_str_encodings.get(value) - if encoded is None: - encoded = _encode_str_for_hash(value) - if ( - len(value) <= _HASH_MAX_CACHED_STR - and len(_hash_str_encodings) < _HASH_MAX_CACHE_ENTRIES - ): - _hash_str_encodings[value] = encoded - out += encoded - elif value_type is bool: - out += b"T" if value else b"F" - elif value_type is dict: - out += b"d" - out += len(value).to_bytes(8, "little") - for k, v in sorted(value.items(), key=operator.itemgetter(0)): - _encode_deterministic(k, out, hasher) - _encode_deterministic(v, out, hasher) - if len(out) > _HASH_BUFFER_FLUSH_SIZE and hasher is not None: - hasher.update(out) - del out[:] - elif value_type is ImportVar: - encoded = _hash_import_var_encodings.get(value) - if encoded is None: - header, fields = _hash_dataclass_layout(ImportVar) - buffer = bytearray(header) - for encoded_name, name in fields: - buffer += encoded_name - _encode_deterministic(getattr(value, name), buffer, None) - encoded = bytes(buffer) - if len(_hash_import_var_encodings) < _HASH_MAX_CACHE_ENTRIES: - _hash_import_var_encodings[value] = encoded - out += encoded - elif value_type is list or value_type is tuple: - out += b"l" - out += len(value).to_bytes(8, "little") - for item in value: - _encode_deterministic(item, out, hasher) - if len(out) > _HASH_BUFFER_FLUSH_SIZE and hasher is not None: - hasher.update(out) - del out[:] - elif value is None: - out += b"N" - elif value_type is int or value_type is float: - out += b"n" - out += str(value).encode() - else: - _encode_deterministic_subclass(value, out, hasher) - - -def _encode_deterministic_subclass( - value: Any, out: bytearray, hasher: Any | None -) -> None: - """Append the encoding of a value whose exact type has no fast path. - - Covers subclasses of the fast-path types — notably ``str``-based enums, - which must encode as enums rather than as strings — plus ``Var``, - dataclasses, and components. - - Args: - value: The value to encode. - out: The buffer to append the encoding to. - hasher: The hasher ``out`` is flushed into when it grows too large, or - ``None`` when ``out`` must not be drained mid-encoding. - - Raises: - TypeError: If the value is not hashable. - """ - if isinstance(value, bool): - out += b"T" if value else b"F" - elif isinstance(value, (int, float, enum.Enum)): - out += b"n" - out += str(value).encode() - elif isinstance(value, str): - out += _encode_str_for_hash(value) - elif isinstance(value, dict): - out += b"d" - out += len(value).to_bytes(8, "little") - for k, v in sorted(value.items(), key=operator.itemgetter(0)): - _encode_deterministic(k, out, hasher) - _encode_deterministic(v, out, hasher) - elif isinstance(value, (tuple, list)): - out += b"l" - out += len(value).to_bytes(8, "little") - for item in value: - _encode_deterministic(item, out, hasher) - elif isinstance(value, Var): - out += b"v" - _encode_deterministic(value._js_expr, out, hasher) - _encode_deterministic(value._get_all_var_data(), out, hasher) - elif dataclasses.is_dataclass(value): - header, fields = _hash_dataclass_layout( - value if isinstance(value, type) else type(value) - ) - out += header - for encoded_name, name in fields: - out += encoded_name - _encode_deterministic(getattr(value, name), out, hasher) - elif isinstance(value, BaseComponent): - out += b"C" - _encode_deterministic(value.render(), out, hasher) - 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) - if len(out) > _HASH_BUFFER_FLUSH_SIZE and hasher is not None: - hasher.update(out) - del out[:] - - -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 - encoding is buffered in a ``bytearray`` and handed to the hasher in large - chunks instead of one ``update`` per node, since a single component hash - covers tens of thousands of nodes. - - Args: - hasher: A ``hashlib`` hasher (must accept ``.update(bytes)``). - value: The value to fold into the hasher. - """ - buffer = bytearray() - _encode_deterministic(value, buffer, hasher) - hasher.update(buffer) - - -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() - - @dataclasses.dataclass(kw_only=True, frozen=True, slots=True) class TriggerDefinition: """A default event trigger with its args spec and description.""" @@ -1632,71 +1411,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. diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 8c0d1e9d98a..8d16507671e 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -3,12 +3,15 @@ from __future__ import annotations import dataclasses +import enum import inspect +import operator import sys from collections.abc import Callable, Mapping, Sequence from copy import copy from enum import Enum from functools import cache, partial, update_wrapper +from hashlib import md5 from types import UnionType from typing import ( Annotated, @@ -28,7 +31,7 @@ from reflex_components_core.base.fragment import Fragment from reflex_base import constants -from reflex_base.components.component import Component +from reflex_base.components.component import BaseComponent, Component from reflex_base.components.memoize_helpers import ( MemoizationStrategy, get_memoization_strategy, @@ -1758,6 +1761,305 @@ def _create_component_wrapper( return _MemoComponentWrapper(definition) +_HASH_BUFFER_FLUSH_SIZE = 1 << 16 +_HASH_MAX_CACHED_STR = 128 +_HASH_MAX_CACHE_ENTRIES = 4096 + +# Encoded forms of the values that recur across every component hashed during a +# compile: short strings (dict keys, tags, module paths) and ``ImportVar`` +# instances, which make up the bulk of what a component hash feeds in. +# ``ImportVar`` is a frozen dataclass whose fields are all ``str``/``bool``/ +# ``None``, so its generated equality means exactly "same encoding" and is safe +# to key a cache on. Both caches stop admitting new entries at +# ``_HASH_MAX_CACHE_ENTRIES`` so an app rendering many one-off strings can't +# grow them without bound; the recurring values get in first and stay. +_hash_str_encodings: dict[str, bytes] = {} +_hash_import_var_encodings: dict[ImportVar, bytes] = {} +_hash_dataclass_layouts: dict[type, tuple[bytes, tuple[tuple[bytes, str], ...]]] = {} + + +def _hash_dataclass_layout(cls: type) -> tuple[bytes, tuple[tuple[bytes, str], ...]]: + """Get the cached type tag and pre-encoded field names for a dataclass. + + Args: + cls: The dataclass type to describe. + + Returns: + The type tag plus field count, and each field's encoded and plain name. + """ + layout = _hash_dataclass_layouts.get(cls) + if layout is None: + fields = dataclasses.fields(cls) # pyright: ignore [reportArgumentType] + layout = ( + b"D" + len(fields).to_bytes(8, "little"), + tuple((field.name.encode(), field.name) for field in fields), + ) + _hash_dataclass_layouts[cls] = layout + return layout + + +def _encode_str_for_hash(value: str) -> bytes: + """Encode a string as a type-tagged, length-prefixed payload. + + Args: + value: The string to encode. + + Returns: + The encoded string. + """ + encoded = value.encode() + return b"s" + len(encoded).to_bytes(8, "little") + encoded + + +def _encode_deterministic(value: Any, out: bytearray, hasher: Any | None) -> None: + """Append ``value``'s self-delimiting encoding to ``out``. + + Dispatch is on the exact type so the common leaves (strings, bools, + containers, imports) skip the ``isinstance`` ladder in + :func:`_encode_deterministic_subclass`, which handles everything else. + ``out`` is flushed into ``hasher`` at container boundaries once it grows + past ``_HASH_BUFFER_FLUSH_SIZE``, so encoding a large subtree never + buffers the whole thing. + + Args: + value: The value to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into when it grows too large, or + ``None`` when ``out`` is a sub-buffer whose full contents the caller + needs and so must not be drained mid-encoding. + """ + value_type = type(value) + if value_type is str: + encoded = _hash_str_encodings.get(value) + if encoded is None: + encoded = _encode_str_for_hash(value) + if ( + len(value) <= _HASH_MAX_CACHED_STR + and len(_hash_str_encodings) < _HASH_MAX_CACHE_ENTRIES + ): + _hash_str_encodings[value] = encoded + out += encoded + elif value_type is bool: + out += b"T" if value else b"F" + elif value_type is dict: + out += b"d" + out += len(value).to_bytes(8, "little") + for k, v in sorted(value.items(), key=operator.itemgetter(0)): + _encode_deterministic(k, out, hasher) + _encode_deterministic(v, out, hasher) + if len(out) > _HASH_BUFFER_FLUSH_SIZE and hasher is not None: + hasher.update(out) + del out[:] + elif value_type is ImportVar: + encoded = _hash_import_var_encodings.get(value) + if encoded is None: + header, fields = _hash_dataclass_layout(ImportVar) + buffer = bytearray(header) + for encoded_name, name in fields: + buffer += encoded_name + _encode_deterministic(getattr(value, name), buffer, None) + encoded = bytes(buffer) + if len(_hash_import_var_encodings) < _HASH_MAX_CACHE_ENTRIES: + _hash_import_var_encodings[value] = encoded + out += encoded + elif value_type is list or value_type is tuple: + out += b"l" + out += len(value).to_bytes(8, "little") + for item in value: + _encode_deterministic(item, out, hasher) + if len(out) > _HASH_BUFFER_FLUSH_SIZE and hasher is not None: + hasher.update(out) + del out[:] + elif value is None: + out += b"N" + elif value_type is int or value_type is float: + out += b"n" + out += str(value).encode() + else: + _encode_deterministic_subclass(value, out, hasher) + + +def _encode_deterministic_subclass( + value: Any, out: bytearray, hasher: Any | None +) -> None: + """Append the encoding of a value whose exact type has no fast path. + + Covers subclasses of the fast-path types — notably ``str``-based enums, + which must encode as enums rather than as strings — plus ``Var``, + dataclasses, and components. + + Args: + value: The value to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into when it grows too large, or + ``None`` when ``out`` must not be drained mid-encoding. + + Raises: + TypeError: If the value is not hashable. + """ + if isinstance(value, bool): + out += b"T" if value else b"F" + elif isinstance(value, (int, float, enum.Enum)): + out += b"n" + out += str(value).encode() + elif isinstance(value, str): + out += _encode_str_for_hash(value) + elif isinstance(value, dict): + out += b"d" + out += len(value).to_bytes(8, "little") + for k, v in sorted(value.items(), key=operator.itemgetter(0)): + _encode_deterministic(k, out, hasher) + _encode_deterministic(v, out, hasher) + elif isinstance(value, (tuple, list)): + out += b"l" + out += len(value).to_bytes(8, "little") + for item in value: + _encode_deterministic(item, out, hasher) + elif isinstance(value, Var): + out += b"v" + _encode_deterministic(value._js_expr, out, hasher) + _encode_deterministic(value._get_all_var_data(), out, hasher) + elif dataclasses.is_dataclass(value): + header, fields = _hash_dataclass_layout( + value if isinstance(value, type) else type(value) + ) + out += header + for encoded_name, name in fields: + out += encoded_name + _encode_deterministic(getattr(value, name), out, hasher) + elif isinstance(value, BaseComponent): + out += b"C" + _encode_deterministic(value.render(), out, hasher) + 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) + if len(out) > _HASH_BUFFER_FLUSH_SIZE and hasher is not None: + hasher.update(out) + del out[:] + + +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 + encoding is buffered in a ``bytearray`` and handed to the hasher in large + chunks instead of one ``update`` per node, since a single component hash + covers tens of thousands of nodes. + + Args: + hasher: A ``hashlib`` hasher (must accept ``.update(bytes)``). + value: The value to fold into the hasher. + """ + buffer = bytearray() + _encode_deterministic(value, buffer, hasher) + hasher.update(buffer) + + +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() + + +def _update_component_artifacts_hash( + hasher: Any, component: Component, *, recursive: bool +) -> None: + """Fold a component's compile artifacts into ``hasher``. + + 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 the imports, hooks, + custom code, and app-wrap components have to be part of the hash too. + + Everything is encoded into one shared buffer rather than a hasher update + per artifact. + + Args: + hasher: A ``hashlib`` hasher to fold the artifacts into. + 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. + """ + buffer = bytearray() + if recursive: + _encode_deterministic(component._get_all_imports(), buffer, hasher) + _encode_deterministic(component._get_all_hooks_internal(), buffer, hasher) + _encode_deterministic(component._get_all_hooks(), buffer, hasher) + _encode_deterministic(component._get_all_custom_code(), buffer, hasher) + _encode_deterministic(component._get_all_app_wrap_components(), buffer, hasher) + else: + _encode_deterministic(component._get_imports(), buffer, hasher) + _encode_deterministic(component._get_hooks_internal(), buffer, hasher) + _encode_deterministic(component._get_hooks(), buffer, hasher) + _encode_deterministic(component._get_added_hooks(), buffer, hasher) + _encode_deterministic(component._get_custom_code(), buffer, hasher) + # ``_get_all_custom_code`` folds in ``add_custom_code`` on the recursive + # side; the own-node side has to ask for it explicitly. It used not to, + # so two passthrough bodies differing only in ``add_custom_code`` output + # collided on a tag. + for clz in component._iter_parent_classes_with_method("add_custom_code"): + _encode_deterministic(clz.add_custom_code(component), buffer, hasher) + _encode_deterministic(component._get_app_wrap_components(), buffer, hasher) + hasher.update(buffer) + + +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. + """ + hasher = md5(usedforsecurity=False) + _update_deterministic_hash(hasher, component.render()) + _update_component_artifacts_hash(hasher, component, recursive=recursive) + return hasher.hexdigest() + + +def memo_tag(component: Component) -> str: + """Compute a stable tag name for the memo wrapping ``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). + + 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'}_" + f"{component_hash(component, recursive=recursive)}" + ).capitalize() + + def create_passthrough_component_memo( component: Component, source_module: str | None = None, @@ -1771,7 +2073,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 @@ -1842,7 +2144,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__ diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index 6339cb0ecf9..3325e11ac4f 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -4,13 +4,8 @@ from typing import Any, ClassVar, TypedDict import pytest -from reflex_base.components.component import ( - _HASH_MAX_CACHE_ENTRIES, - Component, - _deterministic_hash, - field, -) -from reflex_base.constants import EventTriggers, Hooks +from reflex_base.components.component import Component, field +from reflex_base.constants import EventTriggers from reflex_base.constants.state import FIELD_MARKER from reflex_base.event import ( EventChain, @@ -2346,107 +2341,3 @@ def test_get_all_hooks_internal_does_not_mutate_hooks_cache(): assert dict(parent._get_hooks_internal()) == parent_own_hooks # And repeated collection yields the same result. assert parent._get_all_hooks_internal() == combined - - -def test_deterministic_hash_is_stable(): - """The same value must hash identically across calls and dict orderings.""" - value = {"b": [1, "x"], "a": {"k": None}} - reordered = {"a": {"k": None}, "b": [1, "x"]} - - assert _deterministic_hash(value) == _deterministic_hash(value) - assert _deterministic_hash(value) == _deterministic_hash(reordered) - - -@pytest.mark.parametrize( - ("left", "right"), - [ - # Type tags must keep values of different types apart. - ("1", 1), - (1, True), - (0, False), - (None, "None"), - ({"a": "b"}, [["a", "b"]]), - # Length prefixes must keep concatenations apart. - (["ab", "c"], ["a", "bc"]), - ([[], []], [[[]]]), - ({"a": "", "b": ""}, {"ab": ""}), - # Nested containers must not flatten into their contents. - ([1, [2]], [1, 2]), - # str-keyed enums encode as enums, not as their string value. - (Hooks.HookPosition.PRE_TRIGGER, Hooks.HookPosition.PRE_TRIGGER.value), - # Dataclasses of the same shape but different types stay distinct. - (ImportVar(tag="a"), ImportVar(tag="a", alias="a")), - ], -) -def test_deterministic_hash_distinguishes(left: Any, right: Any): - """Distinct values must not collide under the type-tagged encoding.""" - assert _deterministic_hash(left) != _deterministic_hash(right) - - -def test_deterministic_hash_treats_lists_and_tuples_alike(): - """Sequences share one type tag, so a list and tuple of equal items match.""" - assert _deterministic_hash([1, "a"]) == _deterministic_hash((1, "a")) - - -def test_deterministic_hash_import_var_cache_is_by_value(): - """Equal ``ImportVar`` instances hash the same; unequal ones do not. - - ``ImportVar`` encodings are cached by value, so a stale or over-eager cache - entry would show up as two unequal imports hashing alike. - """ - a = ImportVar(tag="useState", is_default=False, install=True) - b = ImportVar(tag="useState", is_default=False, install=True) - c = ImportVar(tag="useState", is_default=True, install=True) - - assert _deterministic_hash(a) == _deterministic_hash(b) - assert _deterministic_hash(a) != _deterministic_hash(c) - assert _deterministic_hash({"react": (a, c)}) != _deterministic_hash({ - "react": (c, a) - }) - - -def test_deterministic_hash_long_strings(): - """Strings past the encoding cache's size limit still hash correctly.""" - long_a = "a" * 10_000 - long_b = "a" * 9_999 + "b" - - assert _deterministic_hash(long_a) == _deterministic_hash("a" * 10_000) - assert _deterministic_hash(long_a) != _deterministic_hash(long_b) - - -def test_deterministic_hash_beyond_string_cache_capacity(): - """Strings that arrive after the encoding cache fills still hash correctly.""" - values = [f"cache_capacity_probe_{i}" for i in range(_HASH_MAX_CACHE_ENTRIES + 500)] - digests = [_deterministic_hash(value) for value in values] - - assert len(set(digests)) == len(values) - assert [_deterministic_hash(value) for value in values] == digests - - -def test_deterministic_hash_flushes_large_payloads(): - """A payload larger than the buffer flush size hashes deterministically.""" - payload = {f"key_{i}": "v" * 200 for i in range(2000)} - - assert _deterministic_hash(payload) == _deterministic_hash(dict(payload)) - mutated = {**payload, "key_0": "w" * 200} - assert _deterministic_hash(payload) != _deterministic_hash(mutated) - - -def test_deterministic_hash_components_and_vars(): - """Components and Vars hash by rendered content, not by identity.""" - assert _deterministic_hash(Bare.create(contents="a")) == _deterministic_hash( - Bare.create(contents="a") - ) - assert _deterministic_hash(Bare.create(contents="a")) != _deterministic_hash( - Bare.create(contents="b") - ) - assert _deterministic_hash(Var("a")) == _deterministic_hash(Var("a")) - assert _deterministic_hash(Var("a")) != _deterministic_hash(Var("b")) - # A Var and the bare string it renders to must not collide. - assert _deterministic_hash(Var("a")) != _deterministic_hash("a") - - -def test_deterministic_hash_rejects_unsupported_types(): - """Values with no encoding raise rather than hashing to a shared digest.""" - with pytest.raises(TypeError): - _deterministic_hash(object()) diff --git a/tests/units/components/test_memo.py b/tests/units/components/test_memo.py index f1ecea1fc10..418fbce9890 100644 --- a/tests/units/components/test_memo.py +++ b/tests/units/components/test_memo.py @@ -10,6 +10,7 @@ import pytest from reflex_base.components.component import Component from reflex_base.components.memo import ( + _HASH_MAX_CACHE_ENTRIES, _SPECS, DEFAULT_MEMO_WRAPPER, EMPTY_VAR_COMPONENT, @@ -20,10 +21,14 @@ MemoParam, MemoParamKind, _analyze_params, + _deterministic_hash, _LazyBody, _MemoCallBinding, _strip_optional, + component_hash, + memo_tag, ) +from reflex_base.constants import Hooks from reflex_base.event import EventChain, EventHandler, no_args_event_spec from reflex_base.registry import RegistrationContext from reflex_base.style import Style @@ -35,6 +40,8 @@ from reflex_base.vars.base import Var from reflex_base.vars.function import FunctionStringVar, FunctionVar from reflex_base.vars.object import ObjectVar +from reflex_components_core.base.bare import Bare +from reflex_components_radix.themes.layout.box import Box import reflex as rx from reflex.compiler import compiler @@ -1869,3 +1876,183 @@ def recursive_count(n: rx.vars.NumberVar[int]) -> rx.Var[int]: invoked = recursive_count(n=Var(_js_expr="three", _var_type=int)) assert "recursive_count" in str(invoked) + + +def test_deterministic_hash_is_stable(): + """The same value must hash identically across calls and dict orderings.""" + value = {"b": [1, "x"], "a": {"k": None}} + reordered = {"a": {"k": None}, "b": [1, "x"]} + + assert _deterministic_hash(value) == _deterministic_hash(value) + assert _deterministic_hash(value) == _deterministic_hash(reordered) + + +@pytest.mark.parametrize( + ("left", "right"), + [ + # Type tags must keep values of different types apart. + ("1", 1), + (1, True), + (0, False), + (None, "None"), + ({"a": "b"}, [["a", "b"]]), + # Length prefixes must keep concatenations apart. + (["ab", "c"], ["a", "bc"]), + ([[], []], [[[]]]), + ({"a": "", "b": ""}, {"ab": ""}), + # Nested containers must not flatten into their contents. + ([1, [2]], [1, 2]), + # str-keyed enums encode as enums, not as their string value. + (Hooks.HookPosition.PRE_TRIGGER, Hooks.HookPosition.PRE_TRIGGER.value), + # Dataclasses of the same shape but different types stay distinct. + (ImportVar(tag="a"), ImportVar(tag="a", alias="a")), + ], +) +def test_deterministic_hash_distinguishes(left: Any, right: Any): + """Distinct values must not collide under the type-tagged encoding.""" + assert _deterministic_hash(left) != _deterministic_hash(right) + + +def test_deterministic_hash_treats_lists_and_tuples_alike(): + """Sequences share one type tag, so a list and tuple of equal items match.""" + assert _deterministic_hash([1, "a"]) == _deterministic_hash((1, "a")) + + +def test_deterministic_hash_import_var_cache_is_by_value(): + """Equal ``ImportVar`` instances hash the same; unequal ones do not. + + ``ImportVar`` encodings are cached by value, so a stale or over-eager cache + entry would show up as two unequal imports hashing alike. + """ + a = ImportVar(tag="useState", is_default=False, install=True) + b = ImportVar(tag="useState", is_default=False, install=True) + c = ImportVar(tag="useState", is_default=True, install=True) + + assert _deterministic_hash(a) == _deterministic_hash(b) + assert _deterministic_hash(a) != _deterministic_hash(c) + assert _deterministic_hash({"react": (a, c)}) != _deterministic_hash({ + "react": (c, a) + }) + + +def test_deterministic_hash_long_strings(): + """Strings past the encoding cache's size limit still hash correctly.""" + long_a = "a" * 10_000 + long_b = "a" * 9_999 + "b" + + assert _deterministic_hash(long_a) == _deterministic_hash("a" * 10_000) + assert _deterministic_hash(long_a) != _deterministic_hash(long_b) + + +def test_deterministic_hash_beyond_string_cache_capacity(): + """Strings that arrive after the encoding cache fills still hash correctly.""" + values = [f"cache_capacity_probe_{i}" for i in range(_HASH_MAX_CACHE_ENTRIES + 500)] + digests = [_deterministic_hash(value) for value in values] + + assert len(set(digests)) == len(values) + assert [_deterministic_hash(value) for value in values] == digests + + +def test_deterministic_hash_flushes_large_payloads(): + """A payload larger than the buffer flush size hashes deterministically.""" + payload = {f"key_{i}": "v" * 200 for i in range(2000)} + + assert _deterministic_hash(payload) == _deterministic_hash(dict(payload)) + mutated = {**payload, "key_0": "w" * 200} + assert _deterministic_hash(payload) != _deterministic_hash(mutated) + + +def test_deterministic_hash_components_and_vars(): + """Components and Vars hash by rendered content, not by identity.""" + assert _deterministic_hash(Bare.create(contents="a")) == _deterministic_hash( + Bare.create(contents="a") + ) + assert _deterministic_hash(Bare.create(contents="a")) != _deterministic_hash( + Bare.create(contents="b") + ) + assert _deterministic_hash(Var("a")) == _deterministic_hash(Var("a")) + assert _deterministic_hash(Var("a")) != _deterministic_hash(Var("b")) + # A Var and the bare string it renders to must not collide. + assert _deterministic_hash(Var("a")) != _deterministic_hash("a") + + +def test_deterministic_hash_rejects_unsupported_types(): + """Values with no encoding raise rather than hashing to a shared digest.""" + with pytest.raises(TypeError): + _deterministic_hash(object()) + + +class _CustomCodeProbe(Component): + """A component whose only per-instance artifact is its custom code.""" + + library = "custom-code-probe" + tag = "Probe" + + marker: Var[str] + + def add_custom_code(self) -> list[str]: + """Emit a marker-dependent module-level constant. + + Returns: + The custom code lines. + """ + return [f"const PROBE = {self.marker!s};"] + + def _render(self, props: dict[str, Any] | None = None): + """Render without the marker prop so only custom code differs. + + Args: + props: The props to render. + + Returns: + The rendered tag. + """ + return super()._render(props).remove_props("marker") + + +def test_component_hash_covers_add_custom_code(): + """``add_custom_code`` output must reach the own-node hash. + + Two bodies that render identically and differ only in the module-level code + they emit compile to different modules, so they must not share a tag — a + collision would drop one of the two constants. + """ + a = _CustomCodeProbe.create(marker="alpha") + b = _CustomCodeProbe.create(marker="beta") + + assert a.render() == b.render() + assert component_hash(a, recursive=False) != component_hash(b, recursive=False) + assert memo_tag(a) != memo_tag(b) + + +def test_component_hash_recursive_covers_descendant_artifacts(): + """A recursive hash must see artifacts that a descendant's JSX omits.""" + inner_a = Box.create(Bare.create(contents="x"), on_mount=rx.console_log("x")) + inner_b = Box.create(Bare.create(contents="x")) + outer_a, outer_b = Box.create(inner_a), Box.create(inner_b) + + # ``on_mount`` lives in a lifecycle hook, not in the rendered props. + assert outer_a.render() == outer_b.render() + assert component_hash(outer_a, recursive=True) != component_hash( + outer_b, recursive=True + ) + # The passthrough form deliberately ignores descendants: they render at the + # call site behind the ``{children}`` hole, not inside the memo body. + assert component_hash(outer_a, recursive=False) == component_hash( + outer_b, recursive=False + ) + + +def test_memo_tag_separates_identically_rendering_classes(): + """Distinct classes that render alike must not collide on a tag.""" + + class _AlphaProbe(Component): + tag = "Same" + + class _BetaProbe(Component): + tag = "Same" + + alpha, beta = _AlphaProbe.create(), _BetaProbe.create() + + assert alpha.render() == beta.render() + assert memo_tag(alpha) != memo_tag(beta) From 0d4be289d6f9656a2b44c26ae4bb5cb158b7eab7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 22:35:57 +0000 Subject: [PATCH 4/7] fix(compiler): release memo-naming caches once compilation is done The encoding caches that speed up memo naming were module globals with no teardown. The two value caches are capped, but the dataclass field-layout cache is keyed by type and was uncapped -- and a dataclass defined inside a function body is a fresh class object on every call, so hashing one pinned a class per compile for the life of the process. Confirmed reachable: 50 dynamically created dataclasses survived a gc.collect(). Capping that cache would be the wrong fix. It bounds retention without removing it, and once the cap is hit every dataclass encode falls back to `dataclasses.fields()` plus re-encoding field names per instance -- a silent cliff on the hot path, for a cache whose real-world population is two entries (`VarData` and `ImportVar`, stable across repeated compiles). Every component auto-memoization will ever name is named during compilation, so drop all three caches when it finishes, alongside the existing `GLOBAL_CACHE.clear()` in the same post-compile block. Digests are unchanged and compile wall time is unaffected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr --- .../src/reflex_base/components/memo.py | 20 ++++++++-- reflex/app.py | 4 ++ tests/units/components/test_memo.py | 37 +++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 8d16507671e..166cb8bebce 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -1770,9 +1770,10 @@ def _create_component_wrapper( # instances, which make up the bulk of what a component hash feeds in. # ``ImportVar`` is a frozen dataclass whose fields are all ``str``/``bool``/ # ``None``, so its generated equality means exactly "same encoding" and is safe -# to key a cache on. Both caches stop admitting new entries at -# ``_HASH_MAX_CACHE_ENTRIES`` so an app rendering many one-off strings can't -# grow them without bound; the recurring values get in first and stay. +# to key a cache on. All three are dropped by :func:`clear_hash_caches` once a +# compile is done. Within a compile, the two value caches stop admitting new +# entries at ``_HASH_MAX_CACHE_ENTRIES`` so a page rendering many one-off +# strings can't balloon them; the recurring values get in first and stay. _hash_str_encodings: dict[str, bytes] = {} _hash_import_var_encodings: dict[ImportVar, bytes] = {} _hash_dataclass_layouts: dict[type, tuple[bytes, tuple[tuple[bytes, str], ...]]] = {} @@ -2037,6 +2038,19 @@ def component_hash(component: Component, *, recursive: bool) -> str: return hasher.hexdigest() +def clear_hash_caches() -> None: + """Drop the memo-naming encoding caches. + + Every component that auto-memoization names is named during compilation, so + once a compile finishes these caches hold values nothing will ask for + again -- including, in the pathological case, dataclass types defined inside + a function body, one fresh class object per compile. + """ + _hash_str_encodings.clear() + _hash_import_var_encodings.clear() + _hash_dataclass_layouts.clear() + + def memo_tag(component: Component) -> str: """Compute a stable tag name for the memo wrapping ``component``. diff --git a/reflex/app.py b/reflex/app.py index 63ec53a4c75..fe5a04452bd 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -741,6 +741,7 @@ def __call__(self) -> ASGIApp: Raises: ValueError: If the app has not been initialized. """ + from reflex_base.components.memo import clear_hash_caches from reflex_base.vars.base import GLOBAL_CACHE from reflex.assets import remove_stale_external_asset_symlinks @@ -776,6 +777,9 @@ def __call__(self) -> ASGIApp: # We will not be making more vars, so we can clear the global cache to free up memory. GLOBAL_CACHE.clear() + # Auto-memoization named every wrapper it is going to name during the + # compile above, so its encoding caches are dead weight from here. + clear_hash_caches() if not self._api: msg = "The app has not been initialized." diff --git a/tests/units/components/test_memo.py b/tests/units/components/test_memo.py index 418fbce9890..8cb9028a4b8 100644 --- a/tests/units/components/test_memo.py +++ b/tests/units/components/test_memo.py @@ -2,6 +2,7 @@ from __future__ import annotations +import dataclasses import inspect from types import SimpleNamespace from typing import Any, cast @@ -22,9 +23,13 @@ MemoParamKind, _analyze_params, _deterministic_hash, + _hash_dataclass_layouts, + _hash_import_var_encodings, + _hash_str_encodings, _LazyBody, _MemoCallBinding, _strip_optional, + clear_hash_caches, component_hash, memo_tag, ) @@ -2056,3 +2061,35 @@ class _BetaProbe(Component): assert alpha.render() == beta.render() assert memo_tag(alpha) != memo_tag(beta) + + +def test_clear_hash_caches_drops_every_cache(): + """The compile-scoped encoding caches must all be released together. + + Nothing asks for these values after a compile, and a dataclass type defined + inside a function body is a fresh class object each time -- so a cache left + behind would pin one per compile for the life of the process. + """ + ephemeral = dataclasses.make_dataclass("Ephemeral", [("v", str)]) + before = _deterministic_hash({ + "prop": ephemeral(v="x"), + "imports": (ImportVar(tag="useCacheProbe"),), + }) + + assert _hash_dataclass_layouts + assert _hash_str_encodings + assert _hash_import_var_encodings + + clear_hash_caches() + + assert not _hash_dataclass_layouts + assert not _hash_str_encodings + assert not _hash_import_var_encodings + # Hashing rebuilds them from scratch and must land on the same digest. + assert ( + _deterministic_hash({ + "prop": ephemeral(v="x"), + "imports": (ImportVar(tag="useCacheProbe"),), + }) + == before + ) From d753c6a17dd8f23c741405ab06dc62c4fdddf1ef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 22:43:41 +0000 Subject: [PATCH 5/7] fix(compiler): close two more memo-name collisions, bound the hash buffer Review of the naming hash turned up two more gaps of the same kind as the `add_custom_code` one: - `_get_dynamic_imports` is emitted into the memo body by `compile_experimental_component_memo` but was never hashed, so two components differing only there shared a module and one of their two import statements was dropped. - `memo_tag` identified a class by `__qualname__` alone, so two modules each defining `class Card` with the same rendered output produced the same tag -- exactly what the qualname prefix exists to prevent. The defining module now reaches the digest rather than the prefix, which keeps the discrimination without stretching every generated module filename by a dotted module path. Both are covered by regression tests that fail without the fix. Also make the encoder's buffer bound real: the flush check ran only after a container's whole loop, so one flat 2 MB dict buffered 2 MB before the first flush. Checking per item holds it at the intended 64 KiB and costs nothing measurable -- the encoder is still 1.7-2.0x the old one and every digest is byte-identical to it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr --- packages/reflex-base/news/6947.bugfix.md | 2 + .../src/reflex_base/components/memo.py | 30 +++++-- tests/units/components/test_memo.py | 86 ++++++++++++++++++- 3 files changed, 108 insertions(+), 10 deletions(-) diff --git a/packages/reflex-base/news/6947.bugfix.md b/packages/reflex-base/news/6947.bugfix.md index 267ec73a90d..064045fbc4b 100644 --- a/packages/reflex-base/news/6947.bugfix.md +++ b/packages/reflex-base/news/6947.bugfix.md @@ -1 +1,3 @@ Auto-memoized components whose module-level code came from `add_custom_code` no longer collide on a generated memo name. Two otherwise-identical components emitting different custom code shared one memo module, so one of their two code blocks was dropped from the compiled output. + +Auto-memoized components that emit dynamic imports, or that share a class name with a component from another module, no longer collide on a generated memo name either. Both cases produced one memo module where two were needed, dropping a dynamic import statement or one class's compiled body. diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 166cb8bebce..0822ea6b06d 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -1848,9 +1848,9 @@ def _encode_deterministic(value: Any, out: bytearray, hasher: Any | None) -> Non for k, v in sorted(value.items(), key=operator.itemgetter(0)): _encode_deterministic(k, out, hasher) _encode_deterministic(v, out, hasher) - if len(out) > _HASH_BUFFER_FLUSH_SIZE and hasher is not None: - hasher.update(out) - del out[:] + if len(out) > _HASH_BUFFER_FLUSH_SIZE and hasher is not None: + hasher.update(out) + del out[:] elif value_type is ImportVar: encoded = _hash_import_var_encodings.get(value) if encoded is None: @@ -1868,9 +1868,9 @@ def _encode_deterministic(value: Any, out: bytearray, hasher: Any | None) -> Non out += len(value).to_bytes(8, "little") for item in value: _encode_deterministic(item, out, hasher) - if len(out) > _HASH_BUFFER_FLUSH_SIZE and hasher is not None: - hasher.update(out) - del out[:] + if len(out) > _HASH_BUFFER_FLUSH_SIZE and hasher is not None: + hasher.update(out) + del out[:] elif value is None: out += b"N" elif value_type is int or value_type is float: @@ -1984,8 +1984,10 @@ def _update_component_artifacts_hash( 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 the imports, hooks, - custom code, and app-wrap components have to be part of the hash too. + 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. Everything is encoded into one shared buffer rather than a hasher update per artifact. @@ -1999,11 +2001,22 @@ def _update_component_artifacts_hash( artifacts identify the body. """ buffer = bytearray() + # Two classes can emit byte-identical bodies and still need separate memo + # modules -- the tag prefix already keeps them apart by qualname, so keep + # the digest consistent with that and include the defining module, which + # the prefix omits. Folding it in here rather than into the prefix avoids + # stretching every generated module filename by a dotted module path. + cls = type(component) + _encode_deterministic(f"{cls.__module__}.{cls.__qualname__}", buffer, hasher) if recursive: _encode_deterministic(component._get_all_imports(), buffer, hasher) _encode_deterministic(component._get_all_hooks_internal(), buffer, hasher) _encode_deterministic(component._get_all_hooks(), buffer, hasher) _encode_deterministic(component._get_all_custom_code(), buffer, hasher) + # A set: sort it so the encoding does not ride on iteration order. + _encode_deterministic( + sorted(component._get_all_dynamic_imports()), buffer, hasher + ) _encode_deterministic(component._get_all_app_wrap_components(), buffer, hasher) else: _encode_deterministic(component._get_imports(), buffer, hasher) @@ -2017,6 +2030,7 @@ def _update_component_artifacts_hash( # collided on a tag. for clz in component._iter_parent_classes_with_method("add_custom_code"): _encode_deterministic(clz.add_custom_code(component), buffer, hasher) + _encode_deterministic(component._get_dynamic_imports(), buffer, hasher) _encode_deterministic(component._get_app_wrap_components(), buffer, hasher) hasher.update(buffer) diff --git a/tests/units/components/test_memo.py b/tests/units/components/test_memo.py index 8cb9028a4b8..1964deedc4e 100644 --- a/tests/units/components/test_memo.py +++ b/tests/units/components/test_memo.py @@ -1883,6 +1883,22 @@ def recursive_count(n: rx.vars.NumberVar[int]) -> rx.Var[int]: assert "recursive_count" in str(invoked) +@pytest.fixture +def clean_hash_caches(): + """Isolate a test from the module-level memo-naming caches. + + Tests that fill these caches would otherwise leave their probe values in + place for the rest of the session, and tests run in random order, so a test + that reads cache state has to start from a known one. + + Yields: + None, with the caches empty on entry and on exit. + """ + clear_hash_caches() + yield + clear_hash_caches() + + def test_deterministic_hash_is_stable(): """The same value must hash identically across calls and dict orderings.""" value = {"b": [1, "x"], "a": {"k": None}} @@ -1949,7 +1965,7 @@ def test_deterministic_hash_long_strings(): assert _deterministic_hash(long_a) != _deterministic_hash(long_b) -def test_deterministic_hash_beyond_string_cache_capacity(): +def test_deterministic_hash_beyond_string_cache_capacity(clean_hash_caches: None): """Strings that arrive after the encoding cache fills still hash correctly.""" values = [f"cache_capacity_probe_{i}" for i in range(_HASH_MAX_CACHE_ENTRIES + 500)] digests = [_deterministic_hash(value) for value in values] @@ -2048,6 +2064,72 @@ def test_component_hash_recursive_covers_descendant_artifacts(): ) +class _DynamicImportProbe(Component): + """One class whose dynamic import varies with a prop ``_render`` drops.""" + + library = "dynamic-probe" + tag = "Probe" + + marker: Var[str] + + def _get_dynamic_imports(self) -> str: + """Emit a marker-dependent dynamic import. + + Returns: + The dynamic import statement. + """ + return f"const EXTRA = await import({self.marker!s});" + + def _render(self, props: dict[str, Any] | None = None): + """Render without the marker prop so only the dynamic import differs. + + Args: + props: The props to render. + + Returns: + The rendered tag. + """ + return super()._render(props).remove_props("marker") + + +def test_component_hash_covers_dynamic_imports(): + """Dynamic imports are emitted into the memo body, so they must be hashed. + + Same class, same rendered JSX, different dynamic import: a collision here + would drop one of the two import statements from the compiled output. + """ + a = _DynamicImportProbe.create(marker="alpha") + b = _DynamicImportProbe.create(marker="beta") + + assert type(a) is type(b) + assert a.render() == b.render() + assert a._get_dynamic_imports() != b._get_dynamic_imports() + assert component_hash(a, recursive=False) != component_hash(b, recursive=False) + assert memo_tag(a) != memo_tag(b) + + +def test_memo_tag_separates_same_named_classes_from_different_modules(): + """Two modules defining an identical component must not share a memo tag. + + ``__qualname__`` alone does not distinguish them -- both are ``Probe`` -- so + the defining module has to reach the digest. + """ + probes = [] + for module_name in ("_memo_tag_module_a", "_memo_tag_module_b"): + namespace = {"Component": Component, "__name__": module_name} + exec( + "class Probe(Component):\n tag = 'Probe'\n library = 'probe-lib'\n", + namespace, + ) + probes.append(namespace["Probe"].create()) + + a, b = probes + assert type(a) is not type(b) + assert type(a).__qualname__ == type(b).__qualname__ + assert a.render() == b.render() + assert memo_tag(a) != memo_tag(b) + + def test_memo_tag_separates_identically_rendering_classes(): """Distinct classes that render alike must not collide on a tag.""" @@ -2063,7 +2145,7 @@ class _BetaProbe(Component): assert memo_tag(alpha) != memo_tag(beta) -def test_clear_hash_caches_drops_every_cache(): +def test_clear_hash_caches_drops_every_cache(clean_hash_caches: None): """The compile-scoped encoding caches must all be released together. Nothing asks for these values after a compile, and a dataclass type defined From 628ce4c6acca68452486f8f9600aa431461a5dc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:52:09 +0000 Subject: [PATCH 6/7] fix(compiler): release naming caches from the compile lifecycle `clear_hash_caches()` was called from `App.__call__`, which only the ASGI path reaches. `reflex export` and `reflex compile` get to a compile through `prerequisites.get_compiled_app` -> `App._compile` and never touch `__call__`, so those paths never released anything. Move the call into `App._compile` -- the single funnel every compile goes through -- inside a `finally`, so a failed compile does not leave the caches behind either. Covered by a test that fails under the old placement, on both the success and the exception path. Also add the root `news/` fragment: this PR now touches `reflex/`, so the changelog check requires one for the main package too. Corrects the reflex-base performance fragment, which claimed digests were unchanged -- true of the encoder rewrite alone, but later commits deliberately folded the defining module and dynamic imports into the hash, so generated memo module names do change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr --- news/6947.performance.md | 1 + packages/reflex-base/news/6947.performance.md | 2 +- reflex/app.py | 55 +++++++++++-------- tests/units/test_app.py | 32 +++++++++++ 4 files changed, 65 insertions(+), 25 deletions(-) create mode 100644 news/6947.performance.md diff --git a/news/6947.performance.md b/news/6947.performance.md new file mode 100644 index 00000000000..6b21fae49e3 --- /dev/null +++ b/news/6947.performance.md @@ -0,0 +1 @@ +Compiling an app no longer leaves the auto-memoization naming caches behind. They are released when the compile finishes — including on `reflex export` and `reflex compile`, and when a compile fails — so a long-running process does not accumulate them. diff --git a/packages/reflex-base/news/6947.performance.md b/packages/reflex-base/news/6947.performance.md index bd38a025a5e..b7603182921 100644 --- a/packages/reflex-base/news/6947.performance.md +++ b/packages/reflex-base/news/6947.performance.md @@ -1 +1 @@ -Component content hashing, which auto-memoization runs for every memoized component during a compile, is 2.3-2.6x faster on large pages. The hash now buffers its encoding instead of feeding the hasher one value at a time, dispatches on the exact type, and caches the encoded form of the strings and `ImportVar`s that recur across every component. Digests are byte-for-byte unchanged, so generated memo names stay stable. +Component content hashing, which auto-memoization runs for every memoized component during a compile, is 2.3-2.6x faster on large pages. The hash now buffers its encoding instead of feeding the hasher one value at a time, dispatches on the exact type, and caches the encoded form of the strings and `ImportVar`s that recur across every component. Generated memo module names change as a result; nothing outside the compiled output refers to them. diff --git a/reflex/app.py b/reflex/app.py index fe5a04452bd..239f877f1b8 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -741,7 +741,6 @@ def __call__(self) -> ASGIApp: Raises: ValueError: If the app has not been initialized. """ - from reflex_base.components.memo import clear_hash_caches from reflex_base.vars.base import GLOBAL_CACHE from reflex.assets import remove_stale_external_asset_symlinks @@ -777,9 +776,6 @@ def __call__(self) -> ASGIApp: # We will not be making more vars, so we can clear the global cache to free up memory. GLOBAL_CACHE.clear() - # Auto-memoization named every wrapper it is going to name during the - # compile above, so its encoding caches are dead weight from here. - clear_hash_caches() if not self._api: msg = "The app has not been initialized." @@ -1654,32 +1650,43 @@ def _compile( ReflexRuntimeError: When any page uses state, but no rx.State subclass is defined. FileNotFoundError: When a plugin requires a file that does not exist. """ - ctx = TelemetryContext.start(trigger=trigger) - if ctx is None: - compiler.compile_app( - self, - prerender_routes=prerender_routes, - dry_run=dry_run, - use_rich=use_rich, - ) - return + from reflex_base.components.memo import clear_hash_caches - with ctx: - did_real_compile = False - try: - did_real_compile = compiler.compile_app( + ctx = TelemetryContext.start(trigger=trigger) + try: + if ctx is None: + compiler.compile_app( self, prerender_routes=prerender_routes, dry_run=dry_run, use_rich=use_rich, ) - except Exception as exc: - ctx.set_exception(exc) - did_real_compile = True - raise - finally: - if did_real_compile: - telemetry_accounting.record_compile(self, ctx) + return + + with ctx: + did_real_compile = False + try: + did_real_compile = compiler.compile_app( + self, + prerender_routes=prerender_routes, + dry_run=dry_run, + use_rich=use_rich, + ) + except Exception as exc: + ctx.set_exception(exc) + did_real_compile = True + raise + finally: + if did_real_compile: + telemetry_accounting.record_compile(self, ctx) + finally: + # Auto-memoization named every wrapper it will ever name during the + # compile, so its encoding caches are dead weight from here. This is + # the single funnel every compile goes through -- the CLI and export + # paths reach it via ``get_compiled_app`` and never touch + # ``App.__call__`` -- and the ``finally`` keeps a failed compile + # from leaving them behind. + clear_hash_caches() def _write_stateful_pages_marker(self): """Write list of routes that create dynamic states for the backend to use later.""" diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 484796337a5..5902b7684ac 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -4283,3 +4283,35 @@ def test_client_error_constants_match_frontend(): f'const ERROR_TYPE_STATE_UPDATE = "{constants.ClientErrorType.STATE_UPDATE}"' in state_js ) + + +@pytest.mark.parametrize("compile_raises", [False, True]) +def test_compile_releases_memo_naming_caches( + mocker: MockerFixture, compile_raises: bool +): + """``App._compile`` must release the memo-naming caches on every path. + + The CLI and export paths reach ``_compile`` through + ``prerequisites.get_compiled_app`` and never touch ``App.__call__``, so the + release has to sit in the compile lifecycle -- and in a ``finally``, so a + failed compile does not leave the caches behind either. + """ + from reflex_base.components.memo import _hash_str_encodings, clear_hash_caches + + app = App() + + def fake_compile_app(*_args: Any, **_kwargs: Any) -> bool: + # Stand in for the naming work a real compile does. + _hash_str_encodings["probe"] = b"probe" + if compile_raises: + msg = "compile blew up" + raise RuntimeError(msg) + return True + + mocker.patch("reflex.compiler.compiler.compile_app", side_effect=fake_compile_app) + clear_hash_caches() + + with pytest.raises(RuntimeError) if compile_raises else contextlib.nullcontext(): + app._compile() + + assert not _hash_str_encodings From 6921d6ddb368c511b3e606c277eff9a0ef08ae40 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 21:33:30 +0000 Subject: [PATCH 7/7] chore: update pyi_hashes.json for the memo module change Moving the naming hash into `memo.py` changed the source that `reflex/experimental/memo.pyi` is generated from, so its recorded hash went stale and the pre-commit check failed. I ran `make_pyi.py` after the first commit but not after the move. `pre-commit run --all-files` now passes all seven hooks. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr --- pyi_hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index 3d85cc7719e..901bfabce12 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -120,5 +120,5 @@ "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", "reflex/__init__.pyi": "a3e1782fab4a9aed55f66cc98af8c217", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", - "reflex/experimental/memo.pyi": "bc8b48357bef580e70a5881b65d3d3f7" + "reflex/experimental/memo.pyi": "e859ea6f902bc547ec725c6b7b93c791" }