From 6b3d4cfb24faad0bab49883535cd83c14e0f5d20 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 19:27:37 +0000 Subject: [PATCH 01/18] 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 02/18] 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 03/18] 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 04/18] 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 05/18] 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 06/18] 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 07/18] 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" } From d222d241938abe9ea2627a558140f205ecbb0299 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 02:37:05 +0000 Subject: [PATCH 08/18] perf(compiler): resolve hash encoders per type, fix a fifth memo collision Incorporates the ideas from #6804 into the memo-name hashing rework. The encoder no longer walks an isinstance ladder for every value whose exact type has no inline fast path. It resolves an encoder once per type and reaches it through a memoized table, so vars, components, enums and dataclasses each pay the ladder once per compile instead of once per value. That also collapses the two parallel ladders the previous version carried -- an exact-type one and an isinstance one -- into a single encoder per type. Fixes a fifth naming collision, found by #6804: the dataclass branch sat ahead of the component branch, so a component that also inherits a dataclass encoded as that mixin's field list. Every component built on MarkdownComponentMap does -- rx.text, rx.heading and friends -- and the mixin declares no fields, so all of them encoded to the same nine bytes. Reachable through app-wrap components, which the hash feeds in as components rather than as rendered dicts. The ImportVar encoding cache is no longer hard-coded to ImportVar: any frozen dataclass declaring only str/bool/None fields is cached by value. Numbers are excluded because equality has to imply an identical encoding for a value-keyed cache to be sound, and True == 1 == 1.0 while all three encode differently. Reading a class's annotations is the expensive part, so that per-type verdict outlives a compile in a WeakKeyDictionary, which still lets a dataclass defined in a function body be collected. Cached encodings are bounded in size as well as in count. Verified byte-identical: every memo tag generated while compiling three benchmark pages is unchanged, and an A/B of the two encoders on the values those compiles hash gives matching digests at 1.00-1.02x the speed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c --- packages/reflex-base/news/6947.bugfix.md | 2 + packages/reflex-base/news/6947.performance.md | 2 +- .../src/reflex_base/components/memo.py | 405 +++++++++++++----- tests/units/components/test_memo.py | 224 +++++++++- 4 files changed, 526 insertions(+), 107 deletions(-) diff --git a/packages/reflex-base/news/6947.bugfix.md b/packages/reflex-base/news/6947.bugfix.md index 064045fbc4b..f132289eddb 100644 --- a/packages/reflex-base/news/6947.bugfix.md +++ b/packages/reflex-base/news/6947.bugfix.md @@ -1,3 +1,5 @@ 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. + +Auto-memoized components whose app-wrap components include an `rx.text` (or any other component built on `MarkdownComponentMap`) no longer collide either. Those components inherit a field-less dataclass, and the content hash encoded them as that empty field list rather than as their rendered content, so any two of them looked identical. diff --git a/packages/reflex-base/news/6947.performance.md b/packages/reflex-base/news/6947.performance.md index b7603182921..72be3225c7e 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. Generated memo module names change as a result; nothing outside the compiled output refers to them. +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, resolves an encoder once per type rather than walking a type ladder per value, and caches the encoded form of the strings and frozen dataclasses (`ImportVar` above all) that recur across every component. Generated memo module names change as a result; nothing outside the compiled output refers to them. diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 830466f09b1..ac0793f3c44 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -27,6 +27,7 @@ get_type_hints, overload, ) +from weakref import WeakKeyDictionary from reflex_components_core.base.fragment import Fragment @@ -1796,20 +1797,38 @@ def _create_component_wrapper( _HASH_BUFFER_FLUSH_SIZE = 1 << 16 _HASH_MAX_CACHED_STR = 128 +_HASH_MAX_CACHED_DATACLASS = 512 _HASH_MAX_CACHE_ENTRIES = 4096 +# The declared field types a frozen dataclass may have and still be safe to key +# an encoding cache on by value: equality between any two values drawn from +# them implies an identical encoding. Numbers are deliberately absent -- +# ``True == 1 == 1.0`` holds and all three hash alike, yet each encodes +# differently, so a lookup could hand back another value's bytes. +_HASH_VALUE_KEYED_FIELD_TYPES = frozenset({str, bool, type(None)}) + +# An encoder appends one value's encoding to a buffer. It takes the hasher its +# buffer is flushed into so it can hand it to nested encodings. +_HashEncoder = Callable[[Any, bytearray, Any], None] + # 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. All three are dropped by :func:`clear_hash_caches` once a +# compile: short strings (dict keys, tags, module paths) and frozen dataclasses +# (overwhelmingly ``ImportVar``), which make up the bulk of what a component +# hash feeds in. All four caches 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_encodings: dict[Any, bytes] = {} _hash_dataclass_layouts: dict[type, tuple[bytes, tuple[tuple[bytes, str], ...]]] = {} +_hash_encoders: dict[type, _HashEncoder] = {} + +# Whether a frozen dataclass type's declared fields make it safe to key an +# encoding cache on by value. Reading a class's annotations costs far more than +# anything else here does per type, so unlike the caches above this one outlives +# a compile -- weakly, so a dataclass defined inside a function body is still +# collected with the frame that made it. +_hash_value_keyed_types: WeakKeyDictionary[type, bool] = WeakKeyDictionary() def _hash_dataclass_layout(cls: type) -> tuple[bytes, tuple[tuple[bytes, str], ...]]: @@ -1832,6 +1851,54 @@ def _hash_dataclass_layout(cls: type) -> tuple[bytes, tuple[tuple[bytes, str], . return layout +def _hash_dataclass_is_value_keyed(cls: type) -> bool: + """Check whether instances of ``cls`` can key an encoding cache by value. + + Two instances that compare equal have to encode alike, or a cache hit would + hand back the wrong bytes. That holds when every declared field type is + ``str``, ``bool`` or ``None``, and stops holding as soon as a number is in + play -- a field typed ``int | bool`` can hold ``1`` and ``True``, which are + equal, hash alike, and encode differently. + + Args: + cls: The frozen dataclass type to check. + + Returns: + Whether every declared field type is safe to key on. + """ + keyed = _hash_value_keyed_types.get(cls) + if keyed is None: + keyed = _hash_value_keyed_types[cls] = _hash_dataclass_declares_keyed_fields( + cls + ) + return keyed + + +def _hash_dataclass_declares_keyed_fields(cls: type) -> bool: + """Resolve whether every field ``cls`` declares is safe to key a cache on. + + Args: + cls: The frozen dataclass type to check. + + Returns: + Whether every declared field type is drawn from + ``_HASH_VALUE_KEYED_FIELD_TYPES``. + """ + try: + hints = get_type_hints(cls) + except (NameError, TypeError): + # A class whose annotations name types that aren't resolvable at runtime + # (a class local to a function, a TYPE_CHECKING-only import) states no + # contract we can read, so it doesn't get cached. + return False + for _, name in _hash_dataclass_layout(cls)[1]: + hint = hints.get(name) + members = get_args(hint) if get_origin(hint) in (Union, UnionType) else (hint,) + if not all(member in _HASH_VALUE_KEYED_FIELD_TYPES for member in members): + return False + return True + + def _encode_str_for_hash(value: str) -> bytes: """Encode a string as a type-tagged, length-prefixed payload. @@ -1845,15 +1912,221 @@ def _encode_str_for_hash(value: str) -> bytes: return b"s" + len(encoded).to_bytes(8, "little") + encoded +def _encode_hash_number(value: float | enum.Enum, out: bytearray, hasher: Any) -> None: + """Append a number's or enum member's encoding to ``out``. + + Args: + value: The value to encode. + out: The buffer to append the encoding to. + hasher: Unused; kept for the shared encoder signature. + """ + out += b"n" + out += str(value).encode() + + +def _encode_hash_str(value: str, out: bytearray, hasher: Any) -> None: + """Append a ``str`` subclass instance's encoding to ``out``. + + Args: + value: The value to encode. + out: The buffer to append the encoding to. + hasher: Unused; kept for the shared encoder signature. + """ + out += _encode_str_for_hash(value) + + +def _encode_hash_dict(value: Mapping[Any, Any], out: bytearray, hasher: Any) -> None: + """Append a mapping's encoding to ``out``. + + Args: + value: The mapping to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into once it grows too large. + """ + 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[:] + + +def _encode_hash_sequence(value: Sequence[Any], out: bytearray, hasher: Any) -> None: + """Append a sequence's encoding to ``out``. + + Args: + value: The sequence to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into once it grows too large. + """ + 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[:] + + +def _encode_hash_var(value: Var, out: bytearray, hasher: Any) -> None: + """Append a ``Var``'s encoding to ``out``. + + Args: + value: The var to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into once it grows too large. + """ + out += b"v" + _encode_deterministic(value._js_expr, out, hasher) + _encode_deterministic(value._get_all_var_data(), out, hasher) + + +def _encode_hash_component(value: BaseComponent, out: bytearray, hasher: Any) -> None: + """Append a component's encoding to ``out``. + + Args: + value: The component to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into once it grows too large. + """ + out += b"C" + _encode_deterministic(value.render(), out, hasher) + + +def _encode_hash_dataclass_fields( + cls: type, value: Any, out: bytearray, hasher: Any +) -> None: + """Append the encoding of ``value``'s dataclass fields to ``out``. + + Args: + cls: The dataclass type supplying the field layout. + value: The instance -- or the class itself, for its defaults -- to read + the field values off. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into once it grows too large. + """ + header, fields = _hash_dataclass_layout(cls) + out += header + for encoded_name, name in fields: + out += encoded_name + _encode_deterministic(getattr(value, name), out, hasher) + + +def _encode_hash_dataclass(value: Any, out: bytearray, hasher: Any) -> None: + """Append a dataclass instance's encoding to ``out``. + + Args: + value: The dataclass instance to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into once it grows too large. + """ + _encode_hash_dataclass_fields(type(value), value, out, hasher) + + +def _encode_hash_dataclass_type(value: type, out: bytearray, hasher: Any) -> None: + """Append a dataclass type's encoding -- its field defaults -- to ``out``. + + Args: + value: The dataclass type to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into once it grows too large. + """ + _encode_hash_dataclass_fields(value, value, out, hasher) + + +def _encode_hash_cached_dataclass(value: Any, out: bytearray, hasher: Any) -> None: + """Append a frozen dataclass instance's encoding to ``out``, caching it. + + A compile builds a fresh ``ImportVar`` per component per import, so the same + handful of values is encoded thousands of times: one page hashed 1182 of + them across 22 distinct values. + + Args: + value: The frozen dataclass instance to encode. + out: The buffer to append the encoding to. + hasher: Unused; the fields are encoded into a private buffer that must + not be drained, since the caller needs its full contents. + """ + encoded = _hash_dataclass_encodings.get(value) + if encoded is None: + buffer = bytearray() + _encode_hash_dataclass_fields(type(value), value, buffer, None) + encoded = bytes(buffer) + if ( + len(encoded) <= _HASH_MAX_CACHED_DATACLASS + and len(_hash_dataclass_encodings) < _HASH_MAX_CACHE_ENTRIES + ): + _hash_dataclass_encodings[value] = encoded + out += encoded + + +def _resolve_hash_encoder(value: Any) -> _HashEncoder: + """Pick the encoder for a value whose exact type has no fast path. + + Called once per type, since :func:`_encode_deterministic` memoizes what this + returns -- so subclasses of the fast-path types (notably ``str``-based + enums, which must encode as enums rather than as strings), vars, components + and dataclasses each walk this ladder once instead of once per value. + + Args: + value: A value of the type to resolve an encoder for. + + Returns: + The encoder for the value's type. + + Raises: + TypeError: If the value is not hashable. + """ + # ``bool`` cannot be subclassed, so every bool is caught by the exact-type + # fast path and none arrives here to be mistaken for a number. + if isinstance(value, (int, float, enum.Enum)): + return _encode_hash_number + if isinstance(value, str): + return _encode_hash_str + if isinstance(value, dict): + return _encode_hash_dict + if isinstance(value, (tuple, list)): + return _encode_hash_sequence + if isinstance(value, Var): + return _encode_hash_var + if isinstance(value, BaseComponent): + # Ahead of the dataclass branch: components that also inherit a + # dataclass -- every one built on ``MarkdownComponentMap``, so ``rx.text`` + # and friends -- would otherwise encode as that mixin's field list, + # which is empty, collapsing all of them to the same nine bytes. + return _encode_hash_component + if dataclasses.is_dataclass(value): + if isinstance(value, type): + return _encode_hash_dataclass_type + # ``is_dataclass`` only tests for ``__dataclass_fields__``, which classes + # synthesized at runtime (``MutableProxy``) copy over without the + # decorator's params -- and an unfrozen instance is unhashable anyway. + params = getattr(type(value), "__dataclass_params__", None) + if ( + params is not None + and params.frozen + and _hash_dataclass_is_value_keyed(type(value)) + ): + return _encode_hash_cached_dataclass + return _encode_hash_dataclass + 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 _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. + containers) skip the ``isinstance`` ladder in :func:`_resolve_hash_encoder`, + which every other type walks once and then reaches through a memoized + per-type encoder. ``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. @@ -1876,103 +2149,27 @@ def _encode_deterministic(value: Any, out: bytearray, hasher: Any | None) -> Non 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 + _encode_hash_dict(value, out, hasher) 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[:] + _encode_hash_sequence(value, out, hasher) 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[:] + encoder = _hash_encoders.get(value_type) + if encoder is None: + encoder = _resolve_hash_encoder(value) + if not isinstance(value, type): + # ``value_type`` is the metaclass when the value is itself a + # class, so memoizing would route every other class through the + # encoder resolved for this one. + _hash_encoders[value_type] = encoder + encoder(value, out, hasher) + 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: @@ -2091,11 +2288,13 @@ def clear_hash_caches() -> None: 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. + a function body, one fresh class object per compile, pinned by both the + layout cache and the encoder table. """ _hash_str_encodings.clear() - _hash_import_var_encodings.clear() + _hash_dataclass_encodings.clear() _hash_dataclass_layouts.clear() + _hash_encoders.clear() def memo_tag(component: Component) -> str: diff --git a/tests/units/components/test_memo.py b/tests/units/components/test_memo.py index 98c37166954..74bb6b4c535 100644 --- a/tests/units/components/test_memo.py +++ b/tests/units/components/test_memo.py @@ -12,6 +12,7 @@ from reflex_base.components.component import Component from reflex_base.components.memo import ( _HASH_MAX_CACHE_ENTRIES, + _HASH_MAX_CACHED_DATACLASS, _SPECS, DEFAULT_MEMO_WRAPPER, EMPTY_VAR_COMPONENT, @@ -23,8 +24,9 @@ MemoParamKind, _analyze_params, _deterministic_hash, + _hash_dataclass_encodings, _hash_dataclass_layouts, - _hash_import_var_encodings, + _hash_encoders, _hash_str_encodings, _LazyBody, _MemoCallBinding, @@ -2058,6 +2060,220 @@ def test_deterministic_hash_rejects_unsupported_types(): _deterministic_hash(object()) +class _AppWrapProbe(Component): + """A component whose only per-instance artifact is an app-wrap component.""" + + library = "app-wrap-probe" + tag = "Probe" + + marker: Var[str] + + def _get_app_wrap_components(self) -> dict[tuple[int, str], Component]: + """Wrap the app in a ``MarkdownComponentMap``-based component. + + Returns: + The app wrap components. + """ + return {(50, "AppWrapProbe"): rx.text(self.marker)} + + def _render(self, props: dict[str, Any] | None = None): + """Render without the marker prop so only the app wrap differs. + + Args: + props: The props to render. + + Returns: + The rendered tag. + """ + return super()._render(props).remove_props("marker") + + +def test_component_hash_covers_dataclass_inheriting_app_wrap_components(): + """App-wrap components that also inherit a dataclass must hash by render. + + ``rx.text`` and friends inherit ``MarkdownComponentMap``, a dataclass with + no fields, so encoding the dataclass ahead of the component collapsed every + one of them to the same nine bytes -- and two memo bodies whose app wraps + differed only in such a component shared a tag, dropping one app wrap. + """ + a = _AppWrapProbe.create(marker="alpha") + b = _AppWrapProbe.create(marker="beta") + + assert a.render() == b.render() + assert component_hash(a, recursive=False) != component_hash(b, recursive=False) + assert component_hash(a, recursive=True) != component_hash(b, recursive=True) + assert memo_tag(a) != memo_tag(b) + + +def test_deterministic_hash_encodes_dataclass_components_as_components(): + """A component that also inherits a dataclass encodes its render.""" + assert _deterministic_hash(rx.text("a")) != _deterministic_hash(rx.text("b")) + # A Var is a frozen dataclass too, and must keep encoding as a Var. + assert _deterministic_hash(Var("a")) != _deterministic_hash(Var("b")) + + +@dataclasses.dataclass(frozen=True) +class _KeyedProbe: + """A frozen dataclass whose declared fields are all safe to key on.""" + + name: str + flag: bool = False + alias: str | None = None + + +@dataclasses.dataclass(frozen=True) +class _DefaultsProbe: + """A frozen dataclass every field of which has a default.""" + + name: str = "default" + + +class _PlainProbe: + """A plain class -- no encoding, and ``_DefaultsProbe``'s metaclass.""" + + +@dataclasses.dataclass(frozen=True) +class _NumericProbe: + """A frozen dataclass with a field that ``==`` can conflate.""" + + value: int | bool + + +@dataclasses.dataclass(frozen=True) +class _ContainerProbe: + """A frozen dataclass holding something that can still change.""" + + items: list[int] + + +@dataclasses.dataclass +class _MutableProbe: + """An unfrozen dataclass, and so an unhashable cache key.""" + + value: str + + +def test_deterministic_hash_caches_any_keyable_frozen_dataclass( + clean_hash_caches: None, +): + """Frozen dataclasses of str/bool/None fields are cached, not just imports.""" + probe = _KeyedProbe(name="probe") + + digest = _deterministic_hash(probe) + assert _hash_dataclass_encodings.get(probe) is not None + # An equal instance reuses the entry and lands on the same digest; unequal + # ones must not. + assert _deterministic_hash(_KeyedProbe(name="probe")) == digest + assert _deterministic_hash(_KeyedProbe(name="probe", flag=True)) != digest + assert _deterministic_hash(_KeyedProbe(name="other")) != digest + + +def test_deterministic_hash_does_not_cache_numeric_frozen_dataclasses( + clean_hash_caches: None, +): + """A field that can hold a number is not keyable by value. + + ``True == 1`` and the two hash alike, so a cache keyed on the instance would + hand ``_NumericProbe(1)`` the encoding of ``_NumericProbe(True)``. + """ + boolean, numeric = _NumericProbe(value=True), _NumericProbe(value=1) + + assert boolean == numeric + assert _deterministic_hash(boolean) != _deterministic_hash(numeric) + assert not _hash_dataclass_encodings + + +def test_deterministic_hash_tracks_dataclasses_that_can_still_change(): + """Dataclasses whose contents can change must be re-encoded every time.""" + mutable = _MutableProbe(value="before") + digest = _deterministic_hash(mutable) + mutable.value = "after" + assert _deterministic_hash(mutable) != digest + + # Frozen, but a field holds a mutable container. + container = _ContainerProbe(items=[1]) + digest = _deterministic_hash(container) + container.items.append(2) + assert _deterministic_hash(container) != digest + + +def test_deterministic_hash_handles_dataclasses_without_params(): + """Classes that copy ``__dataclass_fields__`` without the decorator hash. + + ``MutableProxy`` synthesizes its wrapper classes exactly this way: + ``dataclasses.is_dataclass`` is true, but ``__dataclass_params__`` never + comes along, so there is no ``frozen`` flag to read. + """ + synthesized = type( + "_SynthesizedProbe", + (), + { + "__dataclass_fields__": _KeyedProbe.__dataclass_fields__, + "name": "probe", + "flag": False, + "alias": None, + }, + ) + + assert _deterministic_hash(synthesized()) == _deterministic_hash( + _KeyedProbe(name="probe") + ) + + +def test_deterministic_hash_skips_oversized_dataclass_encodings( + clean_hash_caches: None, +): + """Outsized encodings are not retained, keeping the cache's memory bounded.""" + oversized = _KeyedProbe(name="x" * _HASH_MAX_CACHED_DATACLASS) + + digest = _deterministic_hash(oversized) + assert not _hash_dataclass_encodings + assert _deterministic_hash(_KeyedProbe(name="x" * _HASH_MAX_CACHED_DATACLASS)) == ( + digest + ) + + +def test_deterministic_hash_beyond_dataclass_cache_capacity(clean_hash_caches: None): + """Frozen dataclasses arriving after the cache fills still hash correctly.""" + values = [ + _KeyedProbe(name=f"capacity_probe_{i}") + for i in range(_HASH_MAX_CACHE_ENTRIES + 100) + ] + 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_encoder_table_keeps_types_apart(clean_hash_caches: None): + """Memoizing an encoder per type must not route other types through it.""" + # A str-keyed enum resolves to the enum encoder; plain strings must keep + # their own fast path, and the two must not collide. + position = Hooks.HookPosition.PRE_TRIGGER + assert _deterministic_hash(position) != _deterministic_hash(position.value) + assert _deterministic_hash(position.value) == _deterministic_hash( + str(position.value) + ) + # Dataclass types are encoded from their defaults. A class's own type is + # its metaclass, which it shares with unrelated classes, so caching an + # encoder under it would send those down the dataclass path too. + assert type(_DefaultsProbe) is type(_PlainProbe) + assert _deterministic_hash(_DefaultsProbe) == _deterministic_hash( + _DefaultsProbe(name="default") + ) + with pytest.raises(TypeError, match="Cannot hash value"): + _deterministic_hash(_PlainProbe) + + +def test_deterministic_hash_unsupported_type_is_not_memoized(clean_hash_caches: None): + """A type with no encoder must raise every time, not just the first.""" + with pytest.raises(TypeError, match="Cannot hash value"): + _deterministic_hash(object()) + with pytest.raises(TypeError, match="Cannot hash value"): + _deterministic_hash(object()) + assert not _hash_encoders + + class _CustomCodeProbe(Component): """A component whose only per-instance artifact is its custom code.""" @@ -2215,13 +2431,15 @@ def test_clear_hash_caches_drops_every_cache(clean_hash_caches: None): assert _hash_dataclass_layouts assert _hash_str_encodings - assert _hash_import_var_encodings + assert _hash_dataclass_encodings + assert _hash_encoders clear_hash_caches() assert not _hash_dataclass_layouts assert not _hash_str_encodings - assert not _hash_import_var_encodings + assert not _hash_dataclass_encodings + assert not _hash_encoders # Hashing rebuilds them from scratch and must land on the same digest. assert ( _deterministic_hash({ From d8c2466f02534abe4592ea2756b70bac0d4cbba4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 06:14:17 +0000 Subject: [PATCH 09/18] docs: tighten the changelog fragments Fragments are for downstream users; the narrative is a click away on the PR. The bugfix one becomes a bulleted list of what the memo name now accounts for instead of a paragraph per collision. Verified the list renders correctly through the release tooling's own towncrier invocation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c --- news/6947.performance.md | 2 +- packages/reflex-base/news/6947.bugfix.md | 9 +++++---- packages/reflex-base/news/6947.performance.md | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/news/6947.performance.md b/news/6947.performance.md index 6b21fae49e3..138f6988c53 100644 --- a/news/6947.performance.md +++ b/news/6947.performance.md @@ -1 +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. +Compiling an app no longer leaves the auto-memoization naming caches behind, so a long-running process does not accumulate them. diff --git a/packages/reflex-base/news/6947.bugfix.md b/packages/reflex-base/news/6947.bugfix.md index f132289eddb..3296e1294ca 100644 --- a/packages/reflex-base/news/6947.bugfix.md +++ b/packages/reflex-base/news/6947.bugfix.md @@ -1,5 +1,6 @@ -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 render identically no longer share a generated memo name, which silently dropped one of the two compiled bodies. The name now accounts for: -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. - -Auto-memoized components whose app-wrap components include an `rx.text` (or any other component built on `MarkdownComponentMap`) no longer collide either. Those components inherit a field-less dataclass, and the content hash encoded them as that empty field list rather than as their rendered content, so any two of them looked identical. +- 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 diff --git a/packages/reflex-base/news/6947.performance.md b/packages/reflex-base/news/6947.performance.md index 72be3225c7e..dcfb49d3006 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, resolves an encoder once per type rather than walking a type ladder per value, and caches the encoded form of the strings and frozen dataclasses (`ImportVar` above all) that recur across every component. Generated memo module names change as a result; nothing outside the compiled output refers to them. +Component content hashing, which auto-memoization runs for every memoized component during a compile, is 2.3-2.6x faster on large pages. Generated memo module names change as a result; nothing outside the compiled output refers to them. From 210e00206e46a59583fc38b49128804029eab8a7 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Mon, 31 Aug 2026 23:29:09 -0700 Subject: [PATCH 10/18] Delete news/6947.performance.md --- news/6947.performance.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 news/6947.performance.md diff --git a/news/6947.performance.md b/news/6947.performance.md deleted file mode 100644 index 138f6988c53..00000000000 --- a/news/6947.performance.md +++ /dev/null @@ -1 +0,0 @@ -Compiling an app no longer leaves the auto-memoization naming caches behind, so a long-running process does not accumulate them. From a014143692e271ef2303d5c096407e754de69762 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 06:48:03 +0000 Subject: [PATCH 11/18] refactor: move the deterministic hash into its own module The hash is not a property of memoization -- it digests components, vars and rendered data under a self-delimiting encoding, and auto-memoization is just its only caller today. Moved to reflex_base/utils/deterministic_hash.py with the tests alongside it. The private _deterministic_hash/_update_deterministic_hash pair becomes one public variadic deterministic_hash(*values), which is what the two call sites wanted: component_hash now reads as the render plus the artifacts that identify a memo body, and _update_component_artifacts_hash becomes _component_artifacts, a generator that yields them instead of threading a hasher and buffer through. One shared buffer still covers the whole digest. Nothing else under utils imports components at runtime, so the two isinstance checks that need Var and BaseComponent import them inside _resolve_hash_encoder -- once per type, so it never shows up in a profile -- and the module now imports standalone without pulling in the component system. Digests are unchanged: memo tags across three benchmark pages are byte-identical to before the move, and an A/B of the encoder before and after runs at parity. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c --- .../src/reflex_base/components/memo.py | 490 +----------------- .../reflex_base/utils/deterministic_hash.py | 452 ++++++++++++++++ pyi_hashes.json | 2 +- reflex/app.py | 2 +- tests/units/components/test_memo.py | 333 ------------ .../utils/test_deterministic_hash.py | 345 ++++++++++++ tests/units/test_app.py | 5 +- 7 files changed, 829 insertions(+), 800 deletions(-) create mode 100644 packages/reflex-base/src/reflex_base/utils/deterministic_hash.py create mode 100644 tests/units/reflex_base/utils/test_deterministic_hash.py diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index d1326eb4f7b..96ce65c3cfe 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -3,15 +3,12 @@ from __future__ import annotations import dataclasses -import enum import inspect -import operator 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 -from hashlib import md5 from types import UnionType from typing import ( Annotated, @@ -27,12 +24,11 @@ get_type_hints, overload, ) -from weakref import WeakKeyDictionary from reflex_components_core.base.fragment import Fragment from reflex_base import constants -from reflex_base.components.component import BaseComponent, Component +from reflex_base.components.component import Component from reflex_base.components.memoize_helpers import ( MemoizationStrategy, get_memoization_strategy, @@ -46,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 from reflex_base.utils.imports import ImportVar from reflex_base.utils.types import safe_issubclass, typehint_issubclass from reflex_base.vars import VarData @@ -1838,422 +1835,8 @@ def _create_component_wrapper( return _MemoComponentWrapper(definition) -_HASH_BUFFER_FLUSH_SIZE = 1 << 16 -_HASH_MAX_CACHED_STR = 128 -_HASH_MAX_CACHED_DATACLASS = 512 -_HASH_MAX_CACHE_ENTRIES = 4096 - -# The declared field types a frozen dataclass may have and still be safe to key -# an encoding cache on by value: equality between any two values drawn from -# them implies an identical encoding. Numbers are deliberately absent -- -# ``True == 1 == 1.0`` holds and all three hash alike, yet each encodes -# differently, so a lookup could hand back another value's bytes. -_HASH_VALUE_KEYED_FIELD_TYPES = frozenset({str, bool, type(None)}) - -# An encoder appends one value's encoding to a buffer. It takes the hasher its -# buffer is flushed into so it can hand it to nested encodings. -_HashEncoder = Callable[[Any, bytearray, Any], None] - -# Encoded forms of the values that recur across every component hashed during a -# compile: short strings (dict keys, tags, module paths) and frozen dataclasses -# (overwhelmingly ``ImportVar``), which make up the bulk of what a component -# hash feeds in. All four caches 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_dataclass_encodings: dict[Any, bytes] = {} -_hash_dataclass_layouts: dict[type, tuple[bytes, tuple[tuple[bytes, str], ...]]] = {} -_hash_encoders: dict[type, _HashEncoder] = {} - -# Whether a frozen dataclass type's declared fields make it safe to key an -# encoding cache on by value. Reading a class's annotations costs far more than -# anything else here does per type, so unlike the caches above this one outlives -# a compile -- weakly, so a dataclass defined inside a function body is still -# collected with the frame that made it. -_hash_value_keyed_types: WeakKeyDictionary[type, bool] = WeakKeyDictionary() - - -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 _hash_dataclass_is_value_keyed(cls: type) -> bool: - """Check whether instances of ``cls`` can key an encoding cache by value. - - Two instances that compare equal have to encode alike, or a cache hit would - hand back the wrong bytes. That holds when every declared field type is - ``str``, ``bool`` or ``None``, and stops holding as soon as a number is in - play -- a field typed ``int | bool`` can hold ``1`` and ``True``, which are - equal, hash alike, and encode differently. - - Args: - cls: The frozen dataclass type to check. - - Returns: - Whether every declared field type is safe to key on. - """ - keyed = _hash_value_keyed_types.get(cls) - if keyed is None: - keyed = _hash_value_keyed_types[cls] = _hash_dataclass_declares_keyed_fields( - cls - ) - return keyed - - -def _hash_dataclass_declares_keyed_fields(cls: type) -> bool: - """Resolve whether every field ``cls`` declares is safe to key a cache on. - - Args: - cls: The frozen dataclass type to check. - - Returns: - Whether every declared field type is drawn from - ``_HASH_VALUE_KEYED_FIELD_TYPES``. - """ - try: - hints = get_type_hints(cls) - except (NameError, TypeError): - # A class whose annotations name types that aren't resolvable at runtime - # (a class local to a function, a TYPE_CHECKING-only import) states no - # contract we can read, so it doesn't get cached. - return False - for _, name in _hash_dataclass_layout(cls)[1]: - hint = hints.get(name) - members = get_args(hint) if get_origin(hint) in (Union, UnionType) else (hint,) - if not all(member in _HASH_VALUE_KEYED_FIELD_TYPES for member in members): - return False - return True - - -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_hash_number(value: float | enum.Enum, out: bytearray, hasher: Any) -> None: - """Append a number's or enum member's encoding to ``out``. - - Args: - value: The value to encode. - out: The buffer to append the encoding to. - hasher: Unused; kept for the shared encoder signature. - """ - out += b"n" - out += str(value).encode() - - -def _encode_hash_str(value: str, out: bytearray, hasher: Any) -> None: - """Append a ``str`` subclass instance's encoding to ``out``. - - Args: - value: The value to encode. - out: The buffer to append the encoding to. - hasher: Unused; kept for the shared encoder signature. - """ - out += _encode_str_for_hash(value) - - -def _encode_hash_dict(value: Mapping[Any, Any], out: bytearray, hasher: Any) -> None: - """Append a mapping's encoding to ``out``. - - Args: - value: The mapping to encode. - out: The buffer to append the encoding to. - hasher: The hasher ``out`` is flushed into once it grows too large. - """ - 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[:] - - -def _encode_hash_sequence(value: Sequence[Any], out: bytearray, hasher: Any) -> None: - """Append a sequence's encoding to ``out``. - - Args: - value: The sequence to encode. - out: The buffer to append the encoding to. - hasher: The hasher ``out`` is flushed into once it grows too large. - """ - 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[:] - - -def _encode_hash_var(value: Var, out: bytearray, hasher: Any) -> None: - """Append a ``Var``'s encoding to ``out``. - - Args: - value: The var to encode. - out: The buffer to append the encoding to. - hasher: The hasher ``out`` is flushed into once it grows too large. - """ - out += b"v" - _encode_deterministic(value._js_expr, out, hasher) - _encode_deterministic(value._get_all_var_data(), out, hasher) - - -def _encode_hash_component(value: BaseComponent, out: bytearray, hasher: Any) -> None: - """Append a component's encoding to ``out``. - - Args: - value: The component to encode. - out: The buffer to append the encoding to. - hasher: The hasher ``out`` is flushed into once it grows too large. - """ - out += b"C" - _encode_deterministic(value.render(), out, hasher) - - -def _encode_hash_dataclass_fields( - cls: type, value: Any, out: bytearray, hasher: Any -) -> None: - """Append the encoding of ``value``'s dataclass fields to ``out``. - - Args: - cls: The dataclass type supplying the field layout. - value: The instance -- or the class itself, for its defaults -- to read - the field values off. - out: The buffer to append the encoding to. - hasher: The hasher ``out`` is flushed into once it grows too large. - """ - header, fields = _hash_dataclass_layout(cls) - out += header - for encoded_name, name in fields: - out += encoded_name - _encode_deterministic(getattr(value, name), out, hasher) - - -def _encode_hash_dataclass(value: Any, out: bytearray, hasher: Any) -> None: - """Append a dataclass instance's encoding to ``out``. - - Args: - value: The dataclass instance to encode. - out: The buffer to append the encoding to. - hasher: The hasher ``out`` is flushed into once it grows too large. - """ - _encode_hash_dataclass_fields(type(value), value, out, hasher) - - -def _encode_hash_dataclass_type(value: type, out: bytearray, hasher: Any) -> None: - """Append a dataclass type's encoding -- its field defaults -- to ``out``. - - Args: - value: The dataclass type to encode. - out: The buffer to append the encoding to. - hasher: The hasher ``out`` is flushed into once it grows too large. - """ - _encode_hash_dataclass_fields(value, value, out, hasher) - - -def _encode_hash_cached_dataclass(value: Any, out: bytearray, hasher: Any) -> None: - """Append a frozen dataclass instance's encoding to ``out``, caching it. - - A compile builds a fresh ``ImportVar`` per component per import, so the same - handful of values is encoded thousands of times: one page hashed 1182 of - them across 22 distinct values. - - Args: - value: The frozen dataclass instance to encode. - out: The buffer to append the encoding to. - hasher: Unused; the fields are encoded into a private buffer that must - not be drained, since the caller needs its full contents. - """ - encoded = _hash_dataclass_encodings.get(value) - if encoded is None: - buffer = bytearray() - _encode_hash_dataclass_fields(type(value), value, buffer, None) - encoded = bytes(buffer) - if ( - len(encoded) <= _HASH_MAX_CACHED_DATACLASS - and len(_hash_dataclass_encodings) < _HASH_MAX_CACHE_ENTRIES - ): - _hash_dataclass_encodings[value] = encoded - out += encoded - - -def _resolve_hash_encoder(value: Any) -> _HashEncoder: - """Pick the encoder for a value whose exact type has no fast path. - - Called once per type, since :func:`_encode_deterministic` memoizes what this - returns -- so subclasses of the fast-path types (notably ``str``-based - enums, which must encode as enums rather than as strings), vars, components - and dataclasses each walk this ladder once instead of once per value. - - Args: - value: A value of the type to resolve an encoder for. - - Returns: - The encoder for the value's type. - - Raises: - TypeError: If the value is not hashable. - """ - # ``bool`` cannot be subclassed, so every bool is caught by the exact-type - # fast path and none arrives here to be mistaken for a number. - if isinstance(value, (int, float, enum.Enum)): - return _encode_hash_number - if isinstance(value, str): - return _encode_hash_str - if isinstance(value, dict): - return _encode_hash_dict - if isinstance(value, (tuple, list)): - return _encode_hash_sequence - if isinstance(value, Var): - return _encode_hash_var - if isinstance(value, BaseComponent): - # Ahead of the dataclass branch: components that also inherit a - # dataclass -- every one built on ``MarkdownComponentMap``, so ``rx.text`` - # and friends -- would otherwise encode as that mixin's field list, - # which is empty, collapsing all of them to the same nine bytes. - return _encode_hash_component - if dataclasses.is_dataclass(value): - if isinstance(value, type): - return _encode_hash_dataclass_type - # ``is_dataclass`` only tests for ``__dataclass_fields__``, which classes - # synthesized at runtime (``MutableProxy``) copy over without the - # decorator's params -- and an unfrozen instance is unhashable anyway. - params = getattr(type(value), "__dataclass_params__", None) - if ( - params is not None - and params.frozen - and _hash_dataclass_is_value_keyed(type(value)) - ): - return _encode_hash_cached_dataclass - return _encode_hash_dataclass - 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 _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) skip the ``isinstance`` ladder in :func:`_resolve_hash_encoder`, - which every other type walks once and then reaches through a memoized - per-type encoder. ``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: - _encode_hash_dict(value, out, hasher) - elif value_type is list or value_type is tuple: - _encode_hash_sequence(value, out, hasher) - 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: - encoder = _hash_encoders.get(value_type) - if encoder is None: - encoder = _resolve_hash_encoder(value) - if not isinstance(value, type): - # ``value_type`` is the metaclass when the value is itself a - # class, so memoizing would route every other class through the - # encoder resolved for this one. - _hash_encoders[value_type] = encoder - encoder(value, out, hasher) - 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``. +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`` @@ -2262,50 +1845,45 @@ def _update_component_artifacts_hash( 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. - 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. + + Yields: + Each artifact, in a fixed order. """ - 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) + yield f"{cls.__module__}.{cls.__qualname__}" 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) + 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. - _encode_deterministic( - sorted(component._get_all_dynamic_imports()), buffer, hasher - ) - _encode_deterministic(component._get_all_app_wrap_components(), buffer, hasher) + yield sorted(component._get_all_dynamic_imports()) + yield component._get_all_app_wrap_components() 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) + 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. 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_dynamic_imports(), buffer, hasher) - _encode_deterministic(component._get_app_wrap_components(), buffer, hasher) - hasher.update(buffer) + 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: @@ -2319,25 +1897,9 @@ def component_hash(component: Component, *, recursive: bool) -> str: 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 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, pinned by both the - layout cache and the encoder table. - """ - _hash_str_encodings.clear() - _hash_dataclass_encodings.clear() - _hash_dataclass_layouts.clear() - _hash_encoders.clear() + return deterministic_hash( + component.render(), *_component_artifacts(component, recursive=recursive) + ) def memo_tag(component: Component) -> str: diff --git a/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py b/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py new file mode 100644 index 00000000000..547cdfb75f0 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py @@ -0,0 +1,452 @@ +"""A stable content hash over components, vars and the data they render to. + +:func:`deterministic_hash` digests a value under a self-delimiting, type-tagged +encoding: every type writes a distinct tag and a length-prefixed payload, so +the encoding is injective and two values that differ anywhere digest +differently. Unlike :func:`hash`, it is stable across processes, which is what +lets a digest name a generated file. + +Values are encoded into a ``bytearray`` handed to the hasher in large chunks +rather than one update per node. Encoders are resolved once per type, and the +encodings of values that recur across a whole run -- short strings and frozen +dataclasses -- are cached until :func:`clear_hash_caches` drops them. + +Auto-memoization is the caller today: it names every wrapper it generates after +a digest of what the generated module will contain, so a collision silently +drops one of the two bodies from the compiled output. +""" + +from __future__ import annotations + +import dataclasses +import enum +import operator +from collections.abc import Callable, Mapping, Sequence +from hashlib import md5 +from types import UnionType +from typing import TYPE_CHECKING, Any, Union, get_args, get_origin, get_type_hints +from weakref import WeakKeyDictionary + +if TYPE_CHECKING: + from reflex_base.components.component import BaseComponent + from reflex_base.vars.base import Var + +_HASH_BUFFER_FLUSH_SIZE = 1 << 16 +_HASH_MAX_CACHED_STR = 128 +_HASH_MAX_CACHED_DATACLASS = 512 +_HASH_MAX_CACHE_ENTRIES = 4096 + +# The declared field types a frozen dataclass may have and still be safe to key +# an encoding cache on by value: equality between any two values drawn from +# them implies an identical encoding. Numbers are deliberately absent -- +# ``True == 1 == 1.0`` holds and all three hash alike, yet each encodes +# differently, so a lookup could hand back another value's bytes. +_HASH_VALUE_KEYED_FIELD_TYPES = frozenset({str, bool, type(None)}) + +# An encoder appends one value's encoding to a buffer. It takes the hasher its +# buffer is flushed into so it can hand it to nested encodings. +_HashEncoder = Callable[[Any, bytearray, Any], None] + +# Encoded forms of the values that recur across every component hashed during a +# compile: short strings (dict keys, tags, module paths) and frozen dataclasses +# (overwhelmingly ``ImportVar``), which make up the bulk of what a component +# hash feeds in. All four caches 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_dataclass_encodings: dict[Any, bytes] = {} +_hash_dataclass_layouts: dict[type, tuple[bytes, tuple[tuple[bytes, str], ...]]] = {} +_hash_encoders: dict[type, _HashEncoder] = {} + +# Whether a frozen dataclass type's declared fields make it safe to key an +# encoding cache on by value. Reading a class's annotations costs far more than +# anything else here does per type, so unlike the caches above this one outlives +# a compile -- weakly, so a dataclass defined inside a function body is still +# collected with the frame that made it. +_hash_value_keyed_types: WeakKeyDictionary[type, bool] = WeakKeyDictionary() + + +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 _hash_dataclass_is_value_keyed(cls: type) -> bool: + """Check whether instances of ``cls`` can key an encoding cache by value. + + Two instances that compare equal have to encode alike, or a cache hit would + hand back the wrong bytes. That holds when every declared field type is + ``str``, ``bool`` or ``None``, and stops holding as soon as a number is in + play -- a field typed ``int | bool`` can hold ``1`` and ``True``, which are + equal, hash alike, and encode differently. + + Args: + cls: The frozen dataclass type to check. + + Returns: + Whether every declared field type is safe to key on. + """ + keyed = _hash_value_keyed_types.get(cls) + if keyed is None: + keyed = _hash_value_keyed_types[cls] = _hash_dataclass_declares_keyed_fields( + cls + ) + return keyed + + +def _hash_dataclass_declares_keyed_fields(cls: type) -> bool: + """Resolve whether every field ``cls`` declares is safe to key a cache on. + + Args: + cls: The frozen dataclass type to check. + + Returns: + Whether every declared field type is drawn from + ``_HASH_VALUE_KEYED_FIELD_TYPES``. + """ + try: + hints = get_type_hints(cls) + except (NameError, TypeError): + # A class whose annotations name types that aren't resolvable at runtime + # (a class local to a function, a TYPE_CHECKING-only import) states no + # contract we can read, so it doesn't get cached. + return False + for _, name in _hash_dataclass_layout(cls)[1]: + hint = hints.get(name) + members = get_args(hint) if get_origin(hint) in (Union, UnionType) else (hint,) + if not all(member in _HASH_VALUE_KEYED_FIELD_TYPES for member in members): + return False + return True + + +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_hash_number(value: float | enum.Enum, out: bytearray, hasher: Any) -> None: + """Append a number's or enum member's encoding to ``out``. + + Args: + value: The value to encode. + out: The buffer to append the encoding to. + hasher: Unused; kept for the shared encoder signature. + """ + out += b"n" + out += str(value).encode() + + +def _encode_hash_str(value: str, out: bytearray, hasher: Any) -> None: + """Append a ``str`` subclass instance's encoding to ``out``. + + Args: + value: The value to encode. + out: The buffer to append the encoding to. + hasher: Unused; kept for the shared encoder signature. + """ + out += _encode_str_for_hash(value) + + +def _encode_hash_dict(value: Mapping[Any, Any], out: bytearray, hasher: Any) -> None: + """Append a mapping's encoding to ``out``. + + Args: + value: The mapping to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into once it grows too large. + """ + 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[:] + + +def _encode_hash_sequence(value: Sequence[Any], out: bytearray, hasher: Any) -> None: + """Append a sequence's encoding to ``out``. + + Args: + value: The sequence to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into once it grows too large. + """ + 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[:] + + +def _encode_hash_var(value: Var, out: bytearray, hasher: Any) -> None: + """Append a ``Var``'s encoding to ``out``. + + Args: + value: The var to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into once it grows too large. + """ + out += b"v" + _encode_deterministic(value._js_expr, out, hasher) + _encode_deterministic(value._get_all_var_data(), out, hasher) + + +def _encode_hash_component(value: BaseComponent, out: bytearray, hasher: Any) -> None: + """Append a component's encoding to ``out``. + + Args: + value: The component to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into once it grows too large. + """ + out += b"C" + _encode_deterministic(value.render(), out, hasher) + + +def _encode_hash_dataclass_fields( + cls: type, value: Any, out: bytearray, hasher: Any +) -> None: + """Append the encoding of ``value``'s dataclass fields to ``out``. + + Args: + cls: The dataclass type supplying the field layout. + value: The instance -- or the class itself, for its defaults -- to read + the field values off. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into once it grows too large. + """ + header, fields = _hash_dataclass_layout(cls) + out += header + for encoded_name, name in fields: + out += encoded_name + _encode_deterministic(getattr(value, name), out, hasher) + + +def _encode_hash_dataclass(value: Any, out: bytearray, hasher: Any) -> None: + """Append a dataclass instance's encoding to ``out``. + + Args: + value: The dataclass instance to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into once it grows too large. + """ + _encode_hash_dataclass_fields(type(value), value, out, hasher) + + +def _encode_hash_dataclass_type(value: type, out: bytearray, hasher: Any) -> None: + """Append a dataclass type's encoding -- its field defaults -- to ``out``. + + Args: + value: The dataclass type to encode. + out: The buffer to append the encoding to. + hasher: The hasher ``out`` is flushed into once it grows too large. + """ + _encode_hash_dataclass_fields(value, value, out, hasher) + + +def _encode_hash_cached_dataclass(value: Any, out: bytearray, hasher: Any) -> None: + """Append a frozen dataclass instance's encoding to ``out``, caching it. + + A compile builds a fresh ``ImportVar`` per component per import, so the same + handful of values is encoded thousands of times: one page hashed 1182 of + them across 22 distinct values. + + Args: + value: The frozen dataclass instance to encode. + out: The buffer to append the encoding to. + hasher: Unused; the fields are encoded into a private buffer that must + not be drained, since the caller needs its full contents. + """ + encoded = _hash_dataclass_encodings.get(value) + if encoded is None: + buffer = bytearray() + _encode_hash_dataclass_fields(type(value), value, buffer, None) + encoded = bytes(buffer) + if ( + len(encoded) <= _HASH_MAX_CACHED_DATACLASS + and len(_hash_dataclass_encodings) < _HASH_MAX_CACHE_ENTRIES + ): + _hash_dataclass_encodings[value] = encoded + out += encoded + + +def _resolve_hash_encoder(value: Any) -> _HashEncoder: + """Pick the encoder for a value whose exact type has no fast path. + + Called once per type, since :func:`_encode_deterministic` memoizes what this + returns -- so subclasses of the fast-path types (notably ``str``-based + enums, which must encode as enums rather than as strings), vars, components + and dataclasses each walk this ladder once instead of once per value. + + Args: + value: A value of the type to resolve an encoder for. + + Returns: + The encoder for the value's type. + + Raises: + TypeError: If the value is not hashable. + """ + # Imported here rather than at module scope: nothing else under ``utils`` + # depends on ``components`` at runtime, and resolving an encoder happens + # once per type, so the lookup never shows up in a profile. + from reflex_base.components.component import BaseComponent + from reflex_base.vars.base import Var + + # ``bool`` cannot be subclassed, so every bool is caught by the exact-type + # fast path and none arrives here to be mistaken for a number. + if isinstance(value, (int, float, enum.Enum)): + return _encode_hash_number + if isinstance(value, str): + return _encode_hash_str + if isinstance(value, dict): + return _encode_hash_dict + if isinstance(value, (tuple, list)): + return _encode_hash_sequence + if isinstance(value, Var): + return _encode_hash_var + if isinstance(value, BaseComponent): + # Ahead of the dataclass branch: components that also inherit a + # dataclass -- every one built on ``MarkdownComponentMap``, so ``rx.text`` + # and friends -- would otherwise encode as that mixin's field list, + # which is empty, collapsing all of them to the same nine bytes. + return _encode_hash_component + if dataclasses.is_dataclass(value): + if isinstance(value, type): + return _encode_hash_dataclass_type + # ``is_dataclass`` only tests for ``__dataclass_fields__``, which classes + # synthesized at runtime (``MutableProxy``) copy over without the + # decorator's params -- and an unfrozen instance is unhashable anyway. + params = getattr(type(value), "__dataclass_params__", None) + if ( + params is not None + and params.frozen + and _hash_dataclass_is_value_keyed(type(value)) + ): + return _encode_hash_cached_dataclass + return _encode_hash_dataclass + 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 _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) skip the ``isinstance`` ladder in :func:`_resolve_hash_encoder`, + which every other type walks once and then reaches through a memoized + per-type encoder. ``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: + _encode_hash_dict(value, out, hasher) + elif value_type is list or value_type is tuple: + _encode_hash_sequence(value, out, hasher) + 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: + encoder = _hash_encoders.get(value_type) + if encoder is None: + encoder = _resolve_hash_encoder(value) + if not isinstance(value, type): + # ``value_type`` is the metaclass when the value is itself a + # class, so memoizing would route every other class through the + # encoder resolved for this one. + _hash_encoders[value_type] = encoder + encoder(value, out, hasher) + if len(out) > _HASH_BUFFER_FLUSH_SIZE and hasher is not None: + hasher.update(out) + del out[:] + + +def deterministic_hash(*values: object) -> str: + """Fold values into a single digest, in the order given. + + Every value shares one buffer, so a hash covering many values pays a + handful of hasher updates rather than one per value. + + Args: + *values: The values to hash. + + Returns: + The hex digest over all values. + + Raises: + TypeError: If a value has no encoding. + """ + hasher = md5(usedforsecurity=False) + buffer = bytearray() + for value in values: + _encode_deterministic(value, buffer, hasher) + hasher.update(buffer) + return hasher.hexdigest() + + +def clear_hash_caches() -> None: + """Drop the encoding caches. + + Everything 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, pinned by both the + layout cache and the encoder table. + """ + _hash_str_encodings.clear() + _hash_dataclass_encodings.clear() + _hash_dataclass_layouts.clear() + _hash_encoders.clear() diff --git a/pyi_hashes.json b/pyi_hashes.json index 13cd6cc5d47..76ffc5815bb 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": "3797c9aa34efb35476fde6c3047db26d" + "reflex/experimental/memo.pyi": "27a73a66e238746e5da5accf99a8fdfd" } diff --git a/reflex/app.py b/reflex/app.py index ac9b9d532bd..7f1283ae8cf 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1657,7 +1657,7 @@ 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. """ - from reflex_base.components.memo import clear_hash_caches + from reflex_base.utils.deterministic_hash import clear_hash_caches ctx = TelemetryContext.start(trigger=trigger) try: diff --git a/tests/units/components/test_memo.py b/tests/units/components/test_memo.py index 6afd017df90..b061fb8d7f9 100644 --- a/tests/units/components/test_memo.py +++ b/tests/units/components/test_memo.py @@ -2,7 +2,6 @@ from __future__ import annotations -import dataclasses import inspect import re from collections.abc import Callable @@ -13,8 +12,6 @@ import pytest from reflex_base.components.component import Component from reflex_base.components.memo import ( - _HASH_MAX_CACHE_ENTRIES, - _HASH_MAX_CACHED_DATACLASS, _SPECS, DEFAULT_MEMO_WRAPPER, EMPTY_VAR_COMPONENT, @@ -25,19 +22,12 @@ MemoParam, MemoParamKind, _analyze_params, - _deterministic_hash, - _hash_dataclass_encodings, - _hash_dataclass_layouts, - _hash_encoders, - _hash_str_encodings, _LazyBody, _MemoCallBinding, _strip_optional, - clear_hash_caches, 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 @@ -2054,126 +2044,6 @@ 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}} - 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(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] - - 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 _AppWrapProbe(Component): """A component whose only per-instance artifact is an app-wrap component.""" @@ -2219,175 +2089,6 @@ def test_component_hash_covers_dataclass_inheriting_app_wrap_components(): assert memo_tag(a) != memo_tag(b) -def test_deterministic_hash_encodes_dataclass_components_as_components(): - """A component that also inherits a dataclass encodes its render.""" - assert _deterministic_hash(rx.text("a")) != _deterministic_hash(rx.text("b")) - # A Var is a frozen dataclass too, and must keep encoding as a Var. - assert _deterministic_hash(Var("a")) != _deterministic_hash(Var("b")) - - -@dataclasses.dataclass(frozen=True) -class _KeyedProbe: - """A frozen dataclass whose declared fields are all safe to key on.""" - - name: str - flag: bool = False - alias: str | None = None - - -@dataclasses.dataclass(frozen=True) -class _DefaultsProbe: - """A frozen dataclass every field of which has a default.""" - - name: str = "default" - - -class _PlainProbe: - """A plain class -- no encoding, and ``_DefaultsProbe``'s metaclass.""" - - -@dataclasses.dataclass(frozen=True) -class _NumericProbe: - """A frozen dataclass with a field that ``==`` can conflate.""" - - value: int | bool - - -@dataclasses.dataclass(frozen=True) -class _ContainerProbe: - """A frozen dataclass holding something that can still change.""" - - items: list[int] - - -@dataclasses.dataclass -class _MutableProbe: - """An unfrozen dataclass, and so an unhashable cache key.""" - - value: str - - -def test_deterministic_hash_caches_any_keyable_frozen_dataclass( - clean_hash_caches: None, -): - """Frozen dataclasses of str/bool/None fields are cached, not just imports.""" - probe = _KeyedProbe(name="probe") - - digest = _deterministic_hash(probe) - assert _hash_dataclass_encodings.get(probe) is not None - # An equal instance reuses the entry and lands on the same digest; unequal - # ones must not. - assert _deterministic_hash(_KeyedProbe(name="probe")) == digest - assert _deterministic_hash(_KeyedProbe(name="probe", flag=True)) != digest - assert _deterministic_hash(_KeyedProbe(name="other")) != digest - - -def test_deterministic_hash_does_not_cache_numeric_frozen_dataclasses( - clean_hash_caches: None, -): - """A field that can hold a number is not keyable by value. - - ``True == 1`` and the two hash alike, so a cache keyed on the instance would - hand ``_NumericProbe(1)`` the encoding of ``_NumericProbe(True)``. - """ - boolean, numeric = _NumericProbe(value=True), _NumericProbe(value=1) - - assert boolean == numeric - assert _deterministic_hash(boolean) != _deterministic_hash(numeric) - assert not _hash_dataclass_encodings - - -def test_deterministic_hash_tracks_dataclasses_that_can_still_change(): - """Dataclasses whose contents can change must be re-encoded every time.""" - mutable = _MutableProbe(value="before") - digest = _deterministic_hash(mutable) - mutable.value = "after" - assert _deterministic_hash(mutable) != digest - - # Frozen, but a field holds a mutable container. - container = _ContainerProbe(items=[1]) - digest = _deterministic_hash(container) - container.items.append(2) - assert _deterministic_hash(container) != digest - - -def test_deterministic_hash_handles_dataclasses_without_params(): - """Classes that copy ``__dataclass_fields__`` without the decorator hash. - - ``MutableProxy`` synthesizes its wrapper classes exactly this way: - ``dataclasses.is_dataclass`` is true, but ``__dataclass_params__`` never - comes along, so there is no ``frozen`` flag to read. - """ - synthesized = type( - "_SynthesizedProbe", - (), - { - "__dataclass_fields__": _KeyedProbe.__dataclass_fields__, - "name": "probe", - "flag": False, - "alias": None, - }, - ) - - assert _deterministic_hash(synthesized()) == _deterministic_hash( - _KeyedProbe(name="probe") - ) - - -def test_deterministic_hash_skips_oversized_dataclass_encodings( - clean_hash_caches: None, -): - """Outsized encodings are not retained, keeping the cache's memory bounded.""" - oversized = _KeyedProbe(name="x" * _HASH_MAX_CACHED_DATACLASS) - - digest = _deterministic_hash(oversized) - assert not _hash_dataclass_encodings - assert _deterministic_hash(_KeyedProbe(name="x" * _HASH_MAX_CACHED_DATACLASS)) == ( - digest - ) - - -def test_deterministic_hash_beyond_dataclass_cache_capacity(clean_hash_caches: None): - """Frozen dataclasses arriving after the cache fills still hash correctly.""" - values = [ - _KeyedProbe(name=f"capacity_probe_{i}") - for i in range(_HASH_MAX_CACHE_ENTRIES + 100) - ] - 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_encoder_table_keeps_types_apart(clean_hash_caches: None): - """Memoizing an encoder per type must not route other types through it.""" - # A str-keyed enum resolves to the enum encoder; plain strings must keep - # their own fast path, and the two must not collide. - position = Hooks.HookPosition.PRE_TRIGGER - assert _deterministic_hash(position) != _deterministic_hash(position.value) - assert _deterministic_hash(position.value) == _deterministic_hash( - str(position.value) - ) - # Dataclass types are encoded from their defaults. A class's own type is - # its metaclass, which it shares with unrelated classes, so caching an - # encoder under it would send those down the dataclass path too. - assert type(_DefaultsProbe) is type(_PlainProbe) - assert _deterministic_hash(_DefaultsProbe) == _deterministic_hash( - _DefaultsProbe(name="default") - ) - with pytest.raises(TypeError, match="Cannot hash value"): - _deterministic_hash(_PlainProbe) - - -def test_deterministic_hash_unsupported_type_is_not_memoized(clean_hash_caches: None): - """A type with no encoder must raise every time, not just the first.""" - with pytest.raises(TypeError, match="Cannot hash value"): - _deterministic_hash(object()) - with pytest.raises(TypeError, match="Cannot hash value"): - _deterministic_hash(object()) - assert not _hash_encoders - - class _CustomCodeProbe(Component): """A component whose only per-instance artifact is its custom code.""" @@ -2528,37 +2229,3 @@ class _BetaProbe(Component): assert alpha.render() == beta.render() assert memo_tag(alpha) != memo_tag(beta) - - -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 - 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_dataclass_encodings - assert _hash_encoders - - clear_hash_caches() - - assert not _hash_dataclass_layouts - assert not _hash_str_encodings - assert not _hash_dataclass_encodings - assert not _hash_encoders - # Hashing rebuilds them from scratch and must land on the same digest. - assert ( - _deterministic_hash({ - "prop": ephemeral(v="x"), - "imports": (ImportVar(tag="useCacheProbe"),), - }) - == before - ) diff --git a/tests/units/reflex_base/utils/test_deterministic_hash.py b/tests/units/reflex_base/utils/test_deterministic_hash.py new file mode 100644 index 00000000000..7a6c4296583 --- /dev/null +++ b/tests/units/reflex_base/utils/test_deterministic_hash.py @@ -0,0 +1,345 @@ +"""Tests for the deterministic content hash.""" + +from __future__ import annotations + +import dataclasses +from typing import Any + +import pytest +from reflex_base.constants import Hooks +from reflex_base.utils.deterministic_hash import ( + _HASH_MAX_CACHE_ENTRIES, + _HASH_MAX_CACHED_DATACLASS, + _hash_dataclass_encodings, + _hash_dataclass_layouts, + _hash_encoders, + _hash_str_encodings, + clear_hash_caches, + deterministic_hash, +) +from reflex_base.utils.imports import ImportVar +from reflex_base.vars.base import Var +from reflex_components_core.base.bare import Bare + +import reflex as rx + + +@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}} + 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(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] + + 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()) + + +def test_deterministic_hash_encodes_dataclass_components_as_components(): + """A component that also inherits a dataclass encodes its render.""" + assert deterministic_hash(rx.text("a")) != deterministic_hash(rx.text("b")) + # A Var is a frozen dataclass too, and must keep encoding as a Var. + assert deterministic_hash(Var("a")) != deterministic_hash(Var("b")) + + +@dataclasses.dataclass(frozen=True) +class _KeyedProbe: + """A frozen dataclass whose declared fields are all safe to key on.""" + + name: str + flag: bool = False + alias: str | None = None + + +@dataclasses.dataclass(frozen=True) +class _DefaultsProbe: + """A frozen dataclass every field of which has a default.""" + + name: str = "default" + + +class _PlainProbe: + """A plain class -- no encoding, and ``_DefaultsProbe``'s metaclass.""" + + +@dataclasses.dataclass(frozen=True) +class _NumericProbe: + """A frozen dataclass with a field that ``==`` can conflate.""" + + value: int | bool + + +@dataclasses.dataclass(frozen=True) +class _ContainerProbe: + """A frozen dataclass holding something that can still change.""" + + items: list[int] + + +@dataclasses.dataclass +class _MutableProbe: + """An unfrozen dataclass, and so an unhashable cache key.""" + + value: str + + +def test_deterministic_hash_caches_any_keyable_frozen_dataclass( + clean_hash_caches: None, +): + """Frozen dataclasses of str/bool/None fields are cached, not just imports.""" + probe = _KeyedProbe(name="probe") + + digest = deterministic_hash(probe) + assert _hash_dataclass_encodings.get(probe) is not None + # An equal instance reuses the entry and lands on the same digest; unequal + # ones must not. + assert deterministic_hash(_KeyedProbe(name="probe")) == digest + assert deterministic_hash(_KeyedProbe(name="probe", flag=True)) != digest + assert deterministic_hash(_KeyedProbe(name="other")) != digest + + +def test_deterministic_hash_does_not_cache_numeric_frozen_dataclasses( + clean_hash_caches: None, +): + """A field that can hold a number is not keyable by value. + + ``True == 1`` and the two hash alike, so a cache keyed on the instance would + hand ``_NumericProbe(1)`` the encoding of ``_NumericProbe(True)``. + """ + boolean, numeric = _NumericProbe(value=True), _NumericProbe(value=1) + + assert boolean == numeric + assert deterministic_hash(boolean) != deterministic_hash(numeric) + assert not _hash_dataclass_encodings + + +def test_deterministic_hash_tracks_dataclasses_that_can_still_change(): + """Dataclasses whose contents can change must be re-encoded every time.""" + mutable = _MutableProbe(value="before") + digest = deterministic_hash(mutable) + mutable.value = "after" + assert deterministic_hash(mutable) != digest + + # Frozen, but a field holds a mutable container. + container = _ContainerProbe(items=[1]) + digest = deterministic_hash(container) + container.items.append(2) + assert deterministic_hash(container) != digest + + +def test_deterministic_hash_handles_dataclasses_without_params(): + """Classes that copy ``__dataclass_fields__`` without the decorator hash. + + ``MutableProxy`` synthesizes its wrapper classes exactly this way: + ``dataclasses.is_dataclass`` is true, but ``__dataclass_params__`` never + comes along, so there is no ``frozen`` flag to read. + """ + synthesized = type( + "_SynthesizedProbe", + (), + { + "__dataclass_fields__": _KeyedProbe.__dataclass_fields__, + "name": "probe", + "flag": False, + "alias": None, + }, + ) + + assert deterministic_hash(synthesized()) == deterministic_hash( + _KeyedProbe(name="probe") + ) + + +def test_deterministic_hash_skips_oversized_dataclass_encodings( + clean_hash_caches: None, +): + """Outsized encodings are not retained, keeping the cache's memory bounded.""" + oversized = _KeyedProbe(name="x" * _HASH_MAX_CACHED_DATACLASS) + + digest = deterministic_hash(oversized) + assert not _hash_dataclass_encodings + assert deterministic_hash(_KeyedProbe(name="x" * _HASH_MAX_CACHED_DATACLASS)) == ( + digest + ) + + +def test_deterministic_hash_beyond_dataclass_cache_capacity(clean_hash_caches: None): + """Frozen dataclasses arriving after the cache fills still hash correctly.""" + values = [ + _KeyedProbe(name=f"capacity_probe_{i}") + for i in range(_HASH_MAX_CACHE_ENTRIES + 100) + ] + 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_encoder_table_keeps_types_apart(clean_hash_caches: None): + """Memoizing an encoder per type must not route other types through it.""" + # A str-keyed enum resolves to the enum encoder; plain strings must keep + # their own fast path, and the two must not collide. + position = Hooks.HookPosition.PRE_TRIGGER + assert deterministic_hash(position) != deterministic_hash(position.value) + assert deterministic_hash(position.value) == deterministic_hash(str(position.value)) + # Dataclass types are encoded from their defaults. A class's own type is + # its metaclass, which it shares with unrelated classes, so caching an + # encoder under it would send those down the dataclass path too. + assert type(_DefaultsProbe) is type(_PlainProbe) + assert deterministic_hash(_DefaultsProbe) == deterministic_hash( + _DefaultsProbe(name="default") + ) + with pytest.raises(TypeError, match="Cannot hash value"): + deterministic_hash(_PlainProbe) + + +def test_deterministic_hash_unsupported_type_is_not_memoized(clean_hash_caches: None): + """A type with no encoder must raise every time, not just the first.""" + with pytest.raises(TypeError, match="Cannot hash value"): + deterministic_hash(object()) + with pytest.raises(TypeError, match="Cannot hash value"): + deterministic_hash(object()) + assert not _hash_encoders + + +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 + 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_dataclass_encodings + assert _hash_encoders + + clear_hash_caches() + + assert not _hash_dataclass_layouts + assert not _hash_str_encodings + assert not _hash_dataclass_encodings + assert not _hash_encoders + # Hashing rebuilds them from scratch and must land on the same digest. + assert ( + deterministic_hash({ + "prop": ephemeral(v="x"), + "imports": (ImportVar(tag="useCacheProbe"),), + }) + == before + ) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 80cd44d7048..b90c5816635 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -4322,7 +4322,10 @@ def test_compile_releases_memo_naming_caches( 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 + from reflex_base.utils.deterministic_hash import ( + _hash_str_encodings, + clear_hash_caches, + ) app = App() From 70c75ebc0619e4c248926a2f92a30c268c12f560 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 06:54:11 +0000 Subject: [PATCH 12/18] docs: restore the reflex changelog fragment The PR touches reflex/app.py, so the root package needs a fragment too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c --- news/6947.performance.md | 1 + 1 file changed, 1 insertion(+) 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..cafde8a2db0 --- /dev/null +++ b/news/6947.performance.md @@ -0,0 +1 @@ +Clear auto-memoization naming caches after compiling app. From 0150ec5293fdb9b21f70580d66ca4c2fc5141a50 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 07:02:32 +0000 Subject: [PATCH 13/18] docs: note why the shared cached get_type_hints is not used here Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c --- .../reflex-base/src/reflex_base/utils/deterministic_hash.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py b/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py index 547cdfb75f0..98989123363 100644 --- a/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py +++ b/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py @@ -120,6 +120,12 @@ def _hash_dataclass_declares_keyed_fields(cls: type) -> bool: Whether every declared field type is drawn from ``_HASH_VALUE_KEYED_FIELD_TYPES``. """ + # Deliberately not the cached wrapper in ``reflex_base.utils.types``: an + # ``lru_cache`` stores results but not exceptions, and the raising path is + # the one that recurs here -- ``VarData`` and its like cost ~170us every + # call. Caching the verdict instead, failures included, is what keeps this + # to once per type; that cache also holds its classes weakly, which an + # ``lru_cache`` cannot. try: hints = get_type_hints(cls) except (NameError, TypeError): From 99dd019ec2c48353c48c10ac037427c341a26467 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 07:50:50 +0000 Subject: [PATCH 14/18] perf(compiler): hash import library names, not the ImportVars under them Walking the ImportVar lists was the largest single item in the memo-name encoding, and almost all of it was redundant. An import only reaches a memo body through the local name it binds, and every name a body references is already in its render, hooks or custom code: Reflex aliases each tag to a globally unique binding (Trigger -> RadixAccordionTrigger), and validate_imports rejects one name bound from two libraries. So bodies that render alike reference the same names, and the library names are what pin where each name comes from. Encoding drops 11-19% depending on the page (13.06 -> 11.55 ms on a memoization-heavy one). Distinct tag counts across three benchmark pages are unchanged -- 61, 9 and 2 -- so no page gains a collision from the narrower digest. What this deliberately stops distinguishing, documented on the function: two bodies binding the same name from the same library to a different export (X as N vs Y as N) or in a different form (default vs named). Both need one library to export two things a component aliases to one name. Generated memo module names change again, for the same reason as the rest of this branch: nothing outside the compiled output refers to them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c --- .../src/reflex_base/components/memo.py | 17 ++++++- tests/units/components/test_memo.py | 49 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 96ce65c3cfe..07ab16223b6 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -1845,6 +1845,19 @@ def _component_artifacts(component: Component, *, recursive: bool) -> Iterator[A the body has to be part of the hash too: imports, hooks, custom code, dynamic imports, and app-wrap components. + Imports contribute their library names only, not the ``ImportVar`` entries + under them, which is where most of the hashing used to go. An import matters to a + body only through the local name it binds, and every name a body references + is already in its render, hooks or custom code -- Reflex aliases each tag to + a globally unique binding (``Trigger`` becomes ``RadixAccordionTrigger``), + and :func:`~reflex.compiler.utils.validate_imports` rejects one name bound + from two libraries. So identical bodies reference identical names, and the + library names pin where each one comes from. What this deliberately stops + distinguishing: two bodies that bind the *same* name from the *same* library + to a different export (``X as N`` versus ``Y as N``) or in a different form + (default versus named). Both would need one library to export two things a + component aliases to one name. + Args: component: The component whose memo body is being hashed. recursive: Whether descendants' artifacts belong to this memo body. @@ -1863,7 +1876,7 @@ def _component_artifacts(component: Component, *, recursive: bool) -> Iterator[A cls = type(component) yield f"{cls.__module__}.{cls.__qualname__}" if recursive: - yield component._get_all_imports() + yield sorted(component._get_all_imports()) yield component._get_all_hooks_internal() yield component._get_all_hooks() yield component._get_all_custom_code() @@ -1871,7 +1884,7 @@ def _component_artifacts(component: Component, *, recursive: bool) -> Iterator[A yield sorted(component._get_all_dynamic_imports()) yield component._get_all_app_wrap_components() else: - yield component._get_imports() + yield sorted(component._get_imports()) yield component._get_hooks_internal() yield component._get_hooks() yield component._get_added_hooks() diff --git a/tests/units/components/test_memo.py b/tests/units/components/test_memo.py index b061fb8d7f9..dcc4de5b956 100644 --- a/tests/units/components/test_memo.py +++ b/tests/units/components/test_memo.py @@ -2089,6 +2089,55 @@ def test_component_hash_covers_dataclass_inheriting_app_wrap_components(): assert memo_tag(a) != memo_tag(b) +class _ImportLibraryProbe(Component): + """One class whose import library varies with a prop ``_render`` drops.""" + + library = "import-library-probe" + tag = "Probe" + + marker: Var[str] + + def _get_imports(self): + """Import the same binding from a marker-dependent library. + + Returns: + The imports. + """ + return { + **super()._get_imports(), + f"probe-lib-{self.marker!s}": [ImportVar(tag="Thing")], + } + + def _render(self, props: dict[str, Any] | None = None): + """Render without the marker prop so only the imports differ. + + Args: + props: The props to render. + + Returns: + The rendered tag. + """ + return super()._render(props).remove_props("marker") + + +def test_component_hash_covers_import_libraries(): + """The libraries a memo body imports from must reach the hash. + + The hash carries library names rather than the ``ImportVar`` entries, + on the grounds that every binding a body references shows up in its render. + Which libraries those bindings come from still has to be part of the digest: + two bodies importing one name from different libraries compile to different + modules, and sharing a tag would give one of them the other's import. + """ + a = _ImportLibraryProbe.create(marker="alpha") + b = _ImportLibraryProbe.create(marker="beta") + + assert a.render() == b.render() + assert component_hash(a, recursive=False) != component_hash(b, recursive=False) + assert component_hash(a, recursive=True) != component_hash(b, recursive=True) + assert memo_tag(a) != memo_tag(b) + + class _CustomCodeProbe(Component): """A component whose only per-instance artifact is its custom code.""" From be7d2c256edaaed86e433a56b4cb010a5c3f57c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 08:29:36 +0000 Subject: [PATCH 15/18] fix(compiler): encode dataclass and enum identity; trim comments Two injectivity holes in the encoder, both raised in review and both confirmed: - Two dataclasses with the same field names and values encoded identically, so Alpha(a="x") and Beta(a="x") shared a digest. The defining class now goes into the cached layout header, which is built once per type. - enum members encoded as str(value), which for an IntEnum is just its integer, so Level.ONE and 1 shared a digest. Enums now get their own tag and encode their qualified member name. Both were present before this branch; the encoder claims injectivity, so they belong with the rest of the collision fixes. The parametrized case that claimed to cover distinct dataclass types compared two instances of one type and could not have caught the first; it now uses two types. The synthesized-dataclass test asserted a runtime-built class hashed equal to the class it copied its fields from, which only held while class identity was absent from the digest. Also trims the narrative from comments and docstrings across the branch, leaving what the code needs and moving the reasoning to the PR. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c --- packages/reflex-base/news/6947.bugfix.md | 1 + packages/reflex-base/news/6947.performance.md | 2 +- .../src/reflex_base/components/memo.py | 41 ++--- .../reflex_base/utils/deterministic_hash.py | 148 ++++++++---------- .../utils/test_deterministic_hash.py | 48 +++++- 5 files changed, 131 insertions(+), 109 deletions(-) diff --git a/packages/reflex-base/news/6947.bugfix.md b/packages/reflex-base/news/6947.bugfix.md index 3296e1294ca..aa0cacd15ea 100644 --- a/packages/reflex-base/news/6947.bugfix.md +++ b/packages/reflex-base/news/6947.bugfix.md @@ -4,3 +4,4 @@ Auto-memoized components that render identically no longer share a generated mem - 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()` diff --git a/packages/reflex-base/news/6947.performance.md b/packages/reflex-base/news/6947.performance.md index dcfb49d3006..5341e1c358a 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. Generated memo module names change as a result; nothing outside the compiled output refers to them. +Component content hashing, which auto-memoization runs for every memoized component during a compile, encodes about 1.5x faster on large pages. Generated memo module names change as a result; nothing outside the compiled output refers to them. diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 07ab16223b6..1bf5985822b 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -1845,18 +1845,11 @@ def _component_artifacts(component: Component, *, recursive: bool) -> Iterator[A the body has to be part of the hash too: imports, hooks, custom code, dynamic imports, and app-wrap components. - Imports contribute their library names only, not the ``ImportVar`` entries - under them, which is where most of the hashing used to go. An import matters to a - body only through the local name it binds, and every name a body references - is already in its render, hooks or custom code -- Reflex aliases each tag to - a globally unique binding (``Trigger`` becomes ``RadixAccordionTrigger``), - and :func:`~reflex.compiler.utils.validate_imports` rejects one name bound - from two libraries. So identical bodies reference identical names, and the - library names pin where each one comes from. What this deliberately stops - distinguishing: two bodies that bind the *same* name from the *same* library - to a different export (``X as N`` versus ``Y as N``) or in a different form - (default versus named). Both would need one library to export two things a - component aliases to one name. + Imports contribute their library names only. A body reaches an import + through the local name it binds, and any name it references is already in + its render, hooks or custom code, so the library names are what remain to + pin down. This does not separate two bodies that bind the same name from + the same library to a different export or in a different form. Args: component: The component whose memo body is being hashed. @@ -1868,11 +1861,8 @@ def _component_artifacts(component: Component, *, recursive: bool) -> Iterator[A Yields: Each artifact, in a fixed order. """ - # 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. + # 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: @@ -1889,10 +1879,8 @@ def _component_artifacts(component: Component, *, recursive: bool) -> Iterator[A 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. It used not to, - # so two passthrough bodies differing only in ``add_custom_code`` output - # collided on a tag. + # ``_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() @@ -1918,12 +1906,11 @@ def component_hash(component: Component, *, recursive: bool) -> str: 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). + 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. diff --git a/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py b/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py index 98989123363..8512ec311bd 100644 --- a/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py +++ b/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py @@ -1,19 +1,12 @@ """A stable content hash over components, vars and the data they render to. -:func:`deterministic_hash` digests a value under a self-delimiting, type-tagged +:func:`deterministic_hash` digests values under a self-delimiting, type-tagged encoding: every type writes a distinct tag and a length-prefixed payload, so -the encoding is injective and two values that differ anywhere digest -differently. Unlike :func:`hash`, it is stable across processes, which is what -lets a digest name a generated file. - -Values are encoded into a ``bytearray`` handed to the hasher in large chunks -rather than one update per node. Encoders are resolved once per type, and the -encodings of values that recur across a whole run -- short strings and frozen -dataclasses -- are cached until :func:`clear_hash_caches` drops them. - -Auto-memoization is the caller today: it names every wrapper it generates after -a digest of what the generated module will contain, so a collision silently -drops one of the two bodies from the compiled output. +the encoding is injective. Unlike :func:`hash` it is stable across processes, +which is what lets a digest name a generated file. + +Encoders are resolved once per type, and the encodings of recurring values are +cached until :func:`clear_hash_caches` drops them. """ from __future__ import annotations @@ -36,34 +29,26 @@ _HASH_MAX_CACHED_DATACLASS = 512 _HASH_MAX_CACHE_ENTRIES = 4096 -# The declared field types a frozen dataclass may have and still be safe to key -# an encoding cache on by value: equality between any two values drawn from -# them implies an identical encoding. Numbers are deliberately absent -- -# ``True == 1 == 1.0`` holds and all three hash alike, yet each encodes -# differently, so a lookup could hand back another value's bytes. +# Field types for which ``==`` implies an identical encoding, so a frozen +# dataclass of them can key an encoding cache by value. Numbers are excluded: +# ``True == 1 == 1.0`` compare equal and hash alike but encode differently. _HASH_VALUE_KEYED_FIELD_TYPES = frozenset({str, bool, type(None)}) -# An encoder appends one value's encoding to a buffer. It takes the hasher its -# buffer is flushed into so it can hand it to nested encodings. +# Appends one value's encoding to a buffer, taking the hasher that buffer is +# flushed into so it can pass it down to nested encodings. _HashEncoder = Callable[[Any, bytearray, Any], None] -# Encoded forms of the values that recur across every component hashed during a -# compile: short strings (dict keys, tags, module paths) and frozen dataclasses -# (overwhelmingly ``ImportVar``), which make up the bulk of what a component -# hash feeds in. All four caches 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. +# Dropped together by :func:`clear_hash_caches`. The two value caches stop +# admitting entries at ``_HASH_MAX_CACHE_ENTRIES``, so recurring values get in +# first and a run of one-off values cannot grow them without bound. _hash_str_encodings: dict[str, bytes] = {} _hash_dataclass_encodings: dict[Any, bytes] = {} _hash_dataclass_layouts: dict[type, tuple[bytes, tuple[tuple[bytes, str], ...]]] = {} _hash_encoders: dict[type, _HashEncoder] = {} -# Whether a frozen dataclass type's declared fields make it safe to key an -# encoding cache on by value. Reading a class's annotations costs far more than -# anything else here does per type, so unlike the caches above this one outlives -# a compile -- weakly, so a dataclass defined inside a function body is still -# collected with the frame that made it. +# Whether a frozen dataclass type is safe to key by value. Resolving a class's +# annotations is expensive enough that this outlives a single run, and weak keys +# keep a dataclass defined in a function body collectable. _hash_value_keyed_types: WeakKeyDictionary[type, bool] = WeakKeyDictionary() @@ -74,13 +59,18 @@ def _hash_dataclass_layout(cls: type) -> tuple[bytes, tuple[tuple[bytes, str], . cls: The dataclass type to describe. Returns: - The type tag plus field count, and each field's encoded and plain name. + The type tag, field count and defining class, 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] + # The defining class is part of the header: two dataclasses with the + # same field names and values are different values. layout = ( - b"D" + len(fields).to_bytes(8, "little"), + b"D" + + len(fields).to_bytes(8, "little") + + _encode_str_for_hash(f"{cls.__module__}.{cls.__qualname__}"), tuple((field.name.encode(), field.name) for field in fields), ) _hash_dataclass_layouts[cls] = layout @@ -90,11 +80,10 @@ def _hash_dataclass_layout(cls: type) -> tuple[bytes, tuple[tuple[bytes, str], . def _hash_dataclass_is_value_keyed(cls: type) -> bool: """Check whether instances of ``cls`` can key an encoding cache by value. - Two instances that compare equal have to encode alike, or a cache hit would - hand back the wrong bytes. That holds when every declared field type is - ``str``, ``bool`` or ``None``, and stops holding as soon as a number is in - play -- a field typed ``int | bool`` can hold ``1`` and ``True``, which are - equal, hash alike, and encode differently. + True when every declared field type is in + ``_HASH_VALUE_KEYED_FIELD_TYPES``, which is what makes ``==`` imply an + identical encoding. A field typed ``int | bool`` fails: ``1`` and ``True`` + compare equal and hash alike but encode differently. Args: cls: The frozen dataclass type to check. @@ -120,18 +109,12 @@ def _hash_dataclass_declares_keyed_fields(cls: type) -> bool: Whether every declared field type is drawn from ``_HASH_VALUE_KEYED_FIELD_TYPES``. """ - # Deliberately not the cached wrapper in ``reflex_base.utils.types``: an - # ``lru_cache`` stores results but not exceptions, and the raising path is - # the one that recurs here -- ``VarData`` and its like cost ~170us every - # call. Caching the verdict instead, failures included, is what keeps this - # to once per type; that cache also holds its classes weakly, which an - # ``lru_cache`` cannot. + # Not the cached wrapper in ``reflex_base.utils.types``: its ``lru_cache`` + # stores results but not exceptions, and holds its keys strongly. try: hints = get_type_hints(cls) except (NameError, TypeError): - # A class whose annotations name types that aren't resolvable at runtime - # (a class local to a function, a TYPE_CHECKING-only import) states no - # contract we can read, so it doesn't get cached. + # Annotations that don't resolve at runtime state no contract to read. return False for _, name in _hash_dataclass_layout(cls)[1]: hint = hints.get(name) @@ -154,8 +137,8 @@ def _encode_str_for_hash(value: str) -> bytes: return b"s" + len(encoded).to_bytes(8, "little") + encoded -def _encode_hash_number(value: float | enum.Enum, out: bytearray, hasher: Any) -> None: - """Append a number's or enum member's encoding to ``out``. +def _encode_hash_number(value: float, out: bytearray, hasher: Any) -> None: + """Append a number's encoding to ``out``. Args: value: The value to encode. @@ -166,6 +149,22 @@ def _encode_hash_number(value: float | enum.Enum, out: bytearray, hasher: Any) - out += str(value).encode() +def _encode_hash_enum(value: enum.Enum, out: bytearray, hasher: Any) -> None: + """Append an enum member's encoding to ``out``. + + Encodes the member's identity rather than ``str(value)``, which for an + ``IntEnum`` is just its integer. + + Args: + value: The enum member to encode. + out: The buffer to append the encoding to. + hasher: Unused; kept for the shared encoder signature. + """ + cls = type(value) + out += b"e" + out += _encode_str_for_hash(f"{cls.__module__}.{cls.__qualname__}.{value.name}") + + def _encode_hash_str(value: str, out: bytearray, hasher: Any) -> None: """Append a ``str`` subclass instance's encoding to ``out``. @@ -281,10 +280,6 @@ def _encode_hash_dataclass_type(value: type, out: bytearray, hasher: Any) -> Non def _encode_hash_cached_dataclass(value: Any, out: bytearray, hasher: Any) -> None: """Append a frozen dataclass instance's encoding to ``out``, caching it. - A compile builds a fresh ``ImportVar`` per component per import, so the same - handful of values is encoded thousands of times: one page hashed 1182 of - them across 22 distinct values. - Args: value: The frozen dataclass instance to encode. out: The buffer to append the encoding to. @@ -307,10 +302,9 @@ def _encode_hash_cached_dataclass(value: Any, out: bytearray, hasher: Any) -> No def _resolve_hash_encoder(value: Any) -> _HashEncoder: """Pick the encoder for a value whose exact type has no fast path. - Called once per type, since :func:`_encode_deterministic` memoizes what this - returns -- so subclasses of the fast-path types (notably ``str``-based - enums, which must encode as enums rather than as strings), vars, components - and dataclasses each walk this ladder once instead of once per value. + Called once per type: :func:`_encode_deterministic` memoizes the result. + Branch order matters where a value matches several -- a ``str``-based enum + must encode as an enum, not as a string. Args: value: A value of the type to resolve an encoder for. @@ -329,7 +323,9 @@ def _resolve_hash_encoder(value: Any) -> _HashEncoder: # ``bool`` cannot be subclassed, so every bool is caught by the exact-type # fast path and none arrives here to be mistaken for a number. - if isinstance(value, (int, float, enum.Enum)): + if isinstance(value, enum.Enum): + return _encode_hash_enum + if isinstance(value, (int, float)): return _encode_hash_number if isinstance(value, str): return _encode_hash_str @@ -339,18 +335,16 @@ def _resolve_hash_encoder(value: Any) -> _HashEncoder: return _encode_hash_sequence if isinstance(value, Var): return _encode_hash_var + # Ahead of the dataclass branch: a component that also inherits a dataclass + # (anything built on ``MarkdownComponentMap``) must encode as a component, + # not as that mixin's field list. if isinstance(value, BaseComponent): - # Ahead of the dataclass branch: components that also inherit a - # dataclass -- every one built on ``MarkdownComponentMap``, so ``rx.text`` - # and friends -- would otherwise encode as that mixin's field list, - # which is empty, collapsing all of them to the same nine bytes. return _encode_hash_component if dataclasses.is_dataclass(value): if isinstance(value, type): return _encode_hash_dataclass_type - # ``is_dataclass`` only tests for ``__dataclass_fields__``, which classes - # synthesized at runtime (``MutableProxy``) copy over without the - # decorator's params -- and an unfrozen instance is unhashable anyway. + # Classes synthesized at runtime (``MutableProxy``) copy + # ``__dataclass_fields__`` without the decorator's params. params = getattr(type(value), "__dataclass_params__", None) if ( params is not None @@ -369,12 +363,11 @@ def _resolve_hash_encoder(value: Any) -> _HashEncoder: 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) skip the ``isinstance`` ladder in :func:`_resolve_hash_encoder`, - which every other type walks once and then reaches through a memoized - per-type encoder. ``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. + Dispatch is on the exact type, so the common leaves skip the ``isinstance`` + ladder in :func:`_resolve_hash_encoder`; every other type walks it once and + is then memoized in ``_hash_encoders``. ``out`` is flushed into ``hasher`` + at container boundaries once it passes ``_HASH_BUFFER_FLUSH_SIZE``, so a + large subtree is never buffered whole. Args: value: The value to encode. @@ -423,8 +416,8 @@ def _encode_deterministic(value: Any, out: bytearray, hasher: Any | None) -> Non def deterministic_hash(*values: object) -> str: """Fold values into a single digest, in the order given. - Every value shares one buffer, so a hash covering many values pays a - handful of hasher updates rather than one per value. + All values share one buffer, so a digest over many values costs a handful + of hasher updates rather than one per value. Args: *values: The values to hash. @@ -446,11 +439,8 @@ def deterministic_hash(*values: object) -> str: def clear_hash_caches() -> None: """Drop the encoding caches. - Everything 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, pinned by both the - layout cache and the encoder table. + Both the layout cache and the encoder table key on types, so leaving them + populated pins every dataclass type they have seen. """ _hash_str_encodings.clear() _hash_dataclass_encodings.clear() diff --git a/tests/units/reflex_base/utils/test_deterministic_hash.py b/tests/units/reflex_base/utils/test_deterministic_hash.py index 7a6c4296583..fa5052e76b6 100644 --- a/tests/units/reflex_base/utils/test_deterministic_hash.py +++ b/tests/units/reflex_base/utils/test_deterministic_hash.py @@ -3,6 +3,7 @@ from __future__ import annotations import dataclasses +import enum from typing import Any import pytest @@ -24,6 +25,38 @@ import reflex as rx +class _HashLevel(enum.IntEnum): + """An IntEnum, whose ``str()`` is just its integer.""" + + ONE = 1 + + +class _HashColor(enum.Enum): + """An enum sharing a member name with ``_HashShade``.""" + + RED = "red" + + +class _HashShade(enum.Enum): + """A second enum with the same member name and value.""" + + RED = "red" + + +@dataclasses.dataclass(frozen=True) +class _ShapeAlpha: + """One of two dataclasses with identical field names and types.""" + + a: str + + +@dataclasses.dataclass(frozen=True) +class _ShapeBeta: + """The other; a distinct type holding the same values.""" + + a: str + + @pytest.fixture def clean_hash_caches(): """Isolate a test from the module-level memo-naming caches. @@ -66,8 +99,15 @@ def test_deterministic_hash_is_stable(): ([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. + # Same dataclass type, different field values. (ImportVar(tag="a"), ImportVar(tag="a", alias="a")), + # Different dataclass types with the same field names and values. + (_ShapeAlpha(a="x"), _ShapeBeta(a="x")), + # An IntEnum member and the int it equals. + (_HashLevel.ONE, 1), + # Same member name on two enums, and a member against its own value. + (_HashColor.RED, _HashShade.RED), + (_HashColor.RED, _HashColor.RED.value), ], ) def test_deterministic_hash_distinguishes(left: Any, right: Any): @@ -254,7 +294,11 @@ def test_deterministic_hash_handles_dataclasses_without_params(): }, ) - assert deterministic_hash(synthesized()) == deterministic_hash( + # Hashes without reaching for the missing ``frozen`` flag, and stably. + assert deterministic_hash(synthesized()) == deterministic_hash(synthesized()) + # A distinct class, so a distinct digest from the one it copied its fields + # from, even holding the same values. + assert deterministic_hash(synthesized()) != deterministic_hash( _KeyedProbe(name="probe") ) From d0415b3d77825b08e6becf4f8c111912fe5f56be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 08:54:53 +0000 Subject: [PATCH 16/18] test: cover values larger than the hash flush buffer Nothing exercised a single leaf bigger than _HASH_BUFFER_FLUSH_SIZE. A leaf is appended whole, so the buffer holds all of it before the first flush can run: a LiteralStringVar of 3x the flush size peaks at 196,621 B against a 65,536 B threshold. Behaviour is correct, it just had no test. Adds that case, plus a parametrized check that the digest is identical for flush sizes from 1 byte to 1 GiB, which pins the invariant that chunking only moves bytes into the hasher. The second one fails if a flush stops clearing the buffer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c --- .../utils/test_deterministic_hash.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/units/reflex_base/utils/test_deterministic_hash.py b/tests/units/reflex_base/utils/test_deterministic_hash.py index fa5052e76b6..f979b9f7430 100644 --- a/tests/units/reflex_base/utils/test_deterministic_hash.py +++ b/tests/units/reflex_base/utils/test_deterministic_hash.py @@ -8,7 +8,9 @@ import pytest from reflex_base.constants import Hooks +from reflex_base.utils import deterministic_hash as deterministic_hash_module from reflex_base.utils.deterministic_hash import ( + _HASH_BUFFER_FLUSH_SIZE, _HASH_MAX_CACHE_ENTRIES, _HASH_MAX_CACHED_DATACLASS, _hash_dataclass_encodings, @@ -164,6 +166,51 @@ def test_deterministic_hash_flushes_large_payloads(): assert deterministic_hash(payload) != deterministic_hash(mutated) +def test_deterministic_hash_var_larger_than_the_flush_buffer(): + """A Var whose payload passes the flush size hashes correctly. + + A single leaf is appended whole, so the buffer holds it in full before the + first flush can run -- the one value the flush size cannot bound. + """ + size = _HASH_BUFFER_FLUSH_SIZE * 3 + big = rx.Var.create("x" * size) + same = rx.Var.create("x" * size) + differs_in_last_char = rx.Var.create("x" * (size - 1) + "y") + + assert len(big._js_expr) > _HASH_BUFFER_FLUSH_SIZE + assert deterministic_hash(big) == deterministic_hash(same) + assert deterministic_hash(big) != deterministic_hash(differs_in_last_char) + # Nested, where the enclosing container flushes between items. + assert deterministic_hash([big, big]) != deterministic_hash([ + big, + differs_in_last_char, + ]) + + +@pytest.mark.parametrize("flush_size", [1, 64, _HASH_BUFFER_FLUSH_SIZE, 1 << 30]) +def test_deterministic_hash_is_independent_of_the_flush_size( + monkeypatch: pytest.MonkeyPatch, flush_size: int +): + """Where the buffer is handed to the hasher must not change the digest. + + Flushing only moves bytes from the buffer into the hasher, so every flush + size has to agree -- including one that flushes after every item and one + that never flushes at all. + """ + payload = { + "var": rx.Var.create("x" * (_HASH_BUFFER_FLUSH_SIZE * 2)), + "items": [f"item_{i}" for i in range(500)], + "nested": {"deep": [{"k": "v" * 300} for _ in range(50)]}, + } + expected = deterministic_hash(payload) + + monkeypatch.setattr( + deterministic_hash_module, "_HASH_BUFFER_FLUSH_SIZE", flush_size + ) + clear_hash_caches() + assert deterministic_hash(payload) == expected + + 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( From 66a360f353a572a238ce3db1047a7b3eb8d4ee63 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 18:09:20 +0000 Subject: [PATCH 17/18] fix(compiler): hash ImportVars again; don't crash on an unhashable field Two review findings, both reproduced. Hashing only import library names dropped ImportVar payloads from the digest. Icon._get_imports builds a per-instance package_path and alias, and a tagless ImportVar defaults to render=True so compile_imports emits it as a side-effect import ("lucide-react/light.css"). Two bodies differing only there rendered identically, shared a memo tag, and one body's import never reached the compiled module. Restores the imports dict; the narrowing was measured again at ~4% of encoding, not the 23% an earlier harness suggested. _encode_hash_cached_dataclass keyed the value cache on instances whose hashability comes from their declared field types, so a frozen dataclass declaring `name: str` while holding a list raised TypeError where the previous hasher returned a digest. It now falls back to encoding the fields directly. Also corrects the module docstring: numbers carry a type tag but no length prefix, so the previous wording overstated the encoding. Re-measured against main: encoding 1.31x on a memoization-heavy page and 1.17x on _stateful_page, with the whole component_hash at parity. Fragment updated. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c --- packages/reflex-base/news/6947.performance.md | 2 +- .../src/reflex_base/components/memo.py | 9 +-- .../reflex_base/utils/deterministic_hash.py | 15 +++-- tests/units/components/test_memo.py | 59 ++++++++++++++++--- .../utils/test_deterministic_hash.py | 20 +++++++ 5 files changed, 85 insertions(+), 20 deletions(-) diff --git a/packages/reflex-base/news/6947.performance.md b/packages/reflex-base/news/6947.performance.md index 5341e1c358a..d402efcb557 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, encodes about 1.5x faster on large pages. Generated memo module names change as a result; nothing outside the compiled output refers to them. +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. diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 1bf5985822b..a1a9d2aaf8b 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -1845,11 +1845,6 @@ def _component_artifacts(component: Component, *, recursive: bool) -> Iterator[A the body has to be part of the hash too: imports, hooks, custom code, dynamic imports, and app-wrap components. - Imports contribute their library names only. A body reaches an import - through the local name it binds, and any name it references is already in - its render, hooks or custom code, so the library names are what remain to - pin down. This does not separate two bodies that bind the same name from - the same library to a different export or in a different form. Args: component: The component whose memo body is being hashed. @@ -1866,7 +1861,7 @@ def _component_artifacts(component: Component, *, recursive: bool) -> Iterator[A cls = type(component) yield f"{cls.__module__}.{cls.__qualname__}" if recursive: - yield sorted(component._get_all_imports()) + yield component._get_all_imports() yield component._get_all_hooks_internal() yield component._get_all_hooks() yield component._get_all_custom_code() @@ -1874,7 +1869,7 @@ def _component_artifacts(component: Component, *, recursive: bool) -> Iterator[A yield sorted(component._get_all_dynamic_imports()) yield component._get_all_app_wrap_components() else: - yield sorted(component._get_imports()) + yield component._get_imports() yield component._get_hooks_internal() yield component._get_hooks() yield component._get_added_hooks() diff --git a/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py b/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py index 8512ec311bd..39611c0de8d 100644 --- a/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py +++ b/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py @@ -1,9 +1,10 @@ """A stable content hash over components, vars and the data they render to. :func:`deterministic_hash` digests values under a self-delimiting, type-tagged -encoding: every type writes a distinct tag and a length-prefixed payload, so -the encoding is injective. Unlike :func:`hash` it is stable across processes, -which is what lets a digest name a generated file. +encoding: every value writes a distinct type tag ahead of its payload, and +strings and containers carry an explicit length, so the encoding is injective. +Unlike :func:`hash` it is stable across processes, which is what lets a digest +name a generated file. Encoders are resolved once per type, and the encodings of recurring values are cached until :func:`clear_hash_caches` drops them. @@ -286,7 +287,13 @@ def _encode_hash_cached_dataclass(value: Any, out: bytearray, hasher: Any) -> No hasher: Unused; the fields are encoded into a private buffer that must not be drained, since the caller needs its full contents. """ - encoded = _hash_dataclass_encodings.get(value) + try: + encoded = _hash_dataclass_encodings.get(value) + except TypeError: + # A field holds something its annotation does not admit, leaving the + # instance unhashable and so unusable as a cache key. + _encode_hash_dataclass_fields(type(value), value, out, hasher) + return if encoded is None: buffer = bytearray() _encode_hash_dataclass_fields(type(value), value, buffer, None) diff --git a/tests/units/components/test_memo.py b/tests/units/components/test_memo.py index dcc4de5b956..457f12e3744 100644 --- a/tests/units/components/test_memo.py +++ b/tests/units/components/test_memo.py @@ -2121,14 +2121,7 @@ def _render(self, props: dict[str, Any] | None = None): def test_component_hash_covers_import_libraries(): - """The libraries a memo body imports from must reach the hash. - - The hash carries library names rather than the ``ImportVar`` entries, - on the grounds that every binding a body references shows up in its render. - Which libraries those bindings come from still has to be part of the digest: - two bodies importing one name from different libraries compile to different - modules, and sharing a tag would give one of them the other's import. - """ + """The libraries a memo body imports from must reach the hash.""" a = _ImportLibraryProbe.create(marker="alpha") b = _ImportLibraryProbe.create(marker="beta") @@ -2138,6 +2131,56 @@ def test_component_hash_covers_import_libraries(): assert memo_tag(a) != memo_tag(b) +class _ImportPayloadProbe(Component): + """One class, one library, an ``ImportVar`` payload that varies by prop.""" + + library = "import-payload-probe" + tag = "Probe" + + marker: Var[str] + + def _get_imports(self): + """Import a side-effect stylesheet whose path varies with the marker. + + Returns: + The imports. + """ + return { + **super()._get_imports(), + "payload-lib": [ImportVar(tag=None, package_path=f"/{self.marker!s}.css")], + } + + def _render(self, props: dict[str, Any] | None = None): + """Render without the marker prop so only the imports differ. + + Args: + props: The props to render. + + Returns: + The rendered tag. + """ + return super()._render(props).remove_props("marker") + + +def test_component_hash_covers_import_var_payloads(): + """``ImportVar`` fields must reach the hash, not just the library name. + + ``package_path`` and ``alias`` vary per instance in shipping components + (``Icon._get_imports`` builds both), and a tagless ``ImportVar`` defaults to + ``render=True``, so it emits as a side-effect import. Two such bodies render + identically under one library key; sharing a tag drops one body's import + from the compiled module with no error. + """ + a = _ImportPayloadProbe.create(marker="light") + b = _ImportPayloadProbe.create(marker="dark") + + assert a.render() == b.render() + assert sorted(dict(a._get_all_imports())) == sorted(dict(b._get_all_imports())) + assert component_hash(a, recursive=False) != component_hash(b, recursive=False) + assert component_hash(a, recursive=True) != component_hash(b, recursive=True) + assert memo_tag(a) != memo_tag(b) + + class _CustomCodeProbe(Component): """A component whose only per-instance artifact is its custom code.""" diff --git a/tests/units/reflex_base/utils/test_deterministic_hash.py b/tests/units/reflex_base/utils/test_deterministic_hash.py index f979b9f7430..a945fd2158f 100644 --- a/tests/units/reflex_base/utils/test_deterministic_hash.py +++ b/tests/units/reflex_base/utils/test_deterministic_hash.py @@ -309,6 +309,26 @@ def test_deterministic_hash_does_not_cache_numeric_frozen_dataclasses( assert not _hash_dataclass_encodings +def test_deterministic_hash_dataclass_field_contradicting_its_annotation(): + """A field holding something its annotation does not admit must still hash. + + The value-keyed cache is gated on declared field types, so an instance that + contradicts them reaches the cache lookup and is not hashable as a key. + """ + probe = _KeyedProbe(name=["not", "a", "str"]) # pyright: ignore [reportArgumentType] + + assert deterministic_hash(probe) == deterministic_hash( + _KeyedProbe(name=["not", "a", "str"]) # pyright: ignore [reportArgumentType] + ) + assert deterministic_hash(probe) != deterministic_hash( + _KeyedProbe(name=["other"]) # pyright: ignore [reportArgumentType] + ) + # The well-typed instances alongside it still take the cached path. + assert deterministic_hash(_KeyedProbe(name="probe")) == deterministic_hash( + _KeyedProbe(name="probe") + ) + + def test_deterministic_hash_tracks_dataclasses_that_can_still_change(): """Dataclasses whose contents can change must be re-encoded every time.""" mutable = _MutableProbe(value="before") From 0d12a44e8e353934792485907d3268ce9faa4e3b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 19:01:37 +0000 Subject: [PATCH 18/18] test: isolate the annotation-mismatch test from the shared caches Its well-typed _KeyedProbe instances take the cached-dataclass path, so it left entries in _hash_dataclass_encodings for whatever ran next. Every other cache-touching test in the file already takes clean_hash_caches; this one now does too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c --- tests/units/reflex_base/utils/test_deterministic_hash.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/units/reflex_base/utils/test_deterministic_hash.py b/tests/units/reflex_base/utils/test_deterministic_hash.py index a945fd2158f..44f9d5defca 100644 --- a/tests/units/reflex_base/utils/test_deterministic_hash.py +++ b/tests/units/reflex_base/utils/test_deterministic_hash.py @@ -309,7 +309,9 @@ def test_deterministic_hash_does_not_cache_numeric_frozen_dataclasses( assert not _hash_dataclass_encodings -def test_deterministic_hash_dataclass_field_contradicting_its_annotation(): +def test_deterministic_hash_dataclass_field_contradicting_its_annotation( + clean_hash_caches: None, +): """A field holding something its annotation does not admit must still hash. The value-keyed cache is gated on declared field types, so an instance that