From fe952bca61685c9cd8c94fbc7aabed7761fe3128 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 15 Sep 2026 02:57:54 +0500 Subject: [PATCH 1/5] fix(config): preserve preloaded rxconfig classes --- ...reserve-preloaded-config-classes.bugfix.md | 1 + .../news/+reuse-preloaded-rxconfig.bugfix.md | 1 + .../reflex-base/src/reflex_base/config.py | 27 ++++++++++-- reflex/app.py | 4 +- reflex/reflex.py | 3 -- tests/units/conftest.py | 10 +++-- tests/units/test_app.py | 42 +++++++++++++++++++ tests/units/test_config.py | 31 ++++++++++++++ 8 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 news/+preserve-preloaded-config-classes.bugfix.md create mode 100644 packages/reflex-base/news/+reuse-preloaded-rxconfig.bugfix.md diff --git a/news/+preserve-preloaded-config-classes.bugfix.md b/news/+preserve-preloaded-config-classes.bugfix.md new file mode 100644 index 00000000000..bb17a76829f --- /dev/null +++ b/news/+preserve-preloaded-config-classes.bugfix.md @@ -0,0 +1 @@ +Avoid re-executing classes defined in `rxconfig.py` when an app is created, preserving their State registration and Python class identity. diff --git a/packages/reflex-base/news/+reuse-preloaded-rxconfig.bugfix.md b/packages/reflex-base/news/+reuse-preloaded-rxconfig.bugfix.md new file mode 100644 index 00000000000..2ac6e4e5705 --- /dev/null +++ b/packages/reflex-base/news/+reuse-preloaded-rxconfig.bugfix.md @@ -0,0 +1 @@ +Preserve classes imported directly from `rxconfig.py` when the application initializes its configuration. diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index ba77431fbf1..4177c945715 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -1022,6 +1022,25 @@ def _get_config(project_root: Path | None = None) -> Config: break +def _get_preloaded_config(project_root: Path | None = None) -> Config | None: + """Return config from an already-imported project-local rxconfig module. + + Args: + project_root: Directory the loaded module must belong to. Defaults to + the current working directory. + + Returns: + The preloaded config, if rxconfig.py was already imported from this + project. + """ + rxconfig = sys.modules.get(constants.Config.MODULE) + origin = getattr(rxconfig, "__file__", None) + project_root = (project_root or Path.cwd()).resolve() + if origin and Path(origin).resolve().is_relative_to(project_root): + return getattr(rxconfig, "config", None) + return None + + if TYPE_CHECKING: from typing_extensions import deprecated @@ -1049,8 +1068,9 @@ def get_config(reload: bool = False) -> Config: """Get the app config from the current RegistrationContext. The config is loaded from rxconfig.py once per RegistrationContext and - cached on the context thereafter. If no context is currently attached, - one is created and attached automatically. + cached on the context thereafter. If the current project's rxconfig.py was + already imported, its config is cached without re-executing the module. If + no context is currently attached, one is created and attached automatically. Args: reload: Deprecated; force a fresh load of the config. Use @@ -1073,7 +1093,8 @@ 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()) + config = _get_preloaded_config() + ctx._set_config(config if config is not None else _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..c58024fa74b 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,6 +24,7 @@ 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 @@ -29,6 +32,7 @@ from pytest_mock import MockerFixture 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 +113,44 @@ 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 / "rxconfig.py").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) + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.delitem(sys.modules, "rxconfig", raising=False) + + try: + with RegistrationContext(): + config_module = importlib.import_module("rxconfig") + state = config_module.ConfigState + instance = config_module.ConfigClass() + + App(enable_state=False) + + assert get_config() is config_module.config + assert sys.modules["rxconfig"].ConfigState is state + assert type(pickle.loads(pickle.dumps(instance))) is type(instance) + finally: + sys.modules.pop("rxconfig", None) + reflex_base.config._config_module_deps.clear() + reflex_base.config._config_module_deps_root = None + + @pytest.fixture def index_page() -> ComponentCallable: """An index page. diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 676430f596d..0bea10c7608 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -1120,6 +1120,37 @@ def test_get_config_accepts_explicit_project_root( assert reflex_base.config._get_config(project).app_name == "explicit" +def test_get_config_ignores_preloaded_rxconfig_from_another_project( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None +): + """A preloaded config is reused only by the project that imported it. + + Args: + tmp_path: The pytest temporary project directory. + monkeypatch: The pytest monkeypatch fixture. + clean_config_modules: Cleanup for modules left behind by the load. + """ + from reflex_base.registry import RegistrationContext + + first = tmp_path / "first" + first.mkdir() + (first / "rxconfig.py").write_text( + "import reflex as rx\nconfig = rx.Config(app_name='first')\n" + ) + second = tmp_path / "second" + second.mkdir() + (second / "rxconfig.py").write_text( + "import reflex as rx\nconfig = rx.Config(app_name='second')\n" + ) + + monkeypatch.chdir(first) + assert reflex_base.config._get_config().app_name == "first" + + monkeypatch.chdir(second) + with RegistrationContext(): + assert reflex_base.config.get_config().app_name == "second" + + @pytest.fixture def clean_config_modules() -> Generator[None, None, None]: """Drop the modules and dep records a real rxconfig load leaves behind. From dec17718fefadbcffb809bde6320ca5f58f1db63 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 15 Sep 2026 03:12:37 +0500 Subject: [PATCH 2/5] fix(config): initialize backend config before app import --- .../news/+reuse-preloaded-rxconfig.bugfix.md | 1 - .../reflex-base/src/reflex_base/config.py | 27 ++-------- reflex/utils/exec.py | 51 +++++------------- tests/units/test_app.py | 20 +++---- tests/units/test_config.py | 31 ----------- tests/units/utils/test_exec.py | 54 +++++++++++++++++-- 6 files changed, 76 insertions(+), 108 deletions(-) delete mode 100644 packages/reflex-base/news/+reuse-preloaded-rxconfig.bugfix.md diff --git a/packages/reflex-base/news/+reuse-preloaded-rxconfig.bugfix.md b/packages/reflex-base/news/+reuse-preloaded-rxconfig.bugfix.md deleted file mode 100644 index 2ac6e4e5705..00000000000 --- a/packages/reflex-base/news/+reuse-preloaded-rxconfig.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Preserve classes imported directly from `rxconfig.py` when the application initializes its configuration. diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 4177c945715..ba77431fbf1 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -1022,25 +1022,6 @@ def _get_config(project_root: Path | None = None) -> Config: break -def _get_preloaded_config(project_root: Path | None = None) -> Config | None: - """Return config from an already-imported project-local rxconfig module. - - Args: - project_root: Directory the loaded module must belong to. Defaults to - the current working directory. - - Returns: - The preloaded config, if rxconfig.py was already imported from this - project. - """ - rxconfig = sys.modules.get(constants.Config.MODULE) - origin = getattr(rxconfig, "__file__", None) - project_root = (project_root or Path.cwd()).resolve() - if origin and Path(origin).resolve().is_relative_to(project_root): - return getattr(rxconfig, "config", None) - return None - - if TYPE_CHECKING: from typing_extensions import deprecated @@ -1068,9 +1049,8 @@ def get_config(reload: bool = False) -> Config: """Get the app config from the current RegistrationContext. The config is loaded from rxconfig.py once per RegistrationContext and - cached on the context thereafter. If the current project's rxconfig.py was - already imported, its config is cached without re-executing the module. If - no context is currently attached, one is created and attached automatically. + cached on the context thereafter. If no context is currently attached, + one is created and attached automatically. Args: reload: Deprecated; force a fresh load of the config. Use @@ -1093,8 +1073,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: - config = _get_preloaded_config() - ctx._set_config(config if config is not None else _get_config()) + ctx._set_config(_get_config()) return ctx.config diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 723d57bbf9e..dce609120d2 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -17,7 +17,7 @@ from typing import Any, NamedTuple, TypedDict from reflex_base import constants -from reflex_base.config import get_config +from reflex_base.config import get_config, reload_config from reflex_base.constants.base import LogLevel from reflex_base.environment import environment from reflex_base.telemetry_context import CompileTrigger @@ -443,52 +443,25 @@ def should_use_granian(): return False -def get_app_module(): - """Get the app module for the backend. - - Returns: - The app module for the backend. - """ - return get_config().module - - def get_app_instance(): - """Get the app module for the backend. + """Return the backend app factory target. Returns: - The app module for the backend. + The import path for the backend app factory. """ - return f"{get_app_module()}:{constants.CompileVars.APP}" + return f"{__name__}:load_app" -def get_app_file() -> Path: - """Get the app file for the backend. +def load_app(): + """Load config before importing the backend app. Returns: - The app file for the backend. - - Raises: - ImportError: If the app module is not found. + The backend ASGI app. """ - current_working_dir = str(Path.cwd()) - if current_working_dir not in sys.path: - # Add the current working directory to sys.path - sys.path.insert(0, current_working_dir) - app_module = get_app_module() - module_path = get_module_path(app_module) - if module_path is None: - msg = f"Module {app_module} not found. Make sure the module is installed." - raise ImportError(msg) - return module_path - + from reflex.utils.prerequisites import get_and_validate_app -def get_app_instance_from_file() -> str: - """Get the app module for the backend. - - Returns: - The app module for the backend. - """ - return f"{get_app_file()}:{constants.CompileVars.APP}" + reload_config() + return get_and_validate_app().app() def run_backend( @@ -708,7 +681,7 @@ def run_granian_backend(host: str, port: int, loglevel: LogLevel): environment.REFLEX_DEV_BACKEND_RELOAD_ACTIVE.set(True) granian_app = Granian( - target=get_app_instance_from_file(), + target=get_app_instance(), factory=True, address=host, port=port, @@ -835,7 +808,7 @@ def run_granian_backend_prod( logger.debug("Using Granian for backend") granian_app = Granian( - target=app_target or get_app_instance_from_file(), + target=app_target or get_app_instance(), factory=True, address=host, port=port, diff --git a/tests/units/test_app.py b/tests/units/test_app.py index c58024fa74b..c31d2c03ae5 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -5,7 +5,6 @@ import contextlib import contextvars import functools -import importlib import io import json import logging @@ -30,6 +29,7 @@ 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 @@ -122,7 +122,7 @@ def test_app_reuses_preloaded_config_with_state( tmp_path: The pytest temporary project directory. monkeypatch: The pytest monkeypatch fixture. """ - (tmp_path / "rxconfig.py").write_text( + (tmp_path / base_constants.Config.FILE).write_text( "import reflex as rx\n\n" "class ConfigState(rx.State):\n" " value: str = ''\n\n" @@ -131,22 +131,22 @@ def test_app_reuses_preloaded_config_with_state( "config = rx.Config(app_name='config_state_app')\n" ) monkeypatch.chdir(tmp_path) - monkeypatch.syspath_prepend(str(tmp_path)) - monkeypatch.delitem(sys.modules, "rxconfig", raising=False) + config_module_name = base_constants.Config.MODULE + monkeypatch.delitem(sys.modules, config_module_name, raising=False) try: with RegistrationContext(): - config_module = importlib.import_module("rxconfig") - state = config_module.ConfigState - instance = config_module.ConfigClass() + 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_module.config - assert sys.modules["rxconfig"].ConfigState is state + 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("rxconfig", None) + sys.modules.pop(config_module_name, None) reflex_base.config._config_module_deps.clear() reflex_base.config._config_module_deps_root = None diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 0bea10c7608..676430f596d 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -1120,37 +1120,6 @@ def test_get_config_accepts_explicit_project_root( assert reflex_base.config._get_config(project).app_name == "explicit" -def test_get_config_ignores_preloaded_rxconfig_from_another_project( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None -): - """A preloaded config is reused only by the project that imported it. - - Args: - tmp_path: The pytest temporary project directory. - monkeypatch: The pytest monkeypatch fixture. - clean_config_modules: Cleanup for modules left behind by the load. - """ - from reflex_base.registry import RegistrationContext - - first = tmp_path / "first" - first.mkdir() - (first / "rxconfig.py").write_text( - "import reflex as rx\nconfig = rx.Config(app_name='first')\n" - ) - second = tmp_path / "second" - second.mkdir() - (second / "rxconfig.py").write_text( - "import reflex as rx\nconfig = rx.Config(app_name='second')\n" - ) - - monkeypatch.chdir(first) - assert reflex_base.config._get_config().app_name == "first" - - monkeypatch.chdir(second) - with RegistrationContext(): - assert reflex_base.config.get_config().app_name == "second" - - @pytest.fixture def clean_config_modules() -> Generator[None, None, None]: """Drop the modules and dep records a real rxconfig load leaves behind. diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index 1de9840f593..d295c9b6632 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -3,11 +3,15 @@ import builtins import multiprocessing import os +import sys from pathlib import Path import pytest +import reflex_base.config from pytest_mock import MockerFixture +from reflex_base import constants from reflex_base.environment import environment +from reflex_base.registry import RegistrationContext from reflex_base.utils import serializers from reflex.utils import exec as exec_utils @@ -15,6 +19,52 @@ DEV_BACKEND_RELOAD_ENV_NAME = environment.REFLEX_DEV_BACKEND_RELOAD_ACTIVE.name +def test_load_app_initializes_config_before_importing_app( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The backend factory must load config before an app imports rxconfig classes. + + Args: + tmp_path: The pytest temporary project directory. + monkeypatch: The pytest monkeypatch fixture. + """ + package = tmp_path / "config_first_app" + package.mkdir() + (package / "__init__.py").touch() + (package / "config_first_app.py").write_text( + "import reflex as rx\n" + "from rxconfig import ConfigState\n\n" + "def index():\n" + " return rx.text(ConfigState.value)\n\n" + "app = rx.App(_state=ConfigState)\n" + "app.add_page(index)\n" + ) + (tmp_path / constants.Config.FILE).write_text( + "import reflex as rx\n\n" + "class ConfigState(rx.State):\n" + " value: str = ''\n\n" + "config = rx.Config(app_name='config_first_app')\n" + ) + monkeypatch.chdir(tmp_path) + monkeypatch.syspath_prepend(str(tmp_path)) + config_module_name = constants.Config.MODULE + monkeypatch.delitem(sys.modules, config_module_name, raising=False) + monkeypatch.delitem(sys.modules, "config_first_app", raising=False) + monkeypatch.delitem(sys.modules, "config_first_app.config_first_app", raising=False) + + try: + with RegistrationContext(): + assert exec_utils.load_app() is not None + app_module = sys.modules["config_first_app.config_first_app"] + assert app_module.app._state is sys.modules[config_module_name].ConfigState + finally: + sys.modules.pop(config_module_name, None) + sys.modules.pop("config_first_app", None) + sys.modules.pop("config_first_app.config_first_app", None) + reflex_base.config._config_module_deps.clear() + reflex_base.config._config_module_deps_root = None + + @pytest.mark.parametrize("frontend_present", [False, True]) def test_run_backend_manages_nocompile_marker( tmp_path: Path, @@ -130,9 +180,7 @@ def test_run_granian_backend_sets_reload_env_var_and_clears_marker( mocker.patch.object( exec_utils, "get_dev_backend_reload_marker", return_value=marker ) - mocker.patch.object( - exec_utils, "get_app_instance_from_file", return_value="app:app" - ) + mocker.patch.object(exec_utils, "get_app_instance", return_value="app:app") mocker.patch.object(exec_utils, "get_reload_paths", return_value=[]) seen: dict[str, str | None] = {} From fe3b1bd3a8730e6252b6fe6c1cba0c317b930f58 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 15 Sep 2026 03:20:18 +0500 Subject: [PATCH 3/5] fix(config): preserve inherited config in backend workers --- reflex/utils/exec.py | 4 ++-- tests/units/test_app.py | 5 +++++ tests/units/utils/test_exec.py | 18 ++++++++++++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index dce609120d2..77f3d94e0ff 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -17,7 +17,7 @@ from typing import Any, NamedTuple, TypedDict from reflex_base import constants -from reflex_base.config import get_config, reload_config +from reflex_base.config import get_config from reflex_base.constants.base import LogLevel from reflex_base.environment import environment from reflex_base.telemetry_context import CompileTrigger @@ -460,7 +460,7 @@ def load_app(): """ from reflex.utils.prerequisites import get_and_validate_app - reload_config() + get_config() return get_and_validate_app().app() diff --git a/tests/units/test_app.py b/tests/units/test_app.py index c31d2c03ae5..f8afb80bb8f 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -133,6 +133,8 @@ def test_app_reuses_preloaded_config_with_state( 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(): @@ -149,6 +151,9 @@ def test_app_reuses_preloaded_config_with_state( 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 @pytest.fixture diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index d295c9b6632..a458a46ea76 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -19,14 +19,16 @@ DEV_BACKEND_RELOAD_ENV_NAME = environment.REFLEX_DEV_BACKEND_RELOAD_ACTIVE.name +@pytest.mark.parametrize("preload_app", [False, True]) def test_load_app_initializes_config_before_importing_app( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, preload_app: bool ) -> None: - """The backend factory must load config before an app imports rxconfig classes. + """The backend factory preserves the config classes used by a preloaded app. Args: tmp_path: The pytest temporary project directory. monkeypatch: The pytest monkeypatch fixture. + preload_app: Whether the supervisor has already imported the app. """ package = tmp_path / "config_first_app" package.mkdir() @@ -51,12 +53,24 @@ def test_load_app_initializes_config_before_importing_app( monkeypatch.delitem(sys.modules, config_module_name, raising=False) monkeypatch.delitem(sys.modules, "config_first_app", raising=False) monkeypatch.delitem(sys.modules, "config_first_app.config_first_app", raising=False) + inherited_state: type[object] | None = None + inherited_app: object | None = None try: with RegistrationContext(): + if preload_app: + from reflex.utils.prerequisites import get_and_validate_app + + reflex_base.config.get_config() + inherited_state = sys.modules[config_module_name].ConfigState + inherited_app = get_and_validate_app().module.app + assert exec_utils.load_app() is not None app_module = sys.modules["config_first_app.config_first_app"] assert app_module.app._state is sys.modules[config_module_name].ConfigState + if preload_app: + assert app_module.app is inherited_app + assert app_module.app._state is inherited_state finally: sys.modules.pop(config_module_name, None) sys.modules.pop("config_first_app", None) From b36f309f294f55588b7492a6219ff4a33abc343b Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 15 Sep 2026 19:39:57 +0500 Subject: [PATCH 4/5] test(exec): restore state_auto_setters after loading the config-first app --- tests/units/utils/test_exec.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index a458a46ea76..38c513267d1 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -55,6 +55,7 @@ def test_load_app_initializes_config_before_importing_app( monkeypatch.delitem(sys.modules, "config_first_app.config_first_app", raising=False) inherited_state: type[object] | None = None inherited_app: object | None = None + previous_state_auto_setters = reflex_base.config._state_auto_setters try: with RegistrationContext(): @@ -77,6 +78,9 @@ def test_load_app_initializes_config_before_importing_app( sys.modules.pop("config_first_app.config_first_app", 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 @pytest.mark.parametrize("frontend_present", [False, True]) From 98fbd9fc0dc0ad4972c522969b0851443c24951c Mon Sep 17 00:00:00 2001 From: Farhan Date: Sat, 19 Sep 2026 01:30:32 +0500 Subject: [PATCH 5/5] fix(config): reuse an rxconfig the app already imported instead of a backend factory shim The first get_config() load of a context re-executed rxconfig.py even when the app module had already imported it, so a direct ASGI invocation still got a second copy of every class. Reuse the imported module for that first load and drop the load_app factory; exec.py is back to its main version. --- ...reserve-preloaded-config-classes.bugfix.md | 1 - news/7144.bugfix.md | 1 + packages/reflex-base/news/7144.bugfix.md | 1 + .../reflex-base/src/reflex_base/config.py | 31 +++++++- reflex/utils/exec.py | 49 +++++++++--- tests/units/test_app.py | 37 +++++++++ tests/units/utils/test_exec.py | 77 ++----------------- 7 files changed, 113 insertions(+), 84 deletions(-) delete mode 100644 news/+preserve-preloaded-config-classes.bugfix.md create mode 100644 news/7144.bugfix.md create mode 100644 packages/reflex-base/news/7144.bugfix.md diff --git a/news/+preserve-preloaded-config-classes.bugfix.md b/news/+preserve-preloaded-config-classes.bugfix.md deleted file mode 100644 index bb17a76829f..00000000000 --- a/news/+preserve-preloaded-config-classes.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Avoid re-executing classes defined in `rxconfig.py` when an app is created, preserving their State registration and Python class identity. 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/utils/exec.py b/reflex/utils/exec.py index 05f1d7a2b8c..b55d8352f41 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -444,25 +444,52 @@ def should_use_granian(): return False +def get_app_module(): + """Get the app module for the backend. + + Returns: + The app module for the backend. + """ + return get_config().module + + def get_app_instance(): - """Return the backend app factory target. + """Get the app module for the backend. Returns: - The import path for the backend app factory. + The app module for the backend. """ - return f"{__name__}:load_app" + return f"{get_app_module()}:{constants.CompileVars.APP}" -def load_app(): - """Load config before importing the backend app. +def get_app_file() -> Path: + """Get the app file for the backend. Returns: - The backend ASGI app. + The app file for the backend. + + Raises: + ImportError: If the app module is not found. """ - from reflex.utils.prerequisites import get_and_validate_app + current_working_dir = str(Path.cwd()) + if current_working_dir not in sys.path: + # Add the current working directory to sys.path + sys.path.insert(0, current_working_dir) + app_module = get_app_module() + module_path = get_module_path(app_module) + if module_path is None: + msg = f"Module {app_module} not found. Make sure the module is installed." + raise ImportError(msg) + return module_path + - get_config() - return get_and_validate_app().app() +def get_app_instance_from_file() -> str: + """Get the app module for the backend. + + Returns: + The app module for the backend. + """ + return f"{get_app_file()}:{constants.CompileVars.APP}" def run_backend( @@ -700,7 +727,7 @@ def _init_shared_socket(self): environment.REFLEX_DEV_BACKEND_RELOAD_ACTIVE.set(True) granian_app = ParentBoundGranian( - target=get_app_instance(), + target=get_app_instance_from_file(), factory=True, address=host, port=port, @@ -827,7 +854,7 @@ def run_granian_backend_prod( logger.debug("Using Granian for backend") granian_app = Granian( - target=app_target or get_app_instance(), + target=app_target or get_app_instance_from_file(), factory=True, address=host, port=port, diff --git a/tests/units/test_app.py b/tests/units/test_app.py index f8afb80bb8f..0aa5b3ceaa0 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -5,6 +5,7 @@ import contextlib import contextvars import functools +import importlib import io import json import logging @@ -156,6 +157,42 @@ def test_app_reuses_preloaded_config_with_state( 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. diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index ef7cf49aefd..ba0c39ab3e2 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -12,11 +12,8 @@ from unittest.mock import patch import pytest -import reflex_base.config from pytest_mock import MockerFixture -from reflex_base import constants from reflex_base.environment import environment -from reflex_base.registry import RegistrationContext from reflex_base.utils import serializers from reflex.utils import exec as exec_utils @@ -24,70 +21,6 @@ DEV_BACKEND_RELOAD_ENV_NAME = environment.REFLEX_DEV_BACKEND_RELOAD_ACTIVE.name -@pytest.mark.parametrize("preload_app", [False, True]) -def test_load_app_initializes_config_before_importing_app( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, preload_app: bool -) -> None: - """The backend factory preserves the config classes used by a preloaded app. - - Args: - tmp_path: The pytest temporary project directory. - monkeypatch: The pytest monkeypatch fixture. - preload_app: Whether the supervisor has already imported the app. - """ - package = tmp_path / "config_first_app" - package.mkdir() - (package / "__init__.py").touch() - (package / "config_first_app.py").write_text( - "import reflex as rx\n" - "from rxconfig import ConfigState\n\n" - "def index():\n" - " return rx.text(ConfigState.value)\n\n" - "app = rx.App(_state=ConfigState)\n" - "app.add_page(index)\n" - ) - (tmp_path / constants.Config.FILE).write_text( - "import reflex as rx\n\n" - "class ConfigState(rx.State):\n" - " value: str = ''\n\n" - "config = rx.Config(app_name='config_first_app')\n" - ) - monkeypatch.chdir(tmp_path) - monkeypatch.syspath_prepend(str(tmp_path)) - config_module_name = constants.Config.MODULE - monkeypatch.delitem(sys.modules, config_module_name, raising=False) - monkeypatch.delitem(sys.modules, "config_first_app", raising=False) - monkeypatch.delitem(sys.modules, "config_first_app.config_first_app", raising=False) - inherited_state: type[object] | None = None - inherited_app: object | None = None - previous_state_auto_setters = reflex_base.config._state_auto_setters - - try: - with RegistrationContext(): - if preload_app: - from reflex.utils.prerequisites import get_and_validate_app - - reflex_base.config.get_config() - inherited_state = sys.modules[config_module_name].ConfigState - inherited_app = get_and_validate_app().module.app - - assert exec_utils.load_app() is not None - app_module = sys.modules["config_first_app.config_first_app"] - assert app_module.app._state is sys.modules[config_module_name].ConfigState - if preload_app: - assert app_module.app is inherited_app - assert app_module.app._state is inherited_state - finally: - sys.modules.pop(config_module_name, None) - sys.modules.pop("config_first_app", None) - sys.modules.pop("config_first_app.config_first_app", 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 _run_granian_reload_test_app(app_dir: str, port_queue: Queue) -> None: """Run a reloadable Granian app in a child process. @@ -97,7 +30,7 @@ def _run_granian_reload_test_app(app_dir: str, port_queue: Queue) -> None: """ app_path = Path(app_dir) sys.path.insert(0, app_dir) - exec_utils.get_app_instance = lambda: "reload_app:app" + exec_utils.get_app_instance_from_file = lambda: "reload_app:app" exec_utils.get_reload_paths = lambda: [app_path] exec_utils.get_dev_backend_reload_marker = lambda: app_path / ".reload" original_socket = socket.socket @@ -281,7 +214,9 @@ def test_run_granian_backend_sets_reload_env_var_and_clears_marker( mocker.patch.object( exec_utils, "get_dev_backend_reload_marker", return_value=marker ) - mocker.patch.object(exec_utils, "get_app_instance", return_value="app:app") + mocker.patch.object( + exec_utils, "get_app_instance_from_file", return_value="app:app" + ) mocker.patch.object(exec_utils, "get_reload_paths", return_value=[]) seen: dict[str, str | None] = {} @@ -317,7 +252,9 @@ def test_run_granian_backend_binds_listen_socket_in_supervisor( "get_dev_backend_reload_marker", return_value=tmp_path / exec_utils.DEV_BACKEND_RELOAD_MARKER, ) - mocker.patch.object(exec_utils, "get_app_instance", return_value="app:app") + mocker.patch.object( + exec_utils, "get_app_instance_from_file", return_value="app:app" + ) mocker.patch.object(exec_utils, "get_reload_paths", return_value=[]) granian_server = pytest.importorskip("granian.server") servers: list[object] = []