diff --git a/news/7144.bugfix.md b/news/7144.bugfix.md new file mode 100644 index 00000000000..90db3b510ce --- /dev/null +++ b/news/7144.bugfix.md @@ -0,0 +1 @@ +Creating an `rx.App` no longer re-executes `rxconfig.py`, so classes defined there keep their State registration and Python class identity. diff --git a/packages/reflex-base/news/7144.bugfix.md b/packages/reflex-base/news/7144.bugfix.md new file mode 100644 index 00000000000..8b3e7cd1a4e --- /dev/null +++ b/packages/reflex-base/news/7144.bugfix.md @@ -0,0 +1 @@ +The first `get_config()` call in a process reuses an `rxconfig` module that the app already imported instead of executing `rxconfig.py` again, so classes defined there (for example an `rx.State` subclass) keep a single identity under any ASGI server. diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index ba77431fbf1..c24945078a7 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -850,6 +850,10 @@ def _set_persistent(self, **kwargs): _config_module_deps: set[str] = set() _config_module_deps_root: Path | None = None +# The rxconfig module that _get_config last executed. Any other rxconfig in +# sys.modules was imported by project code. Only mutated under _load_config_lock. +_loaded_config_module: ModuleType | None = None + class _ImportRecorder: """Meta-path finder that records import attempts made on one thread. @@ -958,6 +962,28 @@ def get_state_auto_setters() -> bool: return False +def _imported_config(project_root: Path) -> Config | None: + """Get the config of an rxconfig module that project code already imported. + + Executing rxconfig.py again would create a second copy of every class it + defines, distinct from the one the app module holds. + + Args: + project_root: The root the imported module must live under. + + Returns: + The imported module's config, or None when project code has not + imported rxconfig from project_root. + """ + module = sys.modules.get(constants.Config.MODULE) + if module is None or module is _loaded_config_module: + return None + origin = getattr(module, "__file__", None) + if not origin or not Path(origin).is_relative_to(project_root): + return None + return getattr(module, "config", None) + + def _get_config(project_root: Path | None = None) -> Config: """Import rxconfig.py fresh from the project root and return its config. @@ -975,7 +1001,7 @@ def _get_config(project_root: Path | None = None) -> Config: Returns: The app config. """ - global _config_module_deps_root + global _config_module_deps_root, _loaded_config_module project_root = (project_root or Path.cwd()).resolve() with _load_config_lock: @@ -1005,6 +1031,7 @@ def _get_config(project_root: Path | None = None) -> Config: with _record_imports() as recorder: try: rxconfig = importlib.import_module(constants.Config.MODULE) + _loaded_config_module = rxconfig finally: # Record even on failure so a later load from another root # evicts what this one imported. Nothing is evicted here: @@ -1073,7 +1100,7 @@ def get_config(reload: bool = False) -> Config: # Serialize check/load/set so threads sharing a context load once. with _load_config_lock: if ctx._config is None: - ctx._set_config(_get_config()) + ctx._set_config(_imported_config(Path.cwd().resolve()) or _get_config()) return ctx.config diff --git a/reflex/app.py b/reflex/app.py index 558a1d0f607..a0d597cb201 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -30,7 +30,7 @@ from reflex_base import constants, otel from reflex_base.components.component import Component, ComponentStyle -from reflex_base.config import get_config, reload_config +from reflex_base.config import get_config from reflex_base.context.base import BaseContext from reflex_base.environment import environment from reflex_base.event import ( @@ -537,7 +537,7 @@ def __post_init__(self): self._registration_context._set_app(self) - reload_config() + get_config() if "breakpoints" in self.style: set_breakpoints(self.style.pop("breakpoints")) diff --git a/reflex/reflex.py b/reflex/reflex.py index aa7b7c14b1d..78a6a72af50 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -578,9 +578,6 @@ def _run( if backend_host != config.backend_host: config._set_persistent(backend_host=backend_host) - # Reload the config to make sure the env vars are persistent. - reload_config() - console.rule("[bold]Starting Reflex App") prerequisites.check_latest_package_version(constants.Reflex.MODULE_NAME) diff --git a/tests/units/conftest.py b/tests/units/conftest.py index 986e6f166f2..be32824ea5e 100644 --- a/tests/units/conftest.py +++ b/tests/units/conftest.py @@ -37,19 +37,21 @@ @pytest.fixture(autouse=True) def _isolate_app_in_context() -> Generator[None, None, None]: - """Reset the App slot on the active RegistrationContext between tests. + """Reset the App and Config slots on the active context between tests. - A RegistrationContext can only host one App instance, but unit tests - repeatedly instantiate `rx.App`, so we clear `_app` around each test - while keeping other registrations shared (matching prior behavior). + Unit tests repeatedly instantiate `rx.App` with different mocked configs. + Keep class registrations shared, but do not let an earlier test's App or + Config leak into the next one. Yields: None. """ ctx = RegistrationContext.ensure_context() object.__setattr__(ctx, "_app", None) + object.__setattr__(ctx, "_config", None) yield object.__setattr__(ctx, "_app", None) + object.__setattr__(ctx, "_config", None) @pytest.fixture diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 8917ef90838..0aa5b3ceaa0 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -5,12 +5,14 @@ import contextlib import contextvars import functools +import importlib import io import json import logging import multiprocessing import pickle import re +import sys import unittest.mock import uuid from collections.abc import Generator @@ -22,13 +24,16 @@ import pytest import reflex_base +import reflex_base.config from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from pytest_mock import MockerFixture +from reflex_base import constants as base_constants from reflex_base import otel from reflex_base.components.component import Component +from reflex_base.config import get_config from reflex_base.constants.state import FIELD_MARKER from reflex_base.event import Event from reflex_base.event.context import EventContext @@ -109,6 +114,85 @@ class EmptyState(BaseState): """An empty state.""" +def test_app_reuses_preloaded_config_with_state( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """Creating an app does not re-execute a config that registered state. + + Args: + tmp_path: The pytest temporary project directory. + monkeypatch: The pytest monkeypatch fixture. + """ + (tmp_path / base_constants.Config.FILE).write_text( + "import reflex as rx\n\n" + "class ConfigState(rx.State):\n" + " value: str = ''\n\n" + "class ConfigClass:\n" + " pass\n\n" + "config = rx.Config(app_name='config_state_app')\n" + ) + monkeypatch.chdir(tmp_path) + config_module_name = base_constants.Config.MODULE + monkeypatch.delitem(sys.modules, config_module_name, raising=False) + previous_state_auto_setters = reflex_base.config._state_auto_setters + reflex_base.config._state_auto_setters = True + + try: + with RegistrationContext(): + config = get_config() + state = sys.modules[config_module_name].ConfigState + instance = sys.modules[config_module_name].ConfigClass() + + App(enable_state=False) + + assert get_config() is config + assert sys.modules[config_module_name].ConfigState is state + assert type(pickle.loads(pickle.dumps(instance))) is type(instance) + finally: + sys.modules.pop(config_module_name, None) + reflex_base.config._config_module_deps.clear() + reflex_base.config._config_module_deps_root = None + reflex_base.config._state_auto_setters = previous_state_auto_setters + + assert reflex_base.config._state_auto_setters is previous_state_auto_setters + + +def test_app_reuses_config_module_imported_by_app( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """An app module that imports rxconfig before creating the App keeps its classes. + + Args: + tmp_path: The pytest temporary project directory. + monkeypatch: The pytest monkeypatch fixture. + """ + (tmp_path / base_constants.Config.FILE).write_text( + "import reflex as rx\n\n" + "class DirectConfigState(rx.State):\n" + " value: str = ''\n\n" + "config = rx.Config(app_name='direct_config_app')\n" + ) + monkeypatch.chdir(tmp_path) + monkeypatch.syspath_prepend(str(tmp_path)) + config_module_name = base_constants.Config.MODULE + monkeypatch.delitem(sys.modules, config_module_name, raising=False) + previous_state_auto_setters = reflex_base.config._state_auto_setters + + try: + with RegistrationContext(): + rxconfig = importlib.import_module(config_module_name) + + App(_state=rxconfig.DirectConfigState) + + assert sys.modules[config_module_name] is rxconfig + assert get_config() is rxconfig.config + finally: + sys.modules.pop(config_module_name, None) + reflex_base.config._config_module_deps.clear() + reflex_base.config._config_module_deps_root = None + reflex_base.config._state_auto_setters = previous_state_auto_setters + + @pytest.fixture def index_page() -> ComponentCallable: """An index page.