From f01c7556a977fb6bb98b09f6532cd59c9e2ac094 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Wed, 2 Sep 2026 22:35:45 +0530 Subject: [PATCH] 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