From e613009903e54231dcb74818953801d458e8dc88 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:58:10 +0000 Subject: [PATCH 1/6] Speed up runtime type checks and delta encoding with pydantic-core and orjson State var assignments and computed var reads validate their value against the declared type one level deep. That check walked every element in pure Python (`_isinstance`) on every event, and dominated event processing for large lists. `runtime_isinstance` compiles each type hint once into a pydantic-core validator with identical semantics (isinstance leaves, strict containers, unions, literals) and falls back to `_isinstance` for hints without an exact schema equivalent, Var values, and when pydantic-core is not installed. MutableProxy values are unwrapped first since pydantic-core's container checks do not honor the proxied `__class__`. State deltas sent over Socket.IO and streamed upload updates are now encoded by orjson through `format.json_dumps_compact`. Dataclasses and datetimes keep going through the reflex serializers so the output matches `json_dumps`, and a serializer registered for an Enum or UUID subclass switches the wire path back to the stdlib encoder so it is still honored. Integers beyond 64 bits also fall back. Non-finite floats now serialize as null instead of the invalid NaN/Infinity tokens. The dataclass serializer memoizes field names per class. Measured on the reflex-dev/templates dashboard app end to end through a Socket.IO client (median round trip, 10k-row items.csv): next_page 9.4 ms -> 2.7 ms, toggle_sort 134 ms -> 97 ms, overall 84 ms -> 68 ms per event. Synthetic 10k-item checks: list[int] validation 6.7 ms -> 0.15 ms; dataclass delta encoding 18 ms -> 5 ms; dict delta encoding 2.7 ms -> 0.3 ms. --- news/+runtime-type-validation.performance.md | 1 + .../+runtime-type-validation.performance.md | 1 + packages/reflex-base/pyproject.toml | 1 + .../src/reflex_base/utils/format.py | 67 ++++++- .../src/reflex_base/utils/serializers.py | 39 +++- .../src/reflex_base/utils/types.py | 182 ++++++++++++++++++ .../reflex-base/src/reflex_base/vars/base.py | 4 +- .../news/+upload-compact-json.performance.md | 1 + .../reflex_components_core/core/_upload.py | 4 +- reflex/app.py | 6 +- reflex/state.py | 6 +- tests/benchmarks/test_isinstance.py | 24 ++- tests/benchmarks/test_json_dumps.py | 48 +++++ tests/units/reflex_base/utils/test_types.py | 178 ++++++++++++++++- tests/units/utils/test_format.py | 108 ++++++++++- uv.lock | 20 +- 16 files changed, 659 insertions(+), 31 deletions(-) create mode 100644 news/+runtime-type-validation.performance.md create mode 100644 packages/reflex-base/news/+runtime-type-validation.performance.md create mode 100644 packages/reflex-components-core/news/+upload-compact-json.performance.md create mode 100644 tests/benchmarks/test_json_dumps.py diff --git a/news/+runtime-type-validation.performance.md b/news/+runtime-type-validation.performance.md new file mode 100644 index 00000000000..74a7b4a3314 --- /dev/null +++ b/news/+runtime-type-validation.performance.md @@ -0,0 +1 @@ +Runtime type checks on state var assignments and computed var results now run in pydantic-core, and state deltas are encoded with orjson, cutting per-event overhead for large lists and dicts by an order of magnitude. diff --git a/packages/reflex-base/news/+runtime-type-validation.performance.md b/packages/reflex-base/news/+runtime-type-validation.performance.md new file mode 100644 index 00000000000..ca1880ed8d2 --- /dev/null +++ b/packages/reflex-base/news/+runtime-type-validation.performance.md @@ -0,0 +1 @@ +Add `runtime_isinstance`, a compiled one-level type check backed by pydantic-core, and `format.json_dumps_compact`, an orjson-backed encoder for state deltas; `orjson` is now a dependency. Non-finite floats in a delta are sent as `null` instead of the invalid `NaN`/`Infinity` tokens. diff --git a/packages/reflex-base/pyproject.toml b/packages/reflex-base/pyproject.toml index 7e744790a18..868f0069f04 100644 --- a/packages/reflex-base/pyproject.toml +++ b/packages/reflex-base/pyproject.toml @@ -8,6 +8,7 @@ authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] requires-python = ">=3.10" dependencies = [ + "orjson >=3.11.3,<4", "packaging >=24.2,<27", "rich >=13,<16", "typing_extensions >=4.13.0", diff --git a/packages/reflex-base/src/reflex_base/utils/format.py b/packages/reflex-base/src/reflex_base/utils/format.py index 543e0e1778c..8e508a40680 100644 --- a/packages/reflex-base/src/reflex_base/utils/format.py +++ b/packages/reflex-base/src/reflex_base/utils/format.py @@ -10,6 +10,7 @@ from functools import lru_cache from typing import TYPE_CHECKING, Any +import orjson from rich.markup import escape as escape_markup from reflex_base import constants @@ -697,25 +698,46 @@ def format_library_name(library_fullname: str | dict[str, Any]) -> str: return lib -_serialize: Callable[[Any], Any] | None = None +if TYPE_CHECKING: + from types import ModuleType +_serializers: ModuleType | None = None -def _get_serialize() -> Callable[[Any], Any]: - """Get ``serializers.serialize``, importing it on first use. + +def _get_serializers() -> ModuleType: + """Get the ``serializers`` module, importing it on first use. The import cannot live at module scope (``serializers`` imports this module), and repeating it per call is measurable on the compile path, - so the resolved function is cached. + so the resolved module is cached. Returns: - The ``serializers.serialize`` callable. + The ``reflex_base.utils.serializers`` module. """ - global _serialize - if _serialize is None: + global _serializers + if _serializers is None: from reflex_base.utils import serializers - _serialize = serializers.serialize - return _serialize + _serializers = serializers + return _serializers + + +def _get_serialize() -> Callable[[Any], Any]: + """Get ``serializers.serialize``. + + Returns: + The ``serializers.serialize`` callable. + """ + return _get_serializers().serialize + + +# Dataclasses and datetimes keep going through the reflex serializers so their +# output matches ``json_dumps``; orjson's own rendering of both differs. +_ORJSON_OPTIONS = ( + orjson.OPT_NON_STR_KEYS + | orjson.OPT_PASSTHROUGH_DATACLASS + | orjson.OPT_PASSTHROUGH_DATETIME +) def json_dumps(obj: Any, **kwargs) -> str: @@ -734,6 +756,33 @@ def json_dumps(obj: Any, **kwargs) -> str: return json.dumps(obj, **kwargs) +def json_dumps_compact(obj: Any) -> str: + """Serialize an object to compact JSON for the wire. + + Produces the same values as ``json_dumps`` (reflex serializers handle + non-JSON types) with compact separators, encoded by orjson. State deltas + and streamed updates go through here. + + Args: + obj: The object to be serialized. + + Returns: + The JSON string. + """ + serializers = _get_serializers() + if not serializers.overrides_native_json_type(): + try: + return orjson.dumps( + obj, default=serializers.serialize, option=_ORJSON_OPTIONS + ).decode() + except TypeError: + # orjson rejects integers beyond 64 bits, which json accepts. + pass + return json.dumps( + obj, ensure_ascii=False, separators=(",", ":"), default=serializers.serialize + ) + + def collect_form_dict_names(form_dict: dict[str, Any]) -> dict[str, Any]: """Collapse keys with consecutive suffixes into a single list value. diff --git a/packages/reflex-base/src/reflex_base/utils/serializers.py b/packages/reflex-base/src/reflex_base/utils/serializers.py index d4299947499..f4a0aa30c63 100644 --- a/packages/reflex-base/src/reflex_base/utils/serializers.py +++ b/packages/reflex-base/src/reflex_base/utils/serializers.py @@ -126,6 +126,12 @@ def wrapper(fn: SERIALIZED_FUNCTION) -> SERIALIZED_FUNCTION: SERIALIZERS[type_] = fn get_serializer.cache_clear() + global _overrides_native_json_type + if type_ not in _NATIVE_JSON_TYPES and types.safe_issubclass( + type_, _NATIVE_JSON_TYPES + ): + _overrides_native_json_type = True + # Return the function. return fn @@ -166,7 +172,10 @@ def serialize( # If there is no serializer, return None. if serializer is None: if dataclasses.is_dataclass(value) and not isinstance(value, type): - return {k.name: getattr(value, k.name) for k in dataclasses.fields(value)} + return { + name: getattr(value, name) + for name in _dataclass_field_names(type(value)) + } if get_type: return None, None @@ -181,6 +190,34 @@ def serialize( return serialized +@functools.lru_cache +def _dataclass_field_names(cls: type) -> tuple[str, ...]: + """Get the field names of a dataclass, memoized per class. + + Args: + cls: The dataclass type. + + Returns: + The names of the dataclass fields, in definition order. + """ + return tuple(field.name for field in dataclasses.fields(cls)) + + +# Types orjson encodes itself, so a custom serializer registered for one of +# their subclasses would be bypassed on the wire; ``json_dumps_compact`` checks. +_NATIVE_JSON_TYPES = (Enum, UUID) +_overrides_native_json_type = False + + +def overrides_native_json_type() -> bool: + """Whether a serializer is registered for an Enum or UUID subclass. + + Returns: + True if such a serializer exists. + """ + return _overrides_native_json_type + + @functools.lru_cache def get_serializer(type_: type) -> Serializer | None: """Get the serializer for the type. diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index bb421851ad7..76570a6b788 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -30,6 +30,7 @@ _eval_type, # pyright: ignore [reportAttributeAccessIssue] _GenericAlias, # pyright: ignore [reportAttributeAccessIssue] _SpecialGenericAlias, # pyright: ignore [reportAttributeAccessIssue] + cast, get_args, is_typeddict, ) @@ -1041,6 +1042,187 @@ def _isinstance( raise +if find_spec("pydantic_core"): + from pydantic_core import SchemaValidator, ValidationError, core_schema + + def _runtime_schema(cls: GenericType, nested: int) -> core_schema.CoreSchema | None: + """Build a pydantic-core schema equivalent to ``_isinstance`` for a hint. + + Mirrors ``_isinstance(obj, cls, nested=nested, treat_var_as_type=False)`` + branch for branch; leaves are ``isinstance`` checks so subclass semantics + (including ``bool`` as ``int``) are unchanged. + + Args: + cls: The type hint to compile. + nested: How many container levels the check descends into. + + Returns: + The schema, or None when the hint has no exact schema equivalent and + the check must run through ``_isinstance`` instead. + """ + if cls is Any: + return core_schema.any_schema() + if cls is _Var: + return core_schema.is_instance_schema(_Var) + if cls is None or cls is type(None): + return core_schema.none_schema() + if isinstance(cls, TypeAliasTypes): + return _runtime_schema(resolve_type_alias(cls), nested) + + origin_attr = getattr(cls, "__origin__", None) + + if origin_attr is Union or ( + origin_attr is None and isinstance(cls, types.UnionType) + ): + # A Var instance never satisfies a union in _isinstance and a bare Var + # member never matches a non-Var value, so the member contributes nothing. + choices = [ + _runtime_schema(arg, nested) + for arg in _get_args_cached(cls) + if arg is not _Var + ] + if not choices or None in choices: + return None + return core_schema.union_schema( + cast( + "list[core_schema.CoreSchema | tuple[core_schema.CoreSchema, str]]", + choices, + ), + mode="left_to_right", + ) + + if origin_attr is Literal: + return core_schema.literal_schema(list(_get_args_cached(cls))) + + origin = origin_attr if origin_attr is not None else _get_origin_cached(cls) + + if origin is None: + if is_typeddict(cls): + # Key-level validation of a TypedDict has no core schema with the + # same shallow semantics; only the element-level dict check does. + return None if nested else core_schema.is_instance_schema(dict) + if cls is float: + return core_schema.is_instance_schema((float, int)) + # ``object`` would also admit Var instances, which _isinstance rejects. + if not isinstance(cls, type) or cls is object or issubclass(cls, _Var): + return None + return core_schema.is_instance_schema(cls) + + args = _get_args_cached(cls) + + if not args: + return ( + core_schema.is_instance_schema(origin) + if isinstance(origin, type) + else None + ) + + if origin is _Var or origin is _Field: + return _runtime_schema(args[0], nested) + + if nested > 0: + if origin is list: + items = _runtime_schema(args[0], nested - 1) + return ( + None + if items is None + else core_schema.list_schema(items, strict=True) + ) + if origin is tuple: + if args[-1] is Ellipsis: + item = _runtime_schema(args[0], nested - 1) + if item is None: + return None + return core_schema.tuple_schema( + [item], variadic_item_index=0, strict=True + ) + items = [_runtime_schema(arg, nested - 1) for arg in args] + if None in items: + return None + return core_schema.tuple_schema( + cast("list[core_schema.CoreSchema]", items), strict=True + ) + if safe_issubclass(origin, Mapping): + if origin is not dict: + return None + keys = _runtime_schema(args[0], nested - 1) + values = _runtime_schema(args[1], nested - 1) + if keys is None or values is None: + return None + return core_schema.dict_schema(keys, values, strict=True) + if origin is set: + item = _runtime_schema(args[0], nested - 1) + return ( + None if item is None else core_schema.set_schema(item, strict=True) + ) + + base = get_base_class(cls) + return core_schema.is_instance_schema(base) if isinstance(base, type) else None + + # Compiled validators keyed by type hint; None marks hints that fall back to + # ``_isinstance``. Hints are finite (one per annotated field) so this is unbounded. + _RUNTIME_VALIDATORS: dict[Any, SchemaValidator | None] = {} + + def _compile_runtime_validator(cls: GenericType) -> SchemaValidator | None: + """Compile the validator for a hint, or None when it needs ``_isinstance``. + + Args: + cls: The type hint to compile. + + Returns: + The compiled validator, or None for hints without a schema equivalent. + """ + if _Var is _Unloaded: + _load_var_classes() + schema = _runtime_schema(cls, 1) + return None if schema is None else SchemaValidator(schema) + + def runtime_isinstance(obj: Any, cls: GenericType) -> bool: + """Check a runtime value against a state var annotation, one level deep. + + Equivalent to ``_isinstance(obj, cls, nested=1, treat_var_as_type=False)`` + but runs the per-element checks in pydantic-core, compiled once per hint. + Hints without an exact schema equivalent, and Var instances, take the + ``_isinstance`` path. + + Args: + obj: The value to check. + cls: The declared type of the value. + + Returns: + Whether the value matches the declared type. + """ + try: + validator = _RUNTIME_VALIDATORS[cls] + except KeyError: + validator = _RUNTIME_VALIDATORS[cls] = _compile_runtime_validator(cls) + if validator is None or isinstance(obj, _Var): + return _isinstance(obj, cls, nested=1, treat_var_as_type=False) + if type(obj) is not obj.__class__: + # A MutableProxy (wrapt) reports the wrapped value's class, which + # isinstance honors but pydantic-core's container checks do not. + obj = obj.__wrapped__ + try: + validator.validate_python(obj) + except ValidationError: + return False + return True + +else: # pragma: no cover - pydantic is an optional dependency of reflex-base + + def runtime_isinstance(obj: Any, cls: GenericType) -> bool: + """Check a runtime value against a state var annotation, one level deep. + + Args: + obj: The value to check. + cls: The declared type of the value. + + Returns: + Whether the value matches the declared type. + """ + return _isinstance(obj, cls, nested=1, treat_var_as_type=False) + + def is_dataframe(value: type) -> bool: """Check if the given value is a dataframe. diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index 3d541ada126..59bbf6eaac9 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -65,9 +65,9 @@ from reflex_base.utils.types import ( GenericType, Self, - _isinstance, get_origin, has_args, + runtime_isinstance, safe_issubclass, unionize, ) @@ -2609,7 +2609,7 @@ def __get__(self, instance: BaseState | None, owner: type): return value def _check_deprecated_return_type(self, instance: BaseState, value: Any) -> None: - if not _isinstance(value, self._var_type, nested=1, treat_var_as_type=False): + if not runtime_isinstance(value, self._var_type): logger.error( f"Computed var '{type(instance).__name__}.{self._name}' must return" f" a value of type '{self._var_type}', got '{value!s}' of type {type(value)}." diff --git a/packages/reflex-components-core/news/+upload-compact-json.performance.md b/packages/reflex-components-core/news/+upload-compact-json.performance.md new file mode 100644 index 00000000000..7dfc394a2a7 --- /dev/null +++ b/packages/reflex-components-core/news/+upload-compact-json.performance.md @@ -0,0 +1 @@ +Streamed upload state updates are encoded with the orjson-backed compact encoder. diff --git a/packages/reflex-components-core/src/reflex_components_core/core/_upload.py b/packages/reflex-components-core/src/reflex_components_core/core/_upload.py index f8065f4ae7d..9b9498461e4 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/_upload.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/_upload.py @@ -20,7 +20,7 @@ from python_multipart.multipart import MultipartParser, parse_options_header from reflex_base.registry import RegistrationContext from reflex_base.utils import exceptions -from reflex_base.utils.format import json_dumps +from reflex_base.utils.format import json_dumps_compact from reflex_base.utils.streaming_response import DisconnectAwareStreamingResponse from starlette.datastructures import FormData, Headers from starlette.datastructures import UploadFile as StarletteUploadFile @@ -669,7 +669,7 @@ async def _ndjson_updates(): return # Enqueue the task on the main event loop, but emit deltas to the local queue. async for delta in app.event_processor.enqueue_stream_delta(token, event): - yield json_dumps(StateUpdate(delta=delta)) + "\n" + yield json_dumps_compact(StateUpdate(delta=delta)) + "\n" return DisconnectAwareStreamingResponse( _ndjson_updates(), diff --git a/reflex/app.py b/reflex/app.py index ff18d3bda0c..94487f40d11 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -580,7 +580,11 @@ def _setup_state(self) -> None: ping_interval=environment.REFLEX_SOCKET_INTERVAL.get(), ping_timeout=environment.REFLEX_SOCKET_TIMEOUT.get(), json=SimpleNamespace( - dumps=staticmethod(format.json_dumps), + # python-socketio passes ``separators``; the compact + # encoder already emits them. + dumps=staticmethod( + lambda obj, **_kwargs: format.json_dumps_compact(obj) + ), loads=staticmethod(json.loads), ), allow_upgrades=False, diff --git a/reflex/state.py b/reflex/state.py index 1ea080cc863..22d8b85525c 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -55,7 +55,7 @@ ) from reflex_base.utils.exceptions import ImmutableStateError as ImmutableStateError from reflex_base.utils.serializers import serializer -from reflex_base.utils.types import _isinstance +from reflex_base.utils.types import runtime_isinstance from reflex_base.vars import Field, VarData, field from reflex_base.vars.base import ( ComputedVar, @@ -902,7 +902,7 @@ def _evaluate(cls, f: Callable[[Self], Any], of_type: type | None = None) -> Var def computed_var_func(state: Self): result = f(state) - if not _isinstance(result, of_type, nested=1, treat_var_as_type=False): + if not runtime_isinstance(result, of_type): logger.warning( f"Inline ComputedVar {f} expected type {of_type}, got {type(result)}. " "You can specify expected type with `of_type` argument." @@ -1690,7 +1690,7 @@ def __setattr__(self, name: str, value: Any): if (field := fields.get(name)) is not None and field.is_var: field_type = field.outer_type_ - if not _isinstance(value, field_type, nested=1, treat_var_as_type=False): + if not runtime_isinstance(value, field_type): logger.error( f"Expected field '{type(self).__name__}.{name}' to receive type '{field_type}'," f" but got '{value}' of type '{type(value)}'." diff --git a/tests/benchmarks/test_isinstance.py b/tests/benchmarks/test_isinstance.py index cac451f5d8a..23bf0850766 100644 --- a/tests/benchmarks/test_isinstance.py +++ b/tests/benchmarks/test_isinstance.py @@ -9,7 +9,7 @@ import pytest from pytest_codspeed import BenchmarkFixture -from reflex_base.utils.types import _isinstance +from reflex_base.utils.types import _isinstance, runtime_isinstance N = 10_000 @@ -58,3 +58,25 @@ def test_isinstance_scalar(benchmark: BenchmarkFixture): def _(): for i in _INTS: _isinstance(i, int, nested=1, treat_var_as_type=False) + + +@pytest.mark.parametrize( + ("obj", "hint"), + [ + pytest.param(_INTS, list[int], id="list_int"), + pytest.param(_DICTS, list[dict[str, int]], id="list_dict"), + pytest.param(_OPTIONALS, list[int | None], id="list_optional"), + ], +) +def test_runtime_isinstance_container( + obj: list[Any], hint: type, benchmark: BenchmarkFixture +): + """Benchmark the compiled validator used on state var writes and reads. + + Args: + obj: The container to validate. + hint: The declared var type. + benchmark: The codspeed benchmark fixture. + """ + runtime_isinstance(obj, hint) + benchmark(lambda: runtime_isinstance(obj, hint)) diff --git a/tests/benchmarks/test_json_dumps.py b/tests/benchmarks/test_json_dumps.py new file mode 100644 index 00000000000..2ab25bc6237 --- /dev/null +++ b/tests/benchmarks/test_json_dumps.py @@ -0,0 +1,48 @@ +"""Benchmarks for encoding a state delta for the wire. + +``json_dumps_compact`` encodes every delta emitted over the websocket; the +payload mixes plain containers with dataclasses that go through the reflex +serializers. +""" + +import dataclasses + +import pytest +from pytest_codspeed import BenchmarkFixture +from reflex_base.utils.format import json_dumps, json_dumps_compact + +N = 10_000 + + +@dataclasses.dataclass +class _Row: + name: str + qty: int + price: float + + +_ROWS = [_Row(f"row {i}", i, i * 1.5) for i in range(N)] +_DICTS = [{"name": f"row {i}", "qty": i, "price": i * 1.5} for i in range(N)] + + +@pytest.mark.parametrize( + "payload", + [pytest.param(_ROWS, id="dataclasses"), pytest.param(_DICTS, id="dicts")], +) +def test_json_dumps_compact(payload: list, benchmark: BenchmarkFixture): + """Benchmark the wire encoder on a large delta. + + Args: + payload: The delta value to encode. + benchmark: The codspeed benchmark fixture. + """ + benchmark(lambda: json_dumps_compact({"state": {"rows": payload}})) + + +def test_json_dumps_reference(benchmark: BenchmarkFixture): + """Benchmark the stdlib-backed encoder on the same delta for comparison. + + Args: + benchmark: The codspeed benchmark fixture. + """ + benchmark(lambda: json_dumps({"state": {"rows": _ROWS}})) diff --git a/tests/units/reflex_base/utils/test_types.py b/tests/units/reflex_base/utils/test_types.py index c50f97d2707..4552b4bd72c 100644 --- a/tests/units/reflex_base/utils/test_types.py +++ b/tests/units/reflex_base/utils/test_types.py @@ -1,11 +1,18 @@ """Tests for reflex_base.utils.types.""" +import collections +import dataclasses +import datetime +import enum +import types import typing -from collections.abc import Callable -from typing import Literal, TypeVar +from collections.abc import Callable, Mapping, Sequence +from typing import Any, Literal, TypedDict, TypeVar import pytest +import wrapt from reflex_base.utils.types import ( + _RUNTIME_VALIDATORS, ASGIApp, Message, Receive, @@ -13,6 +20,7 @@ Send, _isinstance, resolve_type_alias, + runtime_isinstance, typehint_issubclass, ) from typing_extensions import ParamSpec, TypeAliasType, TypeVarTuple, Unpack @@ -122,3 +130,169 @@ def test_typehint_issubclass_resolves_type_alias(alias_cls: type) -> None: assert typehint_issubclass(maybe, maybe) assert not typehint_issubclass(maybe, str) assert typehint_issubclass(str, maybe) + + +class _Point(TypedDict): + x: int + + +@dataclasses.dataclass +class _Row: + a: int + + +class _Color(enum.Enum): + RED = 1 + + +class _Text(str): + pass + + +_RUNTIME_HINTS = [ + int, + float, + str, + bool, + None, + Any, + object, + _Row, + _Color, + _Point, + _Text, + list, + dict, + list[int], + list[float], + list[str], + list[_Row], + list[_Point], + list[list[int]], + list[Any], + list[object], + list[int | None], + list[_Row | None], + list[Literal["a", "b"]], + dict[str, int], + dict[int, list[int]], + tuple[int, ...], + tuple[int, str], + tuple[()], + set[int], + frozenset[int], + int | None, + int | str, + Literal[1, "a"], + Sequence[int], + Mapping[str, int], + collections.OrderedDict[str, int], + type[_Row], + datetime.datetime, + list[datetime.date], +] + +_RUNTIME_VALUES = [ + 1, + 1.5, + True, + "a", + _Text("a"), + None, + _Row(1), + _Color.RED, + {"x": 1}, + {"x": "s"}, + {}, + [], + [1, 2], + [1.0], + [1, "a"], + [True], + [_Row(1)], + [_Row(1), None], + [[1]], + [[1], ["a"]], + [None, 1], + (1, 2), + (1, "a"), + (), + {1, 2}, + frozenset({1}), + {"a": 1}, + {"a": "b"}, + {1: [1]}, + collections.OrderedDict(a=1), + types.MappingProxyType({"a": 1}), + ["a", "b"], + ["c"], + [object()], + _Row, + [_Row], + datetime.datetime(2024, 1, 1), + [datetime.date(2024, 1, 1)], +] + + +@pytest.mark.parametrize("hint", _RUNTIME_HINTS, ids=repr) +def test_runtime_isinstance_matches_isinstance(hint: Any): + """The compiled check agrees with ``_isinstance`` for every value. + + Args: + hint: The declared type to check against. + """ + for value in _RUNTIME_VALUES: + expected = _isinstance(value, hint, nested=1, treat_var_as_type=False) + assert runtime_isinstance(value, hint) is expected, (value, hint) + + +def test_runtime_isinstance_var_hints_and_values(): + """Var hints and Var values keep the ``_isinstance`` semantics.""" + from reflex_base.vars import Field, LiteralVar, Var + + var = Var("x") + literal = LiteralVar.create(3) + hints = [ + Var, + Var[int], + int | Var, + list[Var], + list[Var[int]], + Field[int], + Field[list[int]], + ] + values = [*_RUNTIME_VALUES, var, literal, [var], [literal]] + for hint in hints: + for value in values: + expected = _isinstance(value, hint, nested=1, treat_var_as_type=False) + assert runtime_isinstance(value, hint) is expected, (value, hint) + for hint in _RUNTIME_HINTS: + for value in (var, literal, [var], [literal]): + expected = _isinstance(value, hint, nested=1, treat_var_as_type=False) + assert runtime_isinstance(value, hint) is expected, (value, hint) + + +def test_runtime_isinstance_compiles_once_and_falls_back(): + """Supported hints compile to a cached validator; others record a fallback.""" + for hint in (list[int], dict[str, _Row], tuple[int, ...], int | None): + runtime_isinstance([], hint) + assert _RUNTIME_VALIDATORS[hint] is not None + # Key-level TypedDict checks and non-dict mappings have no schema equivalent. + for hint in (_Point, Mapping[str, int], object): + runtime_isinstance({}, hint) + assert _RUNTIME_VALIDATORS[hint] is None + + +def test_runtime_isinstance_unwraps_proxies(): + """State reads hand back wrapt proxies; they must validate as their value.""" + for value, hint in [ + ([_Row(1)], list[_Row]), + ({"a": 1}, dict[str, int]), + ((1, 2), tuple[int, ...]), + ({1}, set[int]), + (_Row(1), _Row), + ]: + proxied = wrapt.ObjectProxy(value) + assert runtime_isinstance(proxied, hint) + assert runtime_isinstance([proxied], list[hint]) # pyright: ignore[reportInvalidTypeForm] + assert not runtime_isinstance(wrapt.ObjectProxy(["x"]), list[_Row]) diff --git a/tests/units/utils/test_format.py b/tests/units/utils/test_format.py index 83e718411fd..fc372db5513 100644 --- a/tests/units/utils/test_format.py +++ b/tests/units/utils/test_format.py @@ -1,7 +1,12 @@ from __future__ import annotations +import dataclasses import datetime +import decimal +import enum import json +import pathlib +import uuid from typing import Any import plotly.graph_objects as go @@ -16,7 +21,7 @@ no_args_event_spec, ) from reflex_base.style import Style -from reflex_base.utils import format +from reflex_base.utils import format, serializers from reflex_base.utils.serializers import serialize_figure from reflex_base.vars.base import LiteralVar, Var from reflex_base.vars.function import FunctionStringVar @@ -24,6 +29,7 @@ pytest.importorskip("pydantic") +from pydantic import BaseModel from tests.units.test_state import ( ChildState, @@ -834,6 +840,106 @@ def test_json_dumps(input, output): assert format.json_dumps(input) == output +class _Shade(enum.Enum): + LIGHT = "light" + + +@dataclasses.dataclass +class _Cell: + value: int + _hidden: int = 2 + + def __post_init__(self): + # Not a field: json_dumps leaves it out, and so must the compact path. + self.transient = 1 + + +class _Model(BaseModel): + count: int + when: datetime.datetime + + +_WIRE_PAYLOAD: dict[str, Any] = { + "scalars": [1, 2.5, True, None, "é", "", 2**64, -(2**63) - 1], + "types": [ + _Cell(1), + _Shade.LIGHT, + uuid.UUID(int=5), + datetime.datetime(2024, 1, 1, 12), + datetime.date(2024, 1, 1), + datetime.timedelta(seconds=5), + decimal.Decimal("1.5"), + pathlib.Path("/a/b"), + {1, 2}, + (1, 2), + _Model(count=1, when=datetime.datetime(2024, 1, 1)), + ], + "keys": {2: 2, None: 3, True: 4, 1.5: 5}, +} + + +@pytest.fixture +def native_json(monkeypatch: pytest.MonkeyPatch) -> None: + """Undo Enum-subclass serializers other tests registered, so orjson runs. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.setattr(serializers, "_overrides_native_json_type", False) + + +def _compact_reference(value: Any) -> str: + return json.dumps( + value, ensure_ascii=False, separators=(",", ":"), default=serializers.serialize + ) + + +@pytest.mark.parametrize( + "value", + [ + _WIRE_PAYLOAD, + 2**70, + {"big": [2**70]}, + "text", + None, + [_Cell(i) for i in range(50)], + ], +) +@pytest.mark.usefixtures("native_json") +def test_json_dumps_compact_matches_json_dumps(value: Any): + """The wire encoder renders the same values as json_dumps, compactly. + + Args: + value: The payload to encode. + """ + assert format.json_dumps_compact(value) == _compact_reference(value) + + +@pytest.mark.usefixtures("native_json") +def test_json_dumps_compact_non_finite_floats(): + """Non-finite floats become null instead of the invalid JSON tokens.""" + assert format.json_dumps_compact([float("inf"), float("nan")]) == "[null,null]" + + +@pytest.mark.usefixtures("native_json") +def test_json_dumps_compact_honors_enum_subclass_serializer(monkeypatch): + """A serializer registered for an Enum subclass is still applied on the wire.""" + monkeypatch.delitem(serializers.SERIALIZERS, _Shade, raising=False) + serializers.get_serializer.cache_clear() + assert format.json_dumps_compact([_Shade.LIGHT]) == '["light"]' + + @serializers.serializer + def serialize_shade(shade: _Shade) -> str: + return "shade:" + shade.name + + assert serializers.overrides_native_json_type() + assert format.json_dumps_compact([_Shade.LIGHT]) == '["shade:LIGHT"]' + monkeypatch.delitem(serializers.SERIALIZERS, _Shade) + monkeypatch.delitem(serializers.SERIALIZER_TYPES, _Shade) + serializers.get_serializer.cache_clear() + serializers.get_serializer_type.cache_clear() + + def test_sanitize_client_log_value_respects_max_length(): """The sanitized value never exceeds max_length, even when truncated.""" out = format.sanitize_client_log_value("A" * 5000, max_length=500) diff --git a/uv.lock b/uv.lock index 054c6a98407..c8dc24041da 100644 --- a/uv.lock +++ b/uv.lock @@ -19,23 +19,23 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P7D" [options.exclude-newer-package] -reflex-components-plotly = false -reflex-components-radix = false -reflex-components-moment = false -reflex-components-sonner = false hatch-reflex-pyi = false -reflex-components-gridjs = false reflex = false -reflex-enterprise = false +reflex-base = false reflex-components-code = false -reflex-components-recharts = false reflex-components-core = false reflex-components-dataeditor = false -reflex-base = false -reflex-components-markdown = false +reflex-components-gridjs = false reflex-components-internal = false reflex-components-lucide = false +reflex-components-markdown = false +reflex-components-moment = false +reflex-components-plotly = false +reflex-components-radix = false reflex-components-react-player = false +reflex-components-recharts = false +reflex-components-sonner = false +reflex-enterprise = false reflex-hosting-cli = false [manifest] @@ -3945,6 +3945,7 @@ dev = [ name = "reflex-base" source = { editable = "packages/reflex-base" } dependencies = [ + { name = "orjson" }, { name = "packaging" }, { name = "platformdirs" }, { name = "rich" }, @@ -3958,6 +3959,7 @@ pydantic = [ [package.metadata] requires-dist = [ + { name = "orjson", specifier = ">=3.11.3,<4" }, { name = "packaging", specifier = ">=24.2,<27" }, { name = "platformdirs", specifier = ">=4.3.7,<5.0" }, { name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.12.0,<3.0" }, From a2d92b8a9f1e877c5c4e4383c8f9003c7b50c686 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:58:10 +0000 Subject: [PATCH 2/6] Cut MutableProxy per-element overhead on state var reads Iterating or indexing a list, dict, or dataclass held in a state var wraps every mutable element in a MutableProxy. Each wrap paid for a Python-level __new__ (with a dataclass check) and __init__, a five-frame stack walk to detect dataclasses internals, and a generic mutability lookup even for scalars. The proxy class per (base proxy, wrapped type) is now resolved through one cached dict lookup, and the internal `_new_proxy` constructor calls the C allocator and setattr slots directly. `__iter__` runs the dataclasses-internal check once per iteration instead of per element, and `__getattr__`, `__getitem__` and iteration return str/int/float/bool/None values before touching any of the wrapping machinery. Iterating a 10k-element proxied list of dataclasses or dicts drops from about 4.0 us to 1.0 us per element. On the reflex-dev/templates dashboard app with a 10k-row items.csv, the end-to-end median round trip for the sort events fell from 66 ms to 48 ms and the overall per-event cost from 68 ms to 56 ms (84 ms on main before this branch). --- news/+mutable-proxy-fast-path.performance.md | 1 + reflex/istate/proxy.py | 171 ++++++++++++++----- 2 files changed, 132 insertions(+), 40 deletions(-) create mode 100644 news/+mutable-proxy-fast-path.performance.md diff --git a/news/+mutable-proxy-fast-path.performance.md b/news/+mutable-proxy-fast-path.performance.md new file mode 100644 index 00000000000..0d28a841e74 --- /dev/null +++ b/news/+mutable-proxy-fast-path.performance.md @@ -0,0 +1 @@ +Reading list, dict, and dataclass elements through state vars is about 3.5x faster: proxies are constructed directly, scalar reads skip the wrapping machinery, and the dataclasses-internal check runs once per iteration. diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index 4a3d9cd35f2..826caa28c96 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -432,6 +432,50 @@ def mark_dirty(self): MUTABLE_TYPES += (BaseModel,) +# The base proxy slots, called directly on the per-element hot path to skip +# the Python-level __new__/__init__ dispatch of the subclass. wrapt 2 wraps +# the C allocator in a Python ``ObjectProxy.__new__``; BaseObjectProxy is it. +_proxy_new = getattr(wrapt, "BaseObjectProxy", wrapt.ObjectProxy).__new__ +_proxy_init = wrapt.ObjectProxy.__init__ +_proxy_setattr = wrapt.ObjectProxy.__setattr__ + +# Values of these types are never wrapped; checked first on every read. +_IMMUTABLE_SCALARS = frozenset({str, int, float, bool, type(None)}) + + +def _new_proxy( + base_cls: type[MutableProxy], + value: Any, + state: BaseState, + field_name: str, + path: tuple[_AccessSpec, ...], +) -> MutableProxy: + """Construct a proxy for a value already known to be mutable. + + Args: + base_cls: The proxy class to derive from (see ``__base_proxy__``). + value: The mutable value to wrap (not itself a proxy). + state: The state to mark dirty when the value is changed. + field_name: The state field the value belongs to. + path: Access path from the state field to the value. + + Returns: + The proxy instance. + """ + key = (base_cls, type(value)) + try: + proxy_cls = MutableProxy.__proxy_classes__[key] + except KeyError: + proxy_cls = MutableProxy._resolve_proxy_class(base_cls, type(value)) + proxy = cast("MutableProxy", _proxy_new(proxy_cls)) + _proxy_init(proxy, value) + _proxy_setattr(proxy, "_self_state", state) + _proxy_setattr(proxy, "_self_field_name", field_name) + if path: + _proxy_setattr(proxy, "_self_path", path) + return proxy + + class MutableProxy(wrapt.ObjectProxy): """A proxy for a mutable object that tracks changes.""" @@ -464,8 +508,9 @@ class MutableProxy(wrapt.ObjectProxy): "setdefault", } - # Dynamically generated classes for tracking dataclass mutations. - __dataclass_proxies__: dict[tuple[type, type], type] = {} + # The concrete proxy class per (base proxy class, wrapped type): the base + # class itself, or a generated subclass carrying a dataclass's metadata. + __proxy_classes__: dict[tuple[type, type], type] = {} _self_path: tuple[_AccessSpec, ...] = () # The state (or StateProxy) whose async context this proxy has entered. _self_actx_state: BaseState | None = None @@ -488,23 +533,37 @@ def __new__( Returns: The proxy instance. """ - if dataclasses.is_dataclass(wrapped): - wrapped_cls = type(wrapped) - wrapper_cls_key = (cls, wrapped_cls) - # Find the associated class - if wrapper_cls_key not in cls.__dataclass_proxies__: - # Create a new class carrying the wrapped type's dataclass metadata. - wrapper_cls_name = wrapped_cls.__name__ + cls.__name__ - cls.__dataclass_proxies__[wrapper_cls_key] = type( - wrapper_cls_name, - (cls,), - _dataclass_proxy_namespace(wrapped_cls), - ) - cls = cls.__dataclass_proxies__[wrapper_cls_key] + key = (cls, type(wrapped)) + try: + cls = cls.__proxy_classes__[key] + except KeyError: + cls = cls._resolve_proxy_class(cls, type(wrapped)) # wrapt-stubs types `ObjectProxy.__new__` as returning `ObjectProxy` # rather than `Self`, hence the cast. return cast("Self", super().__new__(cls)) # pyright: ignore[reportArgumentType] + @staticmethod + def _resolve_proxy_class(base_cls: type, wrapped_cls: type) -> type: + """Find and cache the proxy class for a wrapped type. + + Args: + base_cls: The proxy class being instantiated. + wrapped_cls: The type of the wrapped value. + + Returns: + ``base_cls``, or for a dataclass a generated subclass carrying its + dataclass metadata. + """ + proxy_cls = base_cls + if dataclasses.is_dataclass(wrapped_cls): + proxy_cls = type( + wrapped_cls.__name__ + base_cls.__name__, + (base_cls,), + _dataclass_proxy_namespace(wrapped_cls), + ) + MutableProxy.__proxy_classes__[base_cls, wrapped_cls] = proxy_cls + return proxy_cls + def __init__( self, wrapped: Any, @@ -521,17 +580,15 @@ def __init__( wrapped object. path: Access path from the state field to this wrapped object. """ - super().__init__(wrapped) - # Calling the base proxy's __setattr__ directly skips the per-store - # Python-level dispatch in MutableProxy.__setattr__; proxy construction - # is a per-element hot path. object.__setattr__ is not usable here: on - # Python <= 3.13 it rejects instances whose static base (wrapt's C - # ObjectProxy) overrides tp_setattro. - proxy_setattr = super().__setattr__ - proxy_setattr("_self_state", state) - proxy_setattr("_self_field_name", field_name) + _proxy_init(self, wrapped) + # The base proxy's __setattr__ skips the per-store Python-level + # dispatch in MutableProxy.__setattr__. object.__setattr__ is not + # usable here: on Python <= 3.13 it rejects instances whose static + # base (wrapt's C ObjectProxy) overrides tp_setattro. + _proxy_setattr(self, "_self_state", state) + _proxy_setattr(self, "_self_field_name", field_name) if path is not None: - proxy_setattr("_self_path", path) + _proxy_setattr(self, "_self_path", path) def __repr__(self) -> str: """Get the representation of the wrapped object. @@ -588,7 +645,7 @@ async def __aenter__(self) -> Self: isinstance(refreshed_value, MutableProxy) and self._self_field_name == refreshed_value._self_field_name # The proxy class is specialized per dataclass type (see - # __dataclass_proxies__), so a refresh must not change the + # __proxy_classes__), so a refresh must not change the # wrapped dataclass type out from under it. and ( not dataclasses.is_dataclass(self.__wrapped__) @@ -666,15 +723,16 @@ def _is_called_from_dataclasses_internal() -> bool: """ # Walk up the stack a bit to see if we are called from dataclasses # internal code, for example `asdict` or `astuple`. - frame = inspect.currentframe() + frame = sys._getframe(1) for _ in range(5): # Why not `inspect.stack()` -- this is much faster! And reading # `f_code.co_filename` directly avoids the type-dispatch overhead of # `inspect.getfile()`, which dominates this per-element read hot-path. - if not (frame := frame and frame.f_back): + if frame is None: break if frame.f_code.co_filename == _DATACLASSES_FILE: return True + frame = frame.f_back return False def _wrap_recursive( @@ -713,11 +771,12 @@ def _wrap_mutable(self, value: Any, path: tuple[_AccessSpec, ...]) -> Any: # reference is up to date. if isinstance(value, MutableProxy): value = value.__wrapped__ - return globals()[self.__base_proxy__]( - wrapped=value, - state=self._self_state, - field_name=self._self_field_name, - path=path or None, + return _new_proxy( + globals()[self.__base_proxy__], + value, + self._self_state, + self._self_field_name, + path, ) def _wrap_recursive_decorator( @@ -761,6 +820,9 @@ def __getattr__(self, __name: str) -> Any: """ value = super().__getattr__(__name) # pyright: ignore[reportAttributeAccessIssue] + if type(value) in _IMMUTABLE_SCALARS: + return value + if callable(value): if __name in self.__mark_dirty_attrs__: # Wrap special callables, like "append", which should mark state dirty. @@ -804,7 +866,9 @@ def __getitem__(self, key: Any) -> Any: The item value. """ value = super().__getitem__(key) # pyright: ignore[reportAttributeAccessIssue] - if not isinstance(value, MutableProxy) and not is_mutable_type(type(value)): + if type(value) in _IMMUTABLE_SCALARS or ( + not isinstance(value, MutableProxy) and not is_mutable_type(type(value)) + ): # Skip the wrapping machinery entirely on the non-mutable hot path. return value if isinstance(self.__wrapped__, list): @@ -827,15 +891,42 @@ def __iter__(self) -> Any: Yields: Each item value (possibly wrapped in MutableProxy). """ - wrap_mutable = self._wrap_mutable + wrapped_iter = iter(self.__wrapped__) + # Dataclasses internals (asdict, astuple) get the raw values. Checked + # once per iteration rather than per element. + if self._is_called_from_dataclasses_internal(): + yield from wrapped_iter + else: + yield from self._iter_wrapped(wrapped_iter) + + def _iter_wrapped(self, wrapped_iter: Any) -> Any: + """Wrap the mutable values yielded by an iterator over the proxied object. + + Args: + wrapped_iter: The iterator over the wrapped object. + + Yields: + Each item value (possibly wrapped in MutableProxy). + """ + base_cls = globals()[self.__base_proxy__] + state = self._self_state + field_name = self._self_field_name mutable_check = is_mutable_type # All iterated elements share one child path; build it once, not per element. + # Iterated values have no stable key to refresh through, so their + # proxies cannot be used as async context managers. child_path = (*self._self_path, _UNREFRESHABLE_ACCESS_SPEC) - for value in super().__iter__(): # pyright: ignore[reportAttributeAccessIssue] - # Iterated values have no stable key to refresh through, so their - # proxies cannot be used as async context managers. - if isinstance(value, MutableProxy) or mutable_check(type(value)): - yield wrap_mutable(value, child_path) + for value in wrapped_iter: + value_cls = type(value) + if value_cls in _IMMUTABLE_SCALARS: + yield value + elif isinstance(value, MutableProxy): + # Rewrap so the state reference is up to date. + yield _new_proxy( + base_cls, value.__wrapped__, state, field_name, child_path + ) + elif mutable_check(value_cls): + yield _new_proxy(base_cls, value, state, field_name, child_path) else: yield value From 0f97c66ada0b17f26aedb175695d189e698263e5 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:11:49 +0000 Subject: [PATCH 3/6] Keep NaN/Infinity tokens on the wire and honor overwritten base serializers orjson collapses non-finite floats to null, but the frontend revives the bare NaN/Infinity tokens json emits (test_computed_vars covers this). The compact encoder now returns orjson's output only when it contains no null and otherwise re-encodes with json, so a None or a non-finite float in a delta takes the exact-output path. A serializer registered with overwrite=True for Enum or UUID themselves now also switches the wire path away from orjson; the built-in registrations no longer count. runtime_isinstance only unwraps objects that carry __wrapped__, so a test double reporting a foreign __class__ is validated as is instead of raising. --- .../+runtime-type-validation.performance.md | 2 +- .../src/reflex_base/utils/format.py | 16 +++++++---- .../src/reflex_base/utils/serializers.py | 16 ++++++----- .../src/reflex_base/utils/types.py | 2 +- tests/units/reflex_base/utils/test_types.py | 13 +++++++++ tests/units/utils/test_format.py | 27 +++++++++++++++++-- 6 files changed, 61 insertions(+), 15 deletions(-) diff --git a/packages/reflex-base/news/+runtime-type-validation.performance.md b/packages/reflex-base/news/+runtime-type-validation.performance.md index ca1880ed8d2..04101776d28 100644 --- a/packages/reflex-base/news/+runtime-type-validation.performance.md +++ b/packages/reflex-base/news/+runtime-type-validation.performance.md @@ -1 +1 @@ -Add `runtime_isinstance`, a compiled one-level type check backed by pydantic-core, and `format.json_dumps_compact`, an orjson-backed encoder for state deltas; `orjson` is now a dependency. Non-finite floats in a delta are sent as `null` instead of the invalid `NaN`/`Infinity` tokens. +Add `runtime_isinstance`, a compiled one-level type check backed by pydantic-core, and `format.json_dumps_compact`, an orjson-backed encoder for state deltas; `orjson` is now a dependency. diff --git a/packages/reflex-base/src/reflex_base/utils/format.py b/packages/reflex-base/src/reflex_base/utils/format.py index 8e508a40680..28b074783d3 100644 --- a/packages/reflex-base/src/reflex_base/utils/format.py +++ b/packages/reflex-base/src/reflex_base/utils/format.py @@ -759,9 +759,9 @@ def json_dumps(obj: Any, **kwargs) -> str: def json_dumps_compact(obj: Any) -> str: """Serialize an object to compact JSON for the wire. - Produces the same values as ``json_dumps`` (reflex serializers handle - non-JSON types) with compact separators, encoded by orjson. State deltas - and streamed updates go through here. + Produces the same output as ``json_dumps`` with compact separators (reflex + serializers handle non-JSON types), encoded by orjson whenever the payload + lets it. State deltas and streamed updates go through here. Args: obj: The object to be serialized. @@ -772,12 +772,18 @@ def json_dumps_compact(obj: Any) -> str: serializers = _get_serializers() if not serializers.overrides_native_json_type(): try: - return orjson.dumps( + encoded = orjson.dumps( obj, default=serializers.serialize, option=_ORJSON_OPTIONS - ).decode() + ) except TypeError: # orjson rejects integers beyond 64 bits, which json accepts. pass + else: + # orjson collapses NaN and +/-Infinity to null, but the frontend + # expects the bare tokens json emits. A null in the output is + # either a None or such a float; only then take the slow path. + if b"null" not in encoded: + return encoded.decode() return json.dumps( obj, ensure_ascii=False, separators=(",", ":"), default=serializers.serialize ) diff --git a/packages/reflex-base/src/reflex_base/utils/serializers.py b/packages/reflex-base/src/reflex_base/utils/serializers.py index f4a0aa30c63..482a2531fd8 100644 --- a/packages/reflex-base/src/reflex_base/utils/serializers.py +++ b/packages/reflex-base/src/reflex_base/utils/serializers.py @@ -127,9 +127,7 @@ def wrapper(fn: SERIALIZED_FUNCTION) -> SERIALIZED_FUNCTION: get_serializer.cache_clear() global _overrides_native_json_type - if type_ not in _NATIVE_JSON_TYPES and types.safe_issubclass( - type_, _NATIVE_JSON_TYPES - ): + if types.safe_issubclass(type_, _NATIVE_JSON_TYPES): _overrides_native_json_type = True # Return the function. @@ -203,14 +201,15 @@ def _dataclass_field_names(cls: type) -> tuple[str, ...]: return tuple(field.name for field in dataclasses.fields(cls)) -# Types orjson encodes itself, so a custom serializer registered for one of -# their subclasses would be bypassed on the wire; ``json_dumps_compact`` checks. +# Types orjson encodes itself, matching the serializers below; a serializer an +# app registers for them or a subclass would be bypassed on the wire, so +# ``json_dumps_compact`` checks this flag. _NATIVE_JSON_TYPES = (Enum, UUID) _overrides_native_json_type = False def overrides_native_json_type() -> bool: - """Whether a serializer is registered for an Enum or UUID subclass. + """Whether an app registered a serializer for Enum, UUID, or a subclass. Returns: True if such a serializer exists. @@ -547,3 +546,8 @@ def serialize_image(image: Img) -> str: mime_type = "image/png" return f"data:{mime_type};base64,{base64_image}" + + +# The built-in Enum and UUID serializers above render exactly as orjson does; +# only registrations made after this point count as overrides. +_overrides_native_json_type = False diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index 76570a6b788..68b08b110cb 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -1201,7 +1201,7 @@ def runtime_isinstance(obj: Any, cls: GenericType) -> bool: if type(obj) is not obj.__class__: # A MutableProxy (wrapt) reports the wrapped value's class, which # isinstance honors but pydantic-core's container checks do not. - obj = obj.__wrapped__ + obj = getattr(obj, "__wrapped__", obj) try: validator.validate_python(obj) except ValidationError: diff --git a/tests/units/reflex_base/utils/test_types.py b/tests/units/reflex_base/utils/test_types.py index 4552b4bd72c..2f632c603cf 100644 --- a/tests/units/reflex_base/utils/test_types.py +++ b/tests/units/reflex_base/utils/test_types.py @@ -296,3 +296,16 @@ def test_runtime_isinstance_unwraps_proxies(): assert runtime_isinstance(proxied, hint) assert runtime_isinstance([proxied], list[hint]) # pyright: ignore[reportInvalidTypeForm] assert not runtime_isinstance(wrapt.ObjectProxy(["x"]), list[_Row]) + + +def test_runtime_isinstance_tolerates_class_spoofing_without_wrapped(): + """An object reporting another __class__ but no __wrapped__ is checked as is.""" + + class _Impostor: + @property + def __class__(self): + return list + + impostor = _Impostor() + assert runtime_isinstance(impostor, list[int]) is False + assert runtime_isinstance(impostor, Any) is True diff --git a/tests/units/utils/test_format.py b/tests/units/utils/test_format.py index fc372db5513..e799d1a2e83 100644 --- a/tests/units/utils/test_format.py +++ b/tests/units/utils/test_format.py @@ -917,8 +917,31 @@ def test_json_dumps_compact_matches_json_dumps(value: Any): @pytest.mark.usefixtures("native_json") def test_json_dumps_compact_non_finite_floats(): - """Non-finite floats become null instead of the invalid JSON tokens.""" - assert format.json_dumps_compact([float("inf"), float("nan")]) == "[null,null]" + """Non-finite floats keep the bare tokens the frontend revives.""" + assert ( + format.json_dumps_compact([float("inf"), float("-inf"), float("nan")]) + == "[Infinity,-Infinity,NaN]" + ) + assert format.json_dumps_compact({"a": None, "b": float("nan")}) == ( + '{"a":null,"b":NaN}' + ) + + +@pytest.mark.usefixtures("native_json") +def test_json_dumps_compact_overwritten_base_serializer(monkeypatch): + """Replacing the built-in Enum serializer also disables the native path.""" + original = serializers.SERIALIZERS[enum.Enum] + + @serializers.serializer(overwrite=True) + def serialize_enum_by_name(en: enum.Enum) -> str: + return en.name + + try: + assert serializers.overrides_native_json_type() + assert format.json_dumps_compact([_Shade.LIGHT]) == '["LIGHT"]' + finally: + serializers.SERIALIZERS[enum.Enum] = original + serializers.get_serializer.cache_clear() @pytest.mark.usefixtures("native_json") From 375bb440ac33c629ba4ab976d2a54d65d653674a Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:17:40 +0000 Subject: [PATCH 4/6] Address review nits: unbounded dataclass field cache, dict baseline benchmark The per-class dataclass field-name cache no longer evicts at 128 classes, and the stdlib reference benchmark covers the dict payload as well as the dataclass one so both compact-encoder cases have a baseline. --- .../reflex-base/src/reflex_base/utils/serializers.py | 2 +- tests/benchmarks/test_json_dumps.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/utils/serializers.py b/packages/reflex-base/src/reflex_base/utils/serializers.py index 482a2531fd8..c3bc857d358 100644 --- a/packages/reflex-base/src/reflex_base/utils/serializers.py +++ b/packages/reflex-base/src/reflex_base/utils/serializers.py @@ -188,7 +188,7 @@ def serialize( return serialized -@functools.lru_cache +@functools.cache def _dataclass_field_names(cls: type) -> tuple[str, ...]: """Get the field names of a dataclass, memoized per class. diff --git a/tests/benchmarks/test_json_dumps.py b/tests/benchmarks/test_json_dumps.py index 2ab25bc6237..16d4cdd2610 100644 --- a/tests/benchmarks/test_json_dumps.py +++ b/tests/benchmarks/test_json_dumps.py @@ -39,10 +39,15 @@ def test_json_dumps_compact(payload: list, benchmark: BenchmarkFixture): benchmark(lambda: json_dumps_compact({"state": {"rows": payload}})) -def test_json_dumps_reference(benchmark: BenchmarkFixture): +@pytest.mark.parametrize( + "payload", + [pytest.param(_ROWS, id="dataclasses"), pytest.param(_DICTS, id="dicts")], +) +def test_json_dumps_reference(payload: list, benchmark: BenchmarkFixture): """Benchmark the stdlib-backed encoder on the same delta for comparison. Args: + payload: The delta value to encode. benchmark: The codspeed benchmark fixture. """ - benchmark(lambda: json_dumps({"state": {"rows": _ROWS}})) + benchmark(lambda: json_dumps({"state": {"rows": payload}})) From e84cc6129a533f34270065d55e35acef3040cad3 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:20:44 +0000 Subject: [PATCH 5/6] Bound the dataclass field-name cache at 1024 classes Unbounded retention would keep dynamically created dataclass types alive for the process lifetime; 1024 entries never evicts in practice and matches the bound used for the proxy module's per-type cache. --- packages/reflex-base/src/reflex_base/utils/serializers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/reflex-base/src/reflex_base/utils/serializers.py b/packages/reflex-base/src/reflex_base/utils/serializers.py index c3bc857d358..5f333db3788 100644 --- a/packages/reflex-base/src/reflex_base/utils/serializers.py +++ b/packages/reflex-base/src/reflex_base/utils/serializers.py @@ -188,7 +188,9 @@ def serialize( return serialized -@functools.cache +# Bounded like ``is_mutable_type``'s cache: large enough that an app never +# rescans a class in practice, without retaining dynamically created ones forever. +@functools.lru_cache(maxsize=1024) def _dataclass_field_names(cls: type) -> tuple[str, ...]: """Get the field names of a dataclass, memoized per class. From 6907824df4fde9950157c5356d623eba852f0c7f Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:47:27 +0000 Subject: [PATCH 6/6] Replace micro-benchmarks with one holistic table event benchmark test_process_table_event runs three filter events on a state holding 1000 dataclass rows through the real event processor and encodes each delta for the wire the way the socket path does. One event covers the whole per-event runtime path this branch touches: base var assignment checks, iterating and sorting proxied rows, computed var recomputation with return-type checks, and delta encoding. Base branch: 26.6 ms for the three events; this branch: 13.7 ms. The isolated runtime_isinstance and json_dumps_compact benchmarks are removed in its favour. --- tests/benchmarks/fixtures.py | 70 +++++++++++ tests/benchmarks/test_event_processing.py | 134 ++++++++++++++++------ tests/benchmarks/test_isinstance.py | 24 +--- tests/benchmarks/test_json_dumps.py | 53 --------- 4 files changed, 173 insertions(+), 108 deletions(-) delete mode 100644 tests/benchmarks/test_json_dumps.py diff --git a/tests/benchmarks/fixtures.py b/tests/benchmarks/fixtures.py index 63469330109..f417019d962 100644 --- a/tests/benchmarks/fixtures.py +++ b/tests/benchmarks/fixtures.py @@ -272,6 +272,76 @@ def enter_component( return enter_component +@dataclass +class Order: + """A row of the table state used by the holistic event benchmark.""" + + name: str + customer: str + amount: float + status: str + + +class TableState(rx.State): + """A state with a 1000-row table, a filter, and derived views of the rows. + + One event on it drives the whole per-event runtime path: a base var + assignment, iterating proxied dataclass rows, sorting them, re-running + the computed vars with their return-type checks, and a delta carrying + hundreds of rows. + """ + + orders: rx.Field[list[Order]] = rx.field( + default_factory=lambda: [ + Order( + name=f"order {i}", + customer=f"customer {i % 50}", + amount=i * 1.5, + status=("open", "paid", "shipped")[i % 3], + ) + for i in range(1000) + ] + ) + status: rx.Field[str] = rx.field("") + sort_key: rx.Field[str] = rx.field("amount") + sort_reverse: rx.Field[bool] = rx.field(False) + + @rx.event + def set_status(self, status: str): + """Filter the table by status, flipping the sort direction. + + Args: + status: The status to keep, or an empty string for all rows. + """ + self.status = status + self.sort_reverse = not self.sort_reverse + + @rx.var + def filtered_orders(self) -> list[Order]: + """The rows matching the filter, sorted. + + Returns: + The filtered, sorted rows. + """ + orders = self.orders + if self.status: + orders = [order for order in orders if order.status == self.status] + return sorted( + orders, + key=lambda order: getattr(order, self.sort_key), + reverse=self.sort_reverse, + ) + + @rx.var + def total_amount(self) -> float: + """The amount summed over the filtered rows. + + Returns: + The total amount. + """ + return sum(order.amount for order in self.filtered_orders) + + class BenchmarkState(rx.State): """State for the benchmark.""" diff --git a/tests/benchmarks/test_event_processing.py b/tests/benchmarks/test_event_processing.py index 15acf8094d4..6e4ba9bd281 100644 --- a/tests/benchmarks/test_event_processing.py +++ b/tests/benchmarks/test_event_processing.py @@ -1,13 +1,15 @@ -"""Benchmark for the event processing pipeline. +"""Benchmarks for the event processing pipeline. -Measures the time from enqueuing events via ``BaseStateEventProcessor`` -to collecting all emitted ``StateUpdate`` deltas, with mock emit -callbacks that record the deltas. +Events are enqueued via ``BaseStateEventProcessor`` against a real +``StateManagerMemory`` and every emitted delta is collected. The +``test_process_event`` benchmark times the pipeline alone; the table +benchmark also encodes each delta for the wire the way the socket path does. """ import asyncio import traceback -from collections.abc import Mapping +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping +from contextlib import asynccontextmanager from typing import Any from unittest import mock @@ -17,31 +19,41 @@ from reflex_base.event import Event from reflex_base.event.context import EventContext from reflex_base.event.processor import BaseStateEventProcessor -from reflex_base.utils.format import format_event_handler +from reflex_base.utils.format import format_event_handler, json_dumps_compact from reflex.istate.manager.memory import StateManagerMemory +from reflex.state import StateUpdate -from .fixtures import BenchmarkState +from .fixtures import BenchmarkState, TableState +RunEvents = Callable[[int, int], Awaitable[None]] -@pytest_asyncio.fixture -async def event_processing_harness(): - """Set up the full event processing pipeline for benchmarking. - Creates a ``BaseStateEventProcessor`` wired to a real - ``StateManagerMemory`` with mock emit callbacks. Events are - enqueued directly and deltas are collected via the emit callback. +@asynccontextmanager +async def _event_pipeline( + handler_name: str, + payloads: list[dict[str, Any]], + on_delta: Callable[[Mapping[str, Mapping[str, Any]]], Any], +) -> AsyncIterator[RunEvents]: + """Wire a ``BaseStateEventProcessor`` to an in-memory state manager. + + Args: + handler_name: The formatted event handler name to enqueue. + payloads: The payloads to cycle through, one per enqueued event. + on_delta: Called with each emitted delta. Yields: - An async callable that enqueues the given number of events - and waits for all expected deltas. + An async callable that enqueues the given number of events and + waits for all expected deltas. """ - emitted_deltas: list[tuple[str, Mapping[str, Mapping[str, Any]]]] = [] + emitted = 0 async def emit_delta_impl( # noqa: RUF029 token: str, delta: Mapping[str, Mapping[str, Any]] ) -> None: - emitted_deltas.append((token, delta)) + nonlocal emitted + emitted += 1 + on_delta(delta) async def emit_event_impl(token: str, *events: Event) -> None: pass @@ -68,39 +80,74 @@ def handle_backend_exception(ex: Exception) -> None: processor._root_context = root_context token = "benchmark-token" - handler_name = format_event_handler(BenchmarkState.event_handlers["increment"]) - event = Event( - name=handler_name, - router_data={ - "query": {}, - "path": "/", - }, - payload={}, - ) + events = [ + Event( + name=handler_name, + router_data={"query": {}, "path": "/"}, + payload=payload, + ) + for payload in payloads + ] async def run_events(num_events: int, num_expected_deltas: int) -> None: """Enqueue events and wait for all deltas to be emitted. Args: - num_events: Number of increment events to enqueue. + num_events: Number of events to enqueue, cycling the payloads. num_expected_deltas: How many deltas to wait for. """ - emitted_deltas.clear() + nonlocal emitted + emitted = 0 async with processor as p: async for _ in asyncio.as_completed([ - await p.enqueue(token, event) for _ in range(num_events) + await p.enqueue(token, events[i % len(events)]) + for i in range(num_events) ]): pass - assert len(emitted_deltas) == num_expected_deltas + assert emitted == num_expected_deltas yield run_events await state_manager.close() +@pytest_asyncio.fixture +async def event_processing_harness(): + """Set up the pipeline for ``BenchmarkState.increment`` with a mock emit. + + Yields: + An async callable that enqueues the given number of events + and waits for all expected deltas. + """ + handler_name = format_event_handler(BenchmarkState.event_handlers["increment"]) + async with _event_pipeline(handler_name, [{}], lambda delta: None) as run: + yield run + + +@pytest_asyncio.fixture +async def table_event_harness(): + """Set up the pipeline for ``TableState.set_status`` with wire encoding. + + Each delta is encoded exactly as ``App.emit_update`` hands it to + Socket.IO, so the benchmark covers the full backend cost of an event. + + Yields: + An async callable that enqueues the given number of events + and waits for all expected deltas. + """ + handler_name = format_event_handler(TableState.event_handlers["set_status"]) + payloads = [{"status": "open"}, {"status": ""}, {"status": "paid"}] + + def encode(delta: Mapping[str, Mapping[str, Any]]) -> None: + json_dumps_compact(StateUpdate(delta=delta)) + + async with _event_pipeline(handler_name, payloads, encode) as run: + yield run + + def test_process_event( - event_processing_harness, + event_processing_harness: RunEvents, benchmark: BenchmarkFixture, ): """Benchmark processing 3 increment events through the full pipeline. @@ -119,4 +166,27 @@ def test_process_event( # no yields, so we expect 1 delta per event = 3 total. @benchmark def _(): - loop.run_until_complete(run_events(num_events=3, num_expected_deltas=3)) + loop.run_until_complete(run_events(3, 3)) + + +def test_process_table_event( + table_event_harness: RunEvents, + benchmark: BenchmarkFixture, +): + """Benchmark 3 filter events on a 1000-row table, deltas encoded for the wire. + + Every event assigns base vars, iterates the proxied rows, sorts them, + recomputes both computed vars with their return-type checks, and + produces a delta of hundreds of dataclass rows that is encoded like a + real update. + + Args: + table_event_harness: The run_events async callable. + benchmark: The codspeed benchmark fixture. + """ + run_events = table_event_harness + loop = asyncio.get_event_loop() + + @benchmark + def _(): + loop.run_until_complete(run_events(3, 3)) diff --git a/tests/benchmarks/test_isinstance.py b/tests/benchmarks/test_isinstance.py index 23bf0850766..cac451f5d8a 100644 --- a/tests/benchmarks/test_isinstance.py +++ b/tests/benchmarks/test_isinstance.py @@ -9,7 +9,7 @@ import pytest from pytest_codspeed import BenchmarkFixture -from reflex_base.utils.types import _isinstance, runtime_isinstance +from reflex_base.utils.types import _isinstance N = 10_000 @@ -58,25 +58,3 @@ def test_isinstance_scalar(benchmark: BenchmarkFixture): def _(): for i in _INTS: _isinstance(i, int, nested=1, treat_var_as_type=False) - - -@pytest.mark.parametrize( - ("obj", "hint"), - [ - pytest.param(_INTS, list[int], id="list_int"), - pytest.param(_DICTS, list[dict[str, int]], id="list_dict"), - pytest.param(_OPTIONALS, list[int | None], id="list_optional"), - ], -) -def test_runtime_isinstance_container( - obj: list[Any], hint: type, benchmark: BenchmarkFixture -): - """Benchmark the compiled validator used on state var writes and reads. - - Args: - obj: The container to validate. - hint: The declared var type. - benchmark: The codspeed benchmark fixture. - """ - runtime_isinstance(obj, hint) - benchmark(lambda: runtime_isinstance(obj, hint)) diff --git a/tests/benchmarks/test_json_dumps.py b/tests/benchmarks/test_json_dumps.py deleted file mode 100644 index 16d4cdd2610..00000000000 --- a/tests/benchmarks/test_json_dumps.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Benchmarks for encoding a state delta for the wire. - -``json_dumps_compact`` encodes every delta emitted over the websocket; the -payload mixes plain containers with dataclasses that go through the reflex -serializers. -""" - -import dataclasses - -import pytest -from pytest_codspeed import BenchmarkFixture -from reflex_base.utils.format import json_dumps, json_dumps_compact - -N = 10_000 - - -@dataclasses.dataclass -class _Row: - name: str - qty: int - price: float - - -_ROWS = [_Row(f"row {i}", i, i * 1.5) for i in range(N)] -_DICTS = [{"name": f"row {i}", "qty": i, "price": i * 1.5} for i in range(N)] - - -@pytest.mark.parametrize( - "payload", - [pytest.param(_ROWS, id="dataclasses"), pytest.param(_DICTS, id="dicts")], -) -def test_json_dumps_compact(payload: list, benchmark: BenchmarkFixture): - """Benchmark the wire encoder on a large delta. - - Args: - payload: The delta value to encode. - benchmark: The codspeed benchmark fixture. - """ - benchmark(lambda: json_dumps_compact({"state": {"rows": payload}})) - - -@pytest.mark.parametrize( - "payload", - [pytest.param(_ROWS, id="dataclasses"), pytest.param(_DICTS, id="dicts")], -) -def test_json_dumps_reference(payload: list, benchmark: BenchmarkFixture): - """Benchmark the stdlib-backed encoder on the same delta for comparison. - - Args: - payload: The delta value to encode. - benchmark: The codspeed benchmark fixture. - """ - benchmark(lambda: json_dumps({"state": {"rows": payload}}))