From f01c7556a977fb6bb98b09f6532cd59c9e2ac094 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Wed, 2 Sep 2026 22:35:45 +0530 Subject: [PATCH 1/8] Fix config reload state imports (#7028) --- .../+config-reload-state-imports.bugfix.md | 1 + .../reflex-base/src/reflex_base/config.py | 38 ++++--- tests/units/test_config.py | 100 +++++++++++++++++- 3 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 packages/reflex-base/news/+config-reload-state-imports.bugfix.md diff --git a/packages/reflex-base/news/+config-reload-state-imports.bugfix.md b/packages/reflex-base/news/+config-reload-state-imports.bugfix.md new file mode 100644 index 00000000000..1e768777645 --- /dev/null +++ b/packages/reflex-base/news/+config-reload-state-imports.bugfix.md @@ -0,0 +1 @@ +Prevent `reload_config()` from raising a duplicate-state error for state modules imported by `rxconfig.py`. diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 2665b6eeabf..5393c752d67 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -803,10 +803,12 @@ def _set_persistent(self, **kwargs): self._replace_defaults(**kwargs) -# Project-local modules first imported while loading rxconfig.py; evicted -# before the next load so projects don't reuse each other's dependencies. -# Only mutated under _load_config_lock. +# Project-local modules first imported while loading rxconfig.py and the root +# that supplied them. They are evicted before loading a different project so +# projects don't reuse each other's dependencies. Only mutated under +# _load_config_lock. _config_module_deps: set[str] = set() +_config_module_deps_root: Path | None = None class _ImportRecorder: @@ -894,7 +896,9 @@ def get_state_auto_setters() -> bool: return False -def _get_config(project_root: Path | None = None) -> Config: +def _get_config( + project_root: Path | None = None, *, reload_dependencies: bool = True +) -> Config: """Import rxconfig.py fresh from the project root and return its config. The project root is prepended to sys.path for the duration of the import so @@ -907,10 +911,15 @@ def _get_config(project_root: Path | None = None) -> Config: current working directory, resolved once up front so an rxconfig.py that changes the cwd cannot move the root that the sys.path entry and the dependency classification below are based on. + reload_dependencies: Whether to reload project-local modules imported by + rxconfig.py. A config reload in an existing RegistrationContext + keeps them so state classes are not redefined. Returns: The app config. """ + global _config_module_deps_root + project_root = (project_root or Path.cwd()).resolve() with _load_config_lock: # A fresh str object, so the exact inserted entry can be removed by @@ -919,16 +928,17 @@ def _get_config(project_root: Path | None = None) -> Config: cwd = str(project_root) sys.path.insert(0, cwd) try: - # Never cache rxconfig or its project-local dependencies — each load - # goes to disk so different RegistrationContexts hold independent - # Config instances resolved against the current project. Evict - # before probing: find_spec answers from sys.modules, so modules - # left behind by another project directory would fake the existence - # check below. + # Always reload rxconfig, but retain its dependencies when reloading + # the same project. Re-importing a helper that defines State would + # redefine the class in the active RegistrationContext. Before + # switching projects, evict dependencies so find_spec and imports + # cannot reuse modules from the prior root. sys.modules.pop(constants.Config.MODULE, None) - for dep in _config_module_deps: - sys.modules.pop(dep, None) - _config_module_deps.clear() + if reload_dependencies or _config_module_deps_root != project_root: + for dep in _config_module_deps: + sys.modules.pop(dep, None) + _config_module_deps.clear() + _config_module_deps_root = project_root # only import the module if it exists. If a module spec exists then # the module exists. if not find_spec(constants.Config.MODULE): @@ -1021,6 +1031,6 @@ def reload_config() -> Config: The freshly loaded app config. """ ctx = RegistrationContext.ensure_context() - config = _get_config() + config = _get_config(reload_dependencies=ctx._config is None) ctx._set_config(config) return config diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 450a9622a47..fff5a6ed0e6 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -1029,6 +1029,97 @@ def test_get_config_accepts_explicit_project_root( assert reflex_base.config._get_config(project).app_name == "explicit" +def test_reload_config_does_not_redefine_project_state( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None +): + """Reloading config does not re-import project modules that define state. + + Args: + tmp_path: The pytest tmp_path fixture. + monkeypatch: The pytest monkeypatch fixture. + clean_config_modules: Cleanup for modules left behind by the load. + """ + from reflex_base.registry import RegistrationContext + + (tmp_path / "config_reload_state_module.py").write_text( + "import reflex as rx\n\nclass MyState(rx.State):\n value: str = ''\n" + ) + (tmp_path / "rxconfig.py").write_text( + "import config_reload_state_module\nimport reflex as rx\n\n" + "config = rx.Config(app_name='state_reload')\n" + ) + monkeypatch.chdir(tmp_path) + + with RegistrationContext(): + assert reflex_base.config.get_config().app_name == "state_reload" + assert reflex_base.config.reload_config().app_name == "state_reload" + + +def test_get_config_reloads_project_state_for_each_context( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None +): + """Initial config loads register project state in each context. + + Args: + tmp_path: The pytest tmp_path fixture. + monkeypatch: The pytest monkeypatch fixture. + clean_config_modules: Cleanup for modules left behind by the load. + """ + from reflex_base.registry import RegistrationContext + + (tmp_path / "config_reload_state_module.py").write_text( + "import reflex as rx\n\nclass MyState(rx.State):\n value: str = ''\n" + ) + (tmp_path / "rxconfig.py").write_text( + "import config_reload_state_module\nimport reflex as rx\n\n" + "config = rx.Config(app_name='state_reload')\n" + ) + monkeypatch.chdir(tmp_path) + + with RegistrationContext() as first_context: + reflex_base.config.get_config() + first_state = next( + state + for state in first_context.base_states.values() + if state.__module__ == "config_reload_state_module" + ) + + with RegistrationContext() as second_context: + reflex_base.config.get_config() + second_state = next( + state + for state in second_context.base_states.values() + if state.__module__ == "config_reload_state_module" + ) + + assert second_state is not first_state + + +def test_get_config_evicts_dependencies_from_another_project( + tmp_path: Path, clean_config_modules: None +): + """Loading another project does not reuse a same-named local dependency. + + Args: + tmp_path: The pytest tmp_path fixture. + clean_config_modules: Cleanup for modules left behind by the load. + """ + first_project = tmp_path / "first" + second_project = tmp_path / "second" + for project, app_name in ((first_project, "first"), (second_project, "second")): + project.mkdir() + (project / "config_reload_dependency.py").write_text( + f"APP_NAME = {app_name!r}\n" + ) + (project / "rxconfig.py").write_text( + "import config_reload_dependency\nimport reflex as rx\n\n" + "config = rx.Config(app_name=config_reload_dependency.APP_NAME)\n" + ) + + assert reflex_base.config._get_config(first_project).app_name == "first" + assert reflex_base.config._get_config(second_project).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. @@ -1036,13 +1127,20 @@ def clean_config_modules() -> Generator[None, None, None]: Yields: None, once the module table is clean. """ - names = ("rxconfig", "side_module", "chdir_dep_module") + names = ( + "rxconfig", + "side_module", + "chdir_dep_module", + "config_reload_state_module", + "config_reload_dependency", + ) try: yield finally: for name in names: sys.modules.pop(name, None) reflex_base.config._config_module_deps.clear() + reflex_base.config._config_module_deps_root = None # Reruns: taking the prepended entry back out is itself a sys.path shrink, so From 4e0535950d7c234b39cd840fbc43eb906ec00354 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sat, 5 Sep 2026 16:33:15 +0530 Subject: [PATCH 2/8] Handle forked context config reloads (#7028) --- .../reflex-base/src/reflex_base/config.py | 40 +++--- .../reflex-base/src/reflex_base/registry.py | 8 ++ tests/units/test_config.py | 117 +++++++++++++----- 3 files changed, 120 insertions(+), 45 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 5393c752d67..1608d5488b4 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -803,12 +803,9 @@ def _set_persistent(self, **kwargs): self._replace_defaults(**kwargs) -# Project-local modules first imported while loading rxconfig.py and the root -# that supplied them. They are evicted before loading a different project so -# projects don't reuse each other's dependencies. Only mutated under -# _load_config_lock. +# Project-local modules first imported while loading rxconfig.py. Only mutated +# under _load_config_lock. _config_module_deps: set[str] = set() -_config_module_deps_root: Path | None = None class _ImportRecorder: @@ -918,8 +915,7 @@ def _get_config( Returns: The app config. """ - global _config_module_deps_root - + ctx = RegistrationContext.ensure_context() project_root = (project_root or Path.cwd()).resolve() with _load_config_lock: # A fresh str object, so the exact inserted entry can be removed by @@ -928,17 +924,20 @@ def _get_config( cwd = str(project_root) sys.path.insert(0, cwd) try: - # Always reload rxconfig, but retain its dependencies when reloading - # the same project. Re-importing a helper that defines State would - # redefine the class in the active RegistrationContext. Before - # switching projects, evict dependencies so find_spec and imports - # cannot reuse modules from the prior root. + # Restore context-owned modules on reload so forked state classes are + # not redefined while rxconfig.py is imported again. sys.modules.pop(constants.Config.MODULE, None) - if reload_dependencies or _config_module_deps_root != project_root: + if reload_dependencies or ctx._config_module_deps_root != project_root: for dep in _config_module_deps: sys.modules.pop(dep, None) _config_module_deps.clear() - _config_module_deps_root = project_root + else: + for dep in _config_module_deps: + sys.modules.pop(dep, None) + _config_module_deps.clear() + for dep, module in ctx._config_module_deps.items(): + sys.modules[dep] = module + _config_module_deps.update(ctx._config_module_deps) # only import the module if it exists. If a module spec exists then # the module exists. if not find_spec(constants.Config.MODULE): @@ -958,6 +957,16 @@ def _get_config( and "site-packages" not in path.parts ): _config_module_deps.add(name) + ctx._config_module_deps.clear() + ctx._config_module_deps.update( + { + name: module + for name in _config_module_deps + if name != constants.Config.MODULE + and (module := sys.modules.get(name)) is not None + } + ) + object.__setattr__(ctx, "_config_module_deps_root", project_root) return rxconfig.config finally: for i, entry in enumerate(sys.path): @@ -1031,6 +1040,7 @@ def reload_config() -> Config: The freshly loaded app config. """ ctx = RegistrationContext.ensure_context() - config = _get_config(reload_dependencies=ctx._config is None) + preserve_dependencies = bool(ctx._config_module_deps or ctx.base_states) + config = _get_config(reload_dependencies=not preserve_dependencies) ctx._set_config(config) return config diff --git a/packages/reflex-base/src/reflex_base/registry.py b/packages/reflex-base/src/reflex_base/registry.py index 61963cb538f..74a316051ff 100644 --- a/packages/reflex-base/src/reflex_base/registry.py +++ b/packages/reflex-base/src/reflex_base/registry.py @@ -3,6 +3,8 @@ from __future__ import annotations import dataclasses +from pathlib import Path +from types import ModuleType from typing import TYPE_CHECKING, Any from typing_extensions import Self @@ -60,6 +62,10 @@ class RegistrationContext(BaseContext): repr=False, ) _config: Config | None = dataclasses.field(default=None, repr=False) + _config_module_deps: dict[str, ModuleType] = dataclasses.field( + default_factory=dict, repr=False + ) + _config_module_deps_root: Path | None = dataclasses.field(default=None, repr=False) decorated_pages: list[tuple[Callable, dict[str, Any]]] = dataclasses.field( default_factory=list, repr=False, @@ -138,6 +144,8 @@ def fork(self) -> Self: base_state_substates={ k: set(v) for k, v in self.base_state_substates.items() }, + _config_module_deps=dict(self._config_module_deps), + _config_module_deps_root=self._config_module_deps_root, decorated_pages=list(self.decorated_pages), bundled_libraries=list(self.bundled_libraries), ) diff --git a/tests/units/test_config.py b/tests/units/test_config.py index fff5a6ed0e6..46a9fe3694d 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -1029,6 +1029,23 @@ def test_get_config_accepts_explicit_project_root( assert reflex_base.config._get_config(project).app_name == "explicit" +def _write_state_config(project: Path, app_name: str = "state_reload") -> None: + """Write a config that imports a module defining a state class. + + Args: + project: The project directory to populate. + app_name: The app name written to the config. + """ + project.mkdir(exist_ok=True) + (project / "config_reload_state_module.py").write_text( + "import reflex as rx\n\nclass MyState(rx.State):\n value: str = ''\n" + ) + (project / "rxconfig.py").write_text( + "import config_reload_state_module\nimport reflex as rx\n\n" + f"config = rx.Config(app_name={app_name!r})\n" + ) + + def test_reload_config_does_not_redefine_project_state( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None ): @@ -1041,19 +1058,16 @@ def test_reload_config_does_not_redefine_project_state( """ from reflex_base.registry import RegistrationContext - (tmp_path / "config_reload_state_module.py").write_text( - "import reflex as rx\n\nclass MyState(rx.State):\n value: str = ''\n" - ) - (tmp_path / "rxconfig.py").write_text( - "import config_reload_state_module\nimport reflex as rx\n\n" - "config = rx.Config(app_name='state_reload')\n" - ) + _write_state_config(tmp_path) monkeypatch.chdir(tmp_path) - with RegistrationContext(): + with RegistrationContext() as context: assert reflex_base.config.get_config().app_name == "state_reload" assert reflex_base.config.reload_config().app_name == "state_reload" + with context.fork(): + assert reflex_base.config.reload_config().app_name == "state_reload" + def test_get_config_reloads_project_state_for_each_context( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None @@ -1067,13 +1081,7 @@ def test_get_config_reloads_project_state_for_each_context( """ from reflex_base.registry import RegistrationContext - (tmp_path / "config_reload_state_module.py").write_text( - "import reflex as rx\n\nclass MyState(rx.State):\n value: str = ''\n" - ) - (tmp_path / "rxconfig.py").write_text( - "import config_reload_state_module\nimport reflex as rx\n\n" - "config = rx.Config(app_name='state_reload')\n" - ) + _write_state_config(tmp_path) monkeypatch.chdir(tmp_path) with RegistrationContext() as first_context: @@ -1095,29 +1103,79 @@ def test_get_config_reloads_project_state_for_each_context( assert second_state is not first_state -def test_get_config_evicts_dependencies_from_another_project( - tmp_path: Path, clean_config_modules: None +def test_reload_config_restores_modules_for_an_older_context( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None ): - """Loading another project does not reuse a same-named local dependency. + """Reloading an older context restores its project-local modules. Args: tmp_path: The pytest tmp_path fixture. + monkeypatch: The pytest monkeypatch fixture. clean_config_modules: Cleanup for modules left behind by the load. """ + from reflex_base.registry import RegistrationContext + first_project = tmp_path / "first" second_project = tmp_path / "second" - for project, app_name in ((first_project, "first"), (second_project, "second")): - project.mkdir() - (project / "config_reload_dependency.py").write_text( - f"APP_NAME = {app_name!r}\n" - ) - (project / "rxconfig.py").write_text( - "import config_reload_dependency\nimport reflex as rx\n\n" - "config = rx.Config(app_name=config_reload_dependency.APP_NAME)\n" - ) + _write_state_config(first_project, "first") + _write_state_config(second_project, "second") - assert reflex_base.config._get_config(first_project).app_name == "first" - assert reflex_base.config._get_config(second_project).app_name == "second" + first_context = RegistrationContext() + with first_context: + monkeypatch.chdir(first_project) + assert reflex_base.config.get_config().app_name == "first" + first_module = sys.modules["config_reload_state_module"] + + with RegistrationContext(): + monkeypatch.chdir(second_project) + assert reflex_base.config.get_config().app_name == "second" + assert sys.modules["config_reload_state_module"] is not first_module + + with first_context: + monkeypatch.chdir(first_project) + assert reflex_base.config.reload_config().app_name == "first" + assert sys.modules["config_reload_state_module"] is first_module + + +def _write_dependency_config(project: Path, app_name: str) -> None: + """Write a config that imports a project-local non-state dependency. + + Args: + project: The project directory to populate. + app_name: The app name exposed by the dependency. + """ + project.mkdir() + (project / "config_reload_dependency.py").write_text(f"APP_NAME = {app_name!r}\n") + (project / "rxconfig.py").write_text( + "import config_reload_dependency\nimport reflex as rx\n\n" + "config = rx.Config(app_name=config_reload_dependency.APP_NAME)\n" + ) + + +def test_reload_config_evicts_modules_when_project_changes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None +): + """Reloading a context after changing projects imports the new modules. + + Args: + tmp_path: The pytest tmp_path fixture. + monkeypatch: The pytest monkeypatch fixture. + clean_config_modules: Cleanup for modules left behind by the load. + """ + from reflex_base.registry import RegistrationContext + + first_project = tmp_path / "first" + second_project = tmp_path / "second" + _write_dependency_config(first_project, "first") + _write_dependency_config(second_project, "second") + + with RegistrationContext(): + monkeypatch.chdir(first_project) + assert reflex_base.config.get_config().app_name == "first" + monkeypatch.chdir(second_project) + assert reflex_base.config.reload_config().app_name == "second" + dependency = sys.modules["config_reload_dependency"] + assert Path(dependency.__file__ or "").is_relative_to(second_project) @pytest.fixture @@ -1140,7 +1198,6 @@ def clean_config_modules() -> Generator[None, None, None]: for name in names: sys.modules.pop(name, None) reflex_base.config._config_module_deps.clear() - reflex_base.config._config_module_deps_root = None # Reruns: taking the prepended entry back out is itself a sys.path shrink, so From 4d8f57fb659c59fafc335f25e4ec1c4b6cecbb6c Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sat, 5 Sep 2026 16:41:21 +0530 Subject: [PATCH 3/8] Reload non-state config dependencies --- .../reflex-base/src/reflex_base/config.py | 23 +++++++++++-- tests/units/test_config.py | 34 +++++++++++-------- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 1608d5488b4..5622a4d2acf 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -808,6 +808,21 @@ def _set_persistent(self, **kwargs): _config_module_deps: set[str] = set() +def _get_registered_state_modules(ctx: RegistrationContext) -> set[str]: + """Return modules defining states already registered in a context. + + Args: + ctx: The active registration context. + + Returns: + Module names whose objects must survive a config reload. + """ + state_types = set(ctx.base_states.values()) + for substates in ctx.base_state_substates.values(): + state_types.update(substates) + return {state_type.__module__ for state_type in state_types} + + class _ImportRecorder: """Meta-path finder that records import attempts made on one thread. @@ -910,7 +925,7 @@ def _get_config( and the dependency classification below are based on. reload_dependencies: Whether to reload project-local modules imported by rxconfig.py. A config reload in an existing RegistrationContext - keeps them so state classes are not redefined. + keeps only modules that define already-registered state classes. Returns: The app config. @@ -935,9 +950,11 @@ def _get_config( for dep in _config_module_deps: sys.modules.pop(dep, None) _config_module_deps.clear() + state_modules = _get_registered_state_modules(ctx) for dep, module in ctx._config_module_deps.items(): - sys.modules[dep] = module - _config_module_deps.update(ctx._config_module_deps) + if dep in state_modules: + sys.modules[dep] = module + _config_module_deps.add(dep) # only import the module if it exists. If a module spec exists then # the module exists. if not find_spec(constants.Config.MODULE): diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 46a9fe3694d..100adf1f176 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -29,6 +29,10 @@ interpret_int_env, ) +CONFIG_MODULE = "rxconfig" +STATE_MODULE = "config_reload_state_module" +DEPENDENCY_MODULE = "config_reload_dependency" + def test_requires_app_name(): """Test that a config requires an app_name.""" @@ -1037,11 +1041,11 @@ def _write_state_config(project: Path, app_name: str = "state_reload") -> None: app_name: The app name written to the config. """ project.mkdir(exist_ok=True) - (project / "config_reload_state_module.py").write_text( + (project / f"{STATE_MODULE}.py").write_text( "import reflex as rx\n\nclass MyState(rx.State):\n value: str = ''\n" ) (project / "rxconfig.py").write_text( - "import config_reload_state_module\nimport reflex as rx\n\n" + f"import {STATE_MODULE}\nimport reflex as rx\n\n" f"config = rx.Config(app_name={app_name!r})\n" ) @@ -1089,7 +1093,7 @@ def test_get_config_reloads_project_state_for_each_context( first_state = next( state for state in first_context.base_states.values() - if state.__module__ == "config_reload_state_module" + if state.__module__ == STATE_MODULE ) with RegistrationContext() as second_context: @@ -1097,7 +1101,7 @@ def test_get_config_reloads_project_state_for_each_context( second_state = next( state for state in second_context.base_states.values() - if state.__module__ == "config_reload_state_module" + if state.__module__ == STATE_MODULE ) assert second_state is not first_state @@ -1124,17 +1128,17 @@ def test_reload_config_restores_modules_for_an_older_context( with first_context: monkeypatch.chdir(first_project) assert reflex_base.config.get_config().app_name == "first" - first_module = sys.modules["config_reload_state_module"] + first_module = sys.modules[STATE_MODULE] with RegistrationContext(): monkeypatch.chdir(second_project) assert reflex_base.config.get_config().app_name == "second" - assert sys.modules["config_reload_state_module"] is not first_module + assert sys.modules[STATE_MODULE] is not first_module with first_context: monkeypatch.chdir(first_project) assert reflex_base.config.reload_config().app_name == "first" - assert sys.modules["config_reload_state_module"] is first_module + assert sys.modules[STATE_MODULE] is first_module def _write_dependency_config(project: Path, app_name: str) -> None: @@ -1145,10 +1149,10 @@ def _write_dependency_config(project: Path, app_name: str) -> None: app_name: The app name exposed by the dependency. """ project.mkdir() - (project / "config_reload_dependency.py").write_text(f"APP_NAME = {app_name!r}\n") + (project / f"{DEPENDENCY_MODULE}.py").write_text(f"APP_NAME = {app_name!r}\n") (project / "rxconfig.py").write_text( - "import config_reload_dependency\nimport reflex as rx\n\n" - "config = rx.Config(app_name=config_reload_dependency.APP_NAME)\n" + f"import {DEPENDENCY_MODULE}\nimport reflex as rx\n\n" + f"config = rx.Config(app_name={DEPENDENCY_MODULE}.APP_NAME)\n" ) @@ -1172,9 +1176,11 @@ def test_reload_config_evicts_modules_when_project_changes( with RegistrationContext(): monkeypatch.chdir(first_project) assert reflex_base.config.get_config().app_name == "first" + (first_project / f"{DEPENDENCY_MODULE}.py").write_text("APP_NAME = 'updated'\n") + assert reflex_base.config.reload_config().app_name == "updated" monkeypatch.chdir(second_project) assert reflex_base.config.reload_config().app_name == "second" - dependency = sys.modules["config_reload_dependency"] + dependency = sys.modules[DEPENDENCY_MODULE] assert Path(dependency.__file__ or "").is_relative_to(second_project) @@ -1186,11 +1192,11 @@ def clean_config_modules() -> Generator[None, None, None]: None, once the module table is clean. """ names = ( - "rxconfig", + CONFIG_MODULE, "side_module", "chdir_dep_module", - "config_reload_state_module", - "config_reload_dependency", + STATE_MODULE, + DEPENDENCY_MODULE, ) try: yield From 10ca63540cba5c2c54b771fa0c60852446df17ab Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sat, 5 Sep 2026 16:49:39 +0530 Subject: [PATCH 4/8] Preserve state package modules on reload --- .../reflex-base/src/reflex_base/config.py | 5 ++- tests/units/test_config.py | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 5622a4d2acf..351a87f8cbd 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -952,7 +952,10 @@ def _get_config( _config_module_deps.clear() state_modules = _get_registered_state_modules(ctx) for dep, module in ctx._config_module_deps.items(): - if dep in state_modules: + if dep in state_modules or any( + state_module.startswith(f"{dep}.") + for state_module in state_modules + ): sys.modules[dep] = module _config_module_deps.add(dep) # only import the module if it exists. If a module spec exists then diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 100adf1f176..bc3da66b4ff 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -32,6 +32,7 @@ CONFIG_MODULE = "rxconfig" STATE_MODULE = "config_reload_state_module" DEPENDENCY_MODULE = "config_reload_dependency" +PACKAGE_STATE_MODULE = "config_reload_package.state" def test_requires_app_name(): @@ -1141,6 +1142,37 @@ def test_reload_config_restores_modules_for_an_older_context( assert sys.modules[STATE_MODULE] is first_module +def test_reload_config_restores_state_package( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None +): + """Reloading config preserves a package containing a registered state. + + Args: + tmp_path: The pytest tmp_path fixture. + monkeypatch: The pytest monkeypatch fixture. + clean_config_modules: Cleanup for modules left behind by the load. + """ + from reflex_base.registry import RegistrationContext + + package = tmp_path / "config_reload_package" + package.mkdir() + (package / "__init__.py").write_text("") + (package / "state.py").write_text( + "import reflex as rx\n\nclass MyState(rx.State):\n value: str = ''\n" + ) + (tmp_path / f"{CONFIG_MODULE}.py").write_text( + f"import {PACKAGE_STATE_MODULE}\nimport reflex as rx\n\n" + "config = rx.Config(app_name='package_state_reload')\n" + ) + monkeypatch.chdir(tmp_path) + + with RegistrationContext(): + assert reflex_base.config.get_config().app_name == "package_state_reload" + assert reflex_base.config.reload_config().app_name == "package_state_reload" + assert PACKAGE_STATE_MODULE in sys.modules + assert "config_reload_package" in sys.modules + + def _write_dependency_config(project: Path, app_name: str) -> None: """Write a config that imports a project-local non-state dependency. @@ -1197,6 +1229,8 @@ def clean_config_modules() -> Generator[None, None, None]: "chdir_dep_module", STATE_MODULE, DEPENDENCY_MODULE, + "config_reload_package", + PACKAGE_STATE_MODULE, ) try: yield From a6f656ed4d65b23fedf6dd5af7586d6c2cf4b6dc Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sat, 5 Sep 2026 16:54:59 +0530 Subject: [PATCH 5/8] Apply CI formatter output --- packages/reflex-base/src/reflex_base/config.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 351a87f8cbd..91d385cadeb 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -978,14 +978,12 @@ def _get_config( ): _config_module_deps.add(name) ctx._config_module_deps.clear() - ctx._config_module_deps.update( - { - name: module - for name in _config_module_deps - if name != constants.Config.MODULE - and (module := sys.modules.get(name)) is not None - } - ) + ctx._config_module_deps.update({ + name: module + for name in _config_module_deps + if name != constants.Config.MODULE + and (module := sys.modules.get(name)) is not None + }) object.__setattr__(ctx, "_config_module_deps_root", project_root) return rxconfig.config finally: From 5dba682d181d0f370bf85dff31a7bda664fa909d Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sat, 5 Sep 2026 16:58:21 +0530 Subject: [PATCH 6/8] Refresh package settings during reload --- .../reflex-base/src/reflex_base/config.py | 13 ++++++++++--- tests/units/test_config.py | 19 +++++++++++-------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 91d385cadeb..17a83ebe0e8 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -951,13 +951,20 @@ def _get_config( sys.modules.pop(dep, None) _config_module_deps.clear() state_modules = _get_registered_state_modules(ctx) - for dep, module in ctx._config_module_deps.items(): - if dep in state_modules or any( + package_modules = { + dep + for dep in ctx._config_module_deps + if any( state_module.startswith(f"{dep}.") for state_module in state_modules - ): + ) + } + for dep, module in ctx._config_module_deps.items(): + if dep in state_modules or dep in package_modules: sys.modules[dep] = module _config_module_deps.add(dep) + for dep in sorted(package_modules, key=lambda name: name.count(".")): + importlib.reload(ctx._config_module_deps[dep]) # only import the module if it exists. If a module spec exists then # the module exists. if not find_spec(constants.Config.MODULE): diff --git a/tests/units/test_config.py b/tests/units/test_config.py index bc3da66b4ff..c54182ff645 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -32,7 +32,8 @@ CONFIG_MODULE = "rxconfig" STATE_MODULE = "config_reload_state_module" DEPENDENCY_MODULE = "config_reload_dependency" -PACKAGE_STATE_MODULE = "config_reload_package.state" +PACKAGE_NAME = "config_reload_package" +PACKAGE_STATE_MODULE = f"{PACKAGE_NAME}.state" def test_requires_app_name(): @@ -1154,23 +1155,25 @@ def test_reload_config_restores_state_package( """ from reflex_base.registry import RegistrationContext - package = tmp_path / "config_reload_package" + package = tmp_path / PACKAGE_NAME package.mkdir() - (package / "__init__.py").write_text("") + (package / "__init__.py").write_text("APP_NAME = 'first'\n") (package / "state.py").write_text( "import reflex as rx\n\nclass MyState(rx.State):\n value: str = ''\n" ) (tmp_path / f"{CONFIG_MODULE}.py").write_text( f"import {PACKAGE_STATE_MODULE}\nimport reflex as rx\n\n" - "config = rx.Config(app_name='package_state_reload')\n" + f"import {PACKAGE_NAME}\n\n" + f"config = rx.Config(app_name={PACKAGE_NAME}.APP_NAME)\n" ) monkeypatch.chdir(tmp_path) with RegistrationContext(): - assert reflex_base.config.get_config().app_name == "package_state_reload" - assert reflex_base.config.reload_config().app_name == "package_state_reload" + assert reflex_base.config.get_config().app_name == "first" + (package / "__init__.py").write_text("APP_NAME = 'second'\n") + assert reflex_base.config.reload_config().app_name == "second" assert PACKAGE_STATE_MODULE in sys.modules - assert "config_reload_package" in sys.modules + assert PACKAGE_NAME in sys.modules def _write_dependency_config(project: Path, app_name: str) -> None: @@ -1229,7 +1232,7 @@ def clean_config_modules() -> Generator[None, None, None]: "chdir_dep_module", STATE_MODULE, DEPENDENCY_MODULE, - "config_reload_package", + PACKAGE_NAME, PACKAGE_STATE_MODULE, ) try: From f49704de7bb9956733ad95724e0ee9c6429cc389 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sat, 5 Sep 2026 17:08:08 +0530 Subject: [PATCH 7/8] Handle package reload edge cases --- .../reflex-base/src/reflex_base/config.py | 36 +++++++++++------ tests/units/test_config.py | 39 ++++++++++++++++++- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 17a83ebe0e8..f396d7df19e 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -7,7 +7,7 @@ import sys import threading import urllib.parse -from collections.abc import Iterator, Sequence +from collections.abc import Iterable, Iterator, Sequence from contextlib import contextmanager from importlib.util import find_spec from pathlib import Path @@ -860,6 +860,23 @@ def find_spec(self, fullname: str, path: Any = None, target: Any = None) -> None _import_recorder = _ImportRecorder() +def _record_project_modules(names: Iterable[str], project_root: Path) -> None: + """Add project-local modules from an import attempt to the dependency set. + + Args: + names: Module names observed by the import recorder. + project_root: Root used to classify project-local modules. + """ + for name in names: + origin = getattr(sys.modules.get(name), "__file__", None) + if ( + origin + and (path := Path(origin)).is_relative_to(project_root) + and "site-packages" not in path.parts + ): + _config_module_deps.add(name) + + @contextmanager def _record_imports() -> Iterator[_ImportRecorder]: """Record imports made on the current thread while rxconfig loads. @@ -963,8 +980,12 @@ def _get_config( if dep in state_modules or dep in package_modules: sys.modules[dep] = module _config_module_deps.add(dep) - for dep in sorted(package_modules, key=lambda name: name.count(".")): - importlib.reload(ctx._config_module_deps[dep]) + with _record_imports() as recorder: + for dep in sorted( + package_modules, key=lambda name: name.count(".") + ): + importlib.reload(ctx._config_module_deps[dep]) + _record_project_modules(recorder.names, project_root) # only import the module if it exists. If a module spec exists then # the module exists. if not find_spec(constants.Config.MODULE): @@ -976,14 +997,7 @@ def _get_config( rxconfig = importlib.import_module(constants.Config.MODULE) finally: # Record even on failure so a retry evicts partially-imported deps. - for name in recorder.names: - origin = getattr(sys.modules.get(name), "__file__", None) - if ( - origin - and (path := Path(origin)).is_relative_to(project_root) - and "site-packages" not in path.parts - ): - _config_module_deps.add(name) + _record_project_modules(recorder.names, project_root) ctx._config_module_deps.clear() ctx._config_module_deps.update({ name: module diff --git a/tests/units/test_config.py b/tests/units/test_config.py index c54182ff645..79849d50f50 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -34,6 +34,7 @@ DEPENDENCY_MODULE = "config_reload_dependency" PACKAGE_NAME = "config_reload_package" PACKAGE_STATE_MODULE = f"{PACKAGE_NAME}.state" +PACKAGE_SETTINGS_MODULE = f"{PACKAGE_NAME}.settings" def test_requires_app_name(): @@ -1157,7 +1158,10 @@ def test_reload_config_restores_state_package( package = tmp_path / PACKAGE_NAME package.mkdir() - (package / "__init__.py").write_text("APP_NAME = 'first'\n") + (package / "__init__.py").write_text( + f"from .{PACKAGE_SETTINGS_MODULE.rsplit('.', maxsplit=1)[-1]} import APP_NAME\n" + ) + (package / "settings.py").write_text("APP_NAME = 'first'\n") (package / "state.py").write_text( "import reflex as rx\n\nclass MyState(rx.State):\n value: str = ''\n" ) @@ -1170,12 +1174,42 @@ def test_reload_config_restores_state_package( with RegistrationContext(): assert reflex_base.config.get_config().app_name == "first" - (package / "__init__.py").write_text("APP_NAME = 'second'\n") + (package / "settings.py").write_text("APP_NAME = 'second'\n") assert reflex_base.config.reload_config().app_name == "second" + (package / "settings.py").write_text("APP_NAME = 'third'\n") + assert reflex_base.config.reload_config().app_name == "third" assert PACKAGE_STATE_MODULE in sys.modules assert PACKAGE_NAME in sys.modules +def test_reload_config_preserves_state_defined_in_package( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None +): + """Reloading config does not redefine a state defined in a package. + + Args: + tmp_path: The pytest tmp_path fixture. + monkeypatch: The pytest monkeypatch fixture. + clean_config_modules: Cleanup for modules left behind by the load. + """ + from reflex_base.registry import RegistrationContext + + package = tmp_path / PACKAGE_NAME + package.mkdir() + (package / "__init__.py").write_text( + "import reflex as rx\n\nclass PackageState(rx.State):\n value: str = ''\n" + ) + (tmp_path / f"{CONFIG_MODULE}.py").write_text( + f"import {PACKAGE_NAME}\nimport reflex as rx\n\n" + "config = rx.Config(app_name='package_state')\n" + ) + monkeypatch.chdir(tmp_path) + + with RegistrationContext(): + assert reflex_base.config.get_config().app_name == "package_state" + assert reflex_base.config.reload_config().app_name == "package_state" + + def _write_dependency_config(project: Path, app_name: str) -> None: """Write a config that imports a project-local non-state dependency. @@ -1234,6 +1268,7 @@ def clean_config_modules() -> Generator[None, None, None]: DEPENDENCY_MODULE, PACKAGE_NAME, PACKAGE_STATE_MODULE, + PACKAGE_SETTINGS_MODULE, ) try: yield From 7669cec81c1f2cdc0a25d0a120125b97d30fde5f Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sat, 5 Sep 2026 17:22:45 +0530 Subject: [PATCH 8/8] Track imports after failed package reload --- .../reflex-base/src/reflex_base/config.py | 12 +++--- tests/units/test_config.py | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index f396d7df19e..c2c5f489571 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -981,11 +981,13 @@ def _get_config( sys.modules[dep] = module _config_module_deps.add(dep) with _record_imports() as recorder: - for dep in sorted( - package_modules, key=lambda name: name.count(".") - ): - importlib.reload(ctx._config_module_deps[dep]) - _record_project_modules(recorder.names, project_root) + try: + for dep in sorted( + package_modules, key=lambda name: name.count(".") + ): + importlib.reload(ctx._config_module_deps[dep]) + finally: + _record_project_modules(recorder.names, project_root) # only import the module if it exists. If a module spec exists then # the module exists. if not find_spec(constants.Config.MODULE): diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 79849d50f50..12642a88fe2 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -1210,6 +1210,47 @@ def test_reload_config_preserves_state_defined_in_package( assert reflex_base.config.reload_config().app_name == "package_state" +def test_reload_config_records_package_imports_after_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None +): + """A failed package reload still tracks imports for the next retry. + + Args: + tmp_path: The pytest tmp_path fixture. + monkeypatch: The pytest monkeypatch fixture. + clean_config_modules: Cleanup for modules left behind by the load. + """ + from reflex_base.registry import RegistrationContext + + package = tmp_path / PACKAGE_NAME + package.mkdir() + (package / "settings.py").write_text("APP_NAME = 'first'\n") + (package / "state.py").write_text( + "import reflex as rx\n\nclass PackageState(rx.State):\n value: str = ''\n" + ) + (package / "__init__.py").write_text( + "import os\n" + "from .settings import APP_NAME\n\n" + "if os.environ.get('CONFIG_RELOAD_FAIL'):\n" + " raise RuntimeError('reload failed')\n" + ) + (tmp_path / f"{CONFIG_MODULE}.py").write_text( + f"import {PACKAGE_NAME}.state\nimport {PACKAGE_NAME}\n" + "import reflex as rx\n\n" + f"config = rx.Config(app_name={PACKAGE_NAME}.APP_NAME)\n" + ) + monkeypatch.chdir(tmp_path) + + with RegistrationContext(): + assert reflex_base.config.get_config().app_name == "first" + monkeypatch.setenv("CONFIG_RELOAD_FAIL", "1") + with pytest.raises(RuntimeError, match="reload failed"): + reflex_base.config.reload_config() + monkeypatch.delenv("CONFIG_RELOAD_FAIL") + (package / "settings.py").write_text("APP_NAME = 'second'\n") + assert reflex_base.config.reload_config().app_name == "second" + + def _write_dependency_config(project: Path, app_name: str) -> None: """Write a config that imports a project-local non-state dependency.