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.bugfix.md b/packages/reflex-base/news/6947.bugfix.md new file mode 100644 index 00000000000..064045fbc4b --- /dev/null +++ b/packages/reflex-base/news/6947.bugfix.md @@ -0,0 +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/news/6947.performance.md b/packages/reflex-base/news/6947.performance.md new file mode 100644 index 00000000000..b7603182921 --- /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. 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 c94e0198c68..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,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() - - @dataclasses.dataclass(kw_only=True, frozen=True, slots=True) class TriggerDefinition: """A default event trigger with its args spec and description.""" @@ -1495,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..0822ea6b06d 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,333 @@ 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. 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], ...]]] = {} + + +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 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. + + 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() + # 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) + _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_dynamic_imports(), 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 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``. + + 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 +2101,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 +2172,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/reflex/app.py b/reflex/app.py index 63ec53a4c75..239f877f1b8 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1650,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/components/test_memo.py b/tests/units/components/test_memo.py index f1ecea1fc10..1964deedc4e 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 @@ -10,6 +11,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 +22,18 @@ MemoParam, 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, ) +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 +45,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 +1881,297 @@ 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) + + +@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 _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) + + +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_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 + ) 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