From 2d3ea84d20a36bbfeb7c8f642e2bed1502b3b3f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 08:27:06 +0000 Subject: [PATCH] Do not import reflex.model while transforming an event payload _transform_event_arg ran `from reflex.model import Model` for every dict payload with a class annotation (dict, dataclass, TypedDict, Pydantic model). The first such event therefore imported reflex.model, and with the db extra SQLAlchemy/SQLModel/alembic (0.22-0.56 s, ~143 modules), inside the event task, and the dev event-loop blocking monitor (#7672) reported the user's trivial handler as blocking the loop. A class can only subclass rx.Model after reflex.model has run, so look the module up in sys.modules instead of importing it, as telemetry_accounting._get_db_model_count already does. Apps that never use rx.Model never load SQLAlchemy, at startup or per event, and apps that do already loaded it when their models were defined. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dxjadq3qkhKnqZR2b9WBFi --- .../news/+dict-arg-no-model-import.bugfix.md | 1 + .../event/processor/base_state_processor.py | 8 +- .../processor/test_base_state_processor.py | 139 +++++++++++++++++- 3 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 packages/reflex-base/news/+dict-arg-no-model-import.bugfix.md diff --git a/packages/reflex-base/news/+dict-arg-no-model-import.bugfix.md b/packages/reflex-base/news/+dict-arg-no-model-import.bugfix.md new file mode 100644 index 00000000000..1b4f59adda5 --- /dev/null +++ b/packages/reflex-base/news/+dict-arg-no-model-import.bugfix.md @@ -0,0 +1 @@ +The first event of a handler that takes a `dict`, dataclass, `TypedDict` or Pydantic model argument no longer imports `reflex.model`, and with it SQLAlchemy, on the event loop, so the development blocking monitor no longer reports that handler as blocking the event loop. diff --git a/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py b/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py index 18d4435b376..7f3115e3c76 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py @@ -7,6 +7,7 @@ import functools import inspect import logging +import sys import warnings from collections.abc import Mapping, Sequence from enum import Enum @@ -176,9 +177,10 @@ def _transform_event_arg(value: Any, hinted_args: Any) -> Any: return value hinted_args = types.value_inside_optional(hinted_args) if isinstance(value, dict) and isinstance(hinted_args, type): - from reflex.model import Model - - if issubclass(hinted_args, Model): + # No class subclasses rx.Model before reflex.model is loaded, so never + # import it (and SQLAlchemy with it) here, inside the event. + model = getattr(sys.modules.get("reflex.model"), "Model", None) + if model is not None and issubclass(hinted_args, model): # Remove non-fields from the payload model_fields = hinted_args.model_fields return hinted_args(**{ diff --git a/tests/units/reflex_base/event/processor/test_base_state_processor.py b/tests/units/reflex_base/event/processor/test_base_state_processor.py index d086708b473..3ee57ed5432 100644 --- a/tests/units/reflex_base/event/processor/test_base_state_processor.py +++ b/tests/units/reflex_base/event/processor/test_base_state_processor.py @@ -5,14 +5,19 @@ import contextlib import dataclasses import gc +import importlib.abc +import importlib.util import inspect import logging +import sys +import time import traceback import warnings import weakref from collections.abc import Mapping from pathlib import Path -from typing import Any, cast +from types import ModuleType +from typing import Any, TypedDict, cast import pytest import pytest_asyncio @@ -2280,6 +2285,138 @@ class Post(rx.Model): assert not post.model_extra +class _TagsDict(TypedDict): + tag: str + name: str + + +@pytest.mark.parametrize( + ("hint", "expected"), + [ + (dict, {"tag": "b", "name": "x"}), + (_TagsDict, {"tag": "b", "name": "x"}), + (_TagsRecord, _TagsRecord(tag="b", name="x")), + ], +) +def test_transform_event_arg_dict_does_not_import_the_model_module( + monkeypatch: pytest.MonkeyPatch, hint: type, expected: Any +): + """A dict payload is transformed without importing reflex.model. + + With the db extra, importing reflex.model loads SQLAlchemy inside the event. + No class can subclass rx.Model while the module is not loaded. + + Args: + monkeypatch: The pytest monkeypatch fixture. + hint: The handler's annotation for the argument. + expected: The transformed argument. + """ + # A None entry makes any import of reflex.model raise ImportError. + monkeypatch.setitem(sys.modules, "reflex.model", None) + assert _transform_event_arg({"tag": "b", "name": "x"}, hint) == expected + + +class _SlowModelImport(importlib.abc.MetaPathFinder, importlib.abc.Loader): + """Stand in for reflex.model with an import as slow as loading SQLAlchemy.""" + + def __init__(self, seconds: float): + """Create the finder. + + Args: + seconds: How long executing the module blocks. + """ + self.seconds = seconds + self.imported = False + + def find_spec(self, fullname: str, path: Any, target: Any = None): + """Find reflex.model only. + + Args: + fullname: The module name. + path: The parent package path. + target: The module being reloaded, if any. + + Returns: + The spec of the slow module, or None for any other module. + """ + if fullname != "reflex.model": + return None + return importlib.util.spec_from_loader(fullname, self) + + def create_module(self, spec: Any) -> None: + """Use the default module creation. + + Args: + spec: The module spec. + """ + + def exec_module(self, module: ModuleType) -> None: + """Block like a heavy import, then define Model. + + Args: + module: The module to execute. + """ + self.imported = True + time.sleep(self.seconds) + module.Model = type("Model", (), {}) # pyright: ignore[reportAttributeAccessIssue] + + +async def test_dict_arg_handler_first_event_does_not_block_on_the_model_import( + wired_app: App, + real_base_state_processor: BaseStateEventProcessor, + token: str, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +): + """The first event of a handler taking a dict is not reported as blocking. + + Reflex used to import reflex.model, and with it SQLAlchemy, while it + transformed the payload, so the dev monitor blamed the handler. + + Args: + wired_app: The App wired to the processor's state manager. + real_base_state_processor: The unmocked BaseStateEventProcessor. + token: The client token. + monkeypatch: The pytest monkeypatch fixture. + caplog: The pytest log capture fixture. + """ + slow_import = _SlowModelImport(seconds=0.3) + # Record the current entries first, so teardown restores (or removes) them. + for namespace, key in ((sys.modules, "reflex.model"), (vars(rx), "model")): + monkeypatch.setitem(namespace, key, None) + monkeypatch.delitem(namespace, key) + monkeypatch.setattr(sys, "meta_path", [slow_import, *sys.meta_path]) + monkeypatch.setenv("REFLEX_ENV_MODE", constants.Env.DEV.value) + real_base_state_processor.event_loop_blocking_threshold = 0.05 + caplog.set_level(logging.WARNING, logger="reflex_base.event.processor.blocking") + + class ProgressState(State): + progress: dict[str, int] = {} + + @event + def on_progress(self, prog: dict): + self.progress = prog + + async with real_base_state_processor as processor: + await processor.enqueue( + token, + Event( + name=f"{ProgressState.get_full_name()}.on_progress", + payload={"prog": {"loaded": 5, "total": 10}}, + router_data={"pathname": "/", "asPath": "/", "query": {}}, + ), + ) + await processor.join(2) + + async with _read_back(real_base_state_processor, token) as root: + assert (await root.get_state(ProgressState)).progress == { + "loaded": 5, + "total": 10, + } + assert not slow_import.imported + assert "is blocking the event loop" not in caplog.text + + def test_prepare_event_payload_transforms_form_data(): """A payload is transformed for the handler's annotations."""