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. diff --git a/packages/reflex-base/news/6947.bugfix.md b/packages/reflex-base/news/6947.bugfix.md new file mode 100644 index 00000000000..aa0cacd15ea --- /dev/null +++ b/packages/reflex-base/news/6947.bugfix.md @@ -0,0 +1,7 @@ +Auto-memoized components that render identically no longer share a generated memo name, which silently dropped one of the two compiled bodies. The name now accounts for: + +- module-level code emitted by `add_custom_code` +- dynamic imports +- app-wrap components, including `rx.text` and the other `MarkdownComponentMap` components that all hashed alike +- the defining module, so same-named components from different modules stay apart +- the identity of dataclasses and enum members, which previously hashed by shape and by `str()` diff --git a/packages/reflex-base/news/6947.performance.md b/packages/reflex-base/news/6947.performance.md new file mode 100644 index 00000000000..d402efcb557 --- /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, 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/component.py b/packages/reflex-base/src/reflex_base/components/component.py index b9e90d134e7..adf8d36fd0e 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 json import logging @@ -15,7 +14,6 @@ from abc import ABC, ABCMeta, abstractmethod from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from dataclasses import _MISSING_TYPE, MISSING -from hashlib import md5 from types import SimpleNamespace from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast @@ -610,88 +608,6 @@ def _components_from( return () -def _hash_str(value: str) -> str: - return md5(f'"{value}"'.encode(), usedforsecurity=False).hexdigest() - - -def _update_deterministic_hash(hasher: Any, value: object) -> None: - """Feed ``value`` into ``hasher`` using a self-delimiting, type-tagged encoding. - - Each branch writes a distinct type tag plus length-prefixed payload, which - keeps the encoding injective without building intermediate strings — the - nested ``str([...])`` approach this replaces was the dominant cost of - ``_deterministic_hash`` (~4x speedup on synthetic, ~2x on real renders). - - Args: - hasher: A ``hashlib`` hasher (must accept ``.update(bytes)``). - value: The value to fold into the hasher. - - Raises: - TypeError: If the value is not hashable. - """ - if value is None: - hasher.update(b"N") - elif isinstance(value, bool): - hasher.update(b"T" if value else b"F") - elif isinstance(value, (int, float, enum.Enum)): - hasher.update(b"n") - hasher.update(str(value).encode()) - elif isinstance(value, str): - encoded = value.encode() - hasher.update(b"s") - hasher.update(len(encoded).to_bytes(8, "little")) - hasher.update(encoded) - elif isinstance(value, dict): - items = sorted(value.items(), key=operator.itemgetter(0)) - hasher.update(b"d") - hasher.update(len(items).to_bytes(8, "little")) - for k, v in items: - _update_deterministic_hash(hasher, k) - _update_deterministic_hash(hasher, v) - elif isinstance(value, (tuple, list)): - hasher.update(b"l") - hasher.update(len(value).to_bytes(8, "little")) - for item in value: - _update_deterministic_hash(hasher, item) - elif isinstance(value, Var): - hasher.update(b"v") - _update_deterministic_hash(hasher, value._js_expr) - _update_deterministic_hash(hasher, value._get_all_var_data()) - elif dataclasses.is_dataclass(value): - fields = dataclasses.fields(value) - hasher.update(b"D") - hasher.update(len(fields).to_bytes(8, "little")) - for field in fields: - hasher.update(field.name.encode()) - _update_deterministic_hash(hasher, getattr(value, field.name)) - elif isinstance(value, BaseComponent): - hasher.update(b"C") - _update_deterministic_hash(hasher, value.render()) - else: - msg = ( - f"Cannot hash value `{value}` of type `{type(value).__name__}`. " - "Only BaseComponent, Var, VarData, dict, str, tuple, and enum.Enum are supported." - ) - raise TypeError(msg) - - -def _deterministic_hash(value: object) -> str: - """Hash a rendered dictionary. - - Args: - value: The dictionary to hash. - - Returns: - The hash of the dictionary. - - Raises: - TypeError: If the value is not hashable. - """ - hasher = md5(usedforsecurity=False) - _update_deterministic_hash(hasher, value) - return hasher.hexdigest() - - PROHIBITED_LIBRARY_IMPORTS: dict[str, str] = { "react-router-dom": ( "React Router 8 removed the `react-router-dom` package and Reflex no " @@ -1531,71 +1447,6 @@ def render(self) -> dict: self._cached_render_result = rendered_dict return rendered_dict - def _get_component_hash(self, shallow: bool = False) -> str: - """Get a stable content hash for this component. - - The hash incorporates the rendered JSX dict plus the component's - recursive imports, hooks (including internal lifecycle hooks), - custom code, and app-wrap components, so two components that - compile to semantically distinct JS modules hash differently - even when their ``render()`` output happens to match (e.g. two - components differing only in ``on_mount``, which is excluded - from ``_render`` props but lives in the lifecycle hook). - - Args: - shallow: If True, only hash the component's own render output and - directly defined hooks, imports, custom code, and app-wrap - components, excluding any of those from child components. - - Returns: - The hex digest content hash. - """ - hasher = md5(usedforsecurity=False) - _update_deterministic_hash(hasher, self.render()) - if shallow: - # For non-snapshot strategies, we only hash the component's own hooks, imports, custom code, and app-wrap components - _update_deterministic_hash(hasher, dict(self._get_imports())) - _update_deterministic_hash(hasher, dict(self._get_hooks_internal())) - _update_deterministic_hash(hasher, dict(self._get_added_hooks())) - _update_deterministic_hash(hasher, self._get_hooks()) - _update_deterministic_hash(hasher, self._get_custom_code()) - _update_deterministic_hash(hasher, dict(self._get_app_wrap_components())) - else: - _update_deterministic_hash(hasher, dict(self._get_all_imports())) - _update_deterministic_hash(hasher, dict(self._get_all_hooks_internal())) - _update_deterministic_hash(hasher, dict(self._get_all_hooks())) - _update_deterministic_hash(hasher, dict(self._get_all_custom_code())) - _update_deterministic_hash( - hasher, dict(self._get_all_app_wrap_components()) - ) - return hasher.hexdigest() - - def _compute_memo_tag(self) -> str: - """Compute a stable tag name for memoizing this component. - - The class qualname is encoded directly in the tag prefix so that - distinct classes which happen to render identically never collide - on a tag. Tag collision would silently share a single cached memo - wrapper across classes and drop the later class's class-level - metadata (e.g. ``_get_app_wrap_components``, which carries - providers like ``UploadFilesProvider`` that must reach the app - root). - - Returns: - The stable tag name. - """ - from reflex_base.components.memoize_helpers import ( - MemoizationStrategy, - get_memoization_strategy, - ) - - comp_hash = self._get_component_hash( - shallow=get_memoization_strategy(self) == MemoizationStrategy.PASSTHROUGH - ) - return format.format_state_name( - f"{type(self).__qualname__}_{self.tag or 'Comp'}_{comp_hash}" - ).capitalize() - def _replace_prop_names(self, rendered_dict: dict) -> None: """Replace the prop names in the render dictionary. diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 133afe39876..a1a9d2aaf8b 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -5,7 +5,7 @@ import dataclasses import inspect import sys -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Iterator, Mapping, Sequence from copy import copy from enum import Enum from functools import cache, partial, update_wrapper @@ -42,6 +42,7 @@ from reflex_base.event import EventChain, EventHandler, no_args_event_spec, run_script from reflex_base.registry import RegistrationContext from reflex_base.utils import console, format, memo_paths +from reflex_base.utils.deterministic_hash import deterministic_hash from reflex_base.utils.imports import ImportVar from reflex_base.utils.types import safe_issubclass, typehint_issubclass from reflex_base.vars import VarData @@ -1834,6 +1835,91 @@ def _create_component_wrapper( return _MemoComponentWrapper(definition) +def _component_artifacts(component: Component, *, recursive: bool) -> Iterator[Any]: + """Yield everything besides the render that identifies a memo body. + + Two components can render identical JSX and still compile to different + modules -- the classic case is a differing ``on_mount``, which ``_render`` + omits but which shows up as a lifecycle hook -- so everything else + :func:`~reflex.compiler.utils.compile_experimental_component_memo` puts in + the body has to be part of the hash too: imports, hooks, custom code, + dynamic imports, and app-wrap components. + + + Args: + component: The component whose memo body is being hashed. + recursive: Whether descendants' artifacts belong to this memo body. + False for a passthrough memo, whose descendants render at the call + site behind the ``{children}`` hole, so only the component's own + artifacts identify the body. + + Yields: + Each artifact, in a fixed order. + """ + # The tag prefix carries the qualname but not the module, and a dotted + # module path in the prefix would stretch every generated filename. + cls = type(component) + yield f"{cls.__module__}.{cls.__qualname__}" + if recursive: + yield component._get_all_imports() + yield component._get_all_hooks_internal() + yield component._get_all_hooks() + yield component._get_all_custom_code() + # A set: sort it so the encoding does not ride on iteration order. + yield sorted(component._get_all_dynamic_imports()) + yield component._get_all_app_wrap_components() + else: + yield component._get_imports() + yield component._get_hooks_internal() + yield component._get_hooks() + yield component._get_added_hooks() + yield component._get_custom_code() + # ``_get_all_custom_code`` folds in ``add_custom_code`` on the + # recursive side; the own-node side has to ask for it explicitly. + for clz in component._iter_parent_classes_with_method("add_custom_code"): + yield clz.add_custom_code(component) + yield component._get_dynamic_imports() + yield component._get_app_wrap_components() + + +def component_hash(component: Component, *, recursive: bool) -> str: + """Get a stable content hash for a component's memo body. + + Args: + component: The component being memoized. + recursive: Whether the memo body carries the component's whole subtree + (a snapshot memo) rather than a ``{children}`` hole. + + Returns: + The hex digest content hash. + """ + return deterministic_hash( + component.render(), *_component_artifacts(component, recursive=recursive) + ) + + +def memo_tag(component: Component) -> str: + """Compute a stable tag name for the memo wrapping ``component``. + + The class qualname is in the tag prefix so distinct classes that render + identically never share a tag. Sharing one would reuse a single cached memo + wrapper across classes and drop the later class's class-level metadata, + such as the ``_get_app_wrap_components`` providers that must reach the app + root. + + Args: + component: The component being memoized. + + Returns: + The stable tag name. + """ + recursive = get_memoization_strategy(component) is MemoizationStrategy.SNAPSHOT + return format.format_state_name( + f"{type(component).__qualname__}_{component.tag or 'Comp'}_" + f"{component_hash(component, recursive=recursive)}" + ).capitalize() + + def create_passthrough_component_memo( component: Component, source_module: str | None = None, @@ -1847,7 +1933,7 @@ def create_passthrough_component_memo( through the memo pipeline instead of emitting ad-hoc page-local ``React.memo`` declarations. - The exported memo name is derived from ``component._compute_memo_tag()`` + The exported memo name is derived from :func:`memo_tag` after the ``{children}`` hole has been substituted into the wrapped component's children (passthrough mode), so two call-sites differing only in their children — whose generated memo bodies are identical — collapse @@ -1918,7 +2004,7 @@ def passthrough(children: Var[Component]) -> Component: "normalizes to `rx.Component`." ) raise TypeError(msg) - tag = preview._compute_memo_tag() + tag = memo_tag(preview) passthrough.__name__ = format.to_snake_case(tag) passthrough.__qualname__ = passthrough.__name__ 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..39611c0de8d --- /dev/null +++ b/packages/reflex-base/src/reflex_base/utils/deterministic_hash.py @@ -0,0 +1,455 @@ +"""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 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. +""" + +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 + +# 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)}) + +# 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] + +# 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 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() + + +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, 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") + + _encode_str_for_hash(f"{cls.__module__}.{cls.__qualname__}"), + 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. + + 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. + + 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``. + """ + # 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): + # 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) + 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, out: bytearray, hasher: Any) -> None: + """Append a number'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_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``. + + 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. + + 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. + """ + 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) + 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: :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. + + 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, enum.Enum): + return _encode_hash_enum + if isinstance(value, (int, float)): + 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 + # 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): + return _encode_hash_component + if dataclasses.is_dataclass(value): + if isinstance(value, type): + return _encode_hash_dataclass_type + # 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 + 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 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. + 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. + + 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. + + 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. + + 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() + _hash_dataclass_layouts.clear() + _hash_encoders.clear() diff --git a/pyi_hashes.json b/pyi_hashes.json index 8936d040427..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": "12545ddd7d0a26acd9bbeba5c5be8d80" + "reflex/experimental/memo.pyi": "27a73a66e238746e5da5accf99a8fdfd" } diff --git a/reflex/app.py b/reflex/app.py index 96fe3151cfa..7f1283ae8cf 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1657,32 +1657,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.utils.deterministic_hash 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/components/test_memo.py b/tests/units/components/test_memo.py index 8b16612dbcf..457f12e3744 100644 --- a/tests/units/components/test_memo.py +++ b/tests/units/components/test_memo.py @@ -25,6 +25,8 @@ _LazyBody, _MemoCallBinding, _strip_optional, + component_hash, + memo_tag, ) from reflex_base.event import EventChain, EventHandler, no_args_event_spec from reflex_base.registry import RegistrationContext @@ -37,6 +39,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 @@ -2038,3 +2042,282 @@ 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) + + +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) + + +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.""" + 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 _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.""" + + 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 + ) + + +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.""" + + 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) 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..44f9d5defca --- /dev/null +++ b/tests/units/reflex_base/utils/test_deterministic_hash.py @@ -0,0 +1,458 @@ +"""Tests for the deterministic content hash.""" + +from __future__ import annotations + +import dataclasses +import enum +from typing import Any + +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, + _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 + + +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. + + 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), + # 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): + """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_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( + 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_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 + 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") + 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, + }, + ) + + # 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") + ) + + +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 aaebc74ca85..b90c5816635 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -4309,3 +4309,38 @@ 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.utils.deterministic_hash 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