From 2cf1eff4e905e670fd5b47211ea9be961ed59f9f Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Thu, 10 Sep 2026 10:47:05 +0200 Subject: [PATCH 1/5] fix(config): keep rxconfig dependencies on same-root reload --- .../news/+config-deps-same-root.bugfix.md | 1 + .../reflex-base/src/reflex_base/config.py | 33 ++-- tests/units/test_config.py | 146 +++++++++++++++++- 3 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 packages/reflex-base/news/+config-deps-same-root.bugfix.md diff --git a/packages/reflex-base/news/+config-deps-same-root.bugfix.md b/packages/reflex-base/news/+config-deps-same-root.bugfix.md new file mode 100644 index 00000000000..d0deb2973ab --- /dev/null +++ b/packages/reflex-base/news/+config-deps-same-root.bugfix.md @@ -0,0 +1 @@ +Keep the project-local modules imported by `rxconfig.py` when the config is reloaded from the same project root, so classes they define are not duplicated and states are not registered twice. diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index b2e574ef425..99ae4c95493 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -834,10 +834,14 @@ 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 +# project root they were recorded under. Evicted before a load from a different +# root so projects don't reuse each other's dependencies. A load from the same +# root keeps them: re-executing them would create a second copy of every class +# they define, distinct from the one the app already imported. Only mutated +# under _load_config_lock. _config_module_deps: set[str] = set() +_config_module_deps_root: Path | None = None class _ImportRecorder: @@ -942,6 +946,8 @@ def _get_config(project_root: Path | None = None) -> Config: 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 @@ -950,16 +956,19 @@ 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. + # Never cache rxconfig itself — each load goes to disk so different + # RegistrationContexts hold independent Config instances. sys.modules.pop(constants.Config.MODULE, None) - for dep in _config_module_deps: - sys.modules.pop(dep, None) - _config_module_deps.clear() + if _config_module_deps_root != project_root: + # Evict the previous project's dependencies before probing: + # find_spec answers from sys.modules, so modules left behind by + # another project directory would fake the existence check + # below. Same-root loads skip this so the modules the app + # imported stay the ones rxconfig.py sees. + 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): diff --git a/tests/units/test_config.py b/tests/units/test_config.py index bf82d5415b8..c4fde667326 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -3,6 +3,7 @@ import logging import multiprocessing import os +import pickle import sys import textwrap import threading @@ -1096,13 +1097,21 @@ 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", + "reload_dep_module", + "shared_helper", + "config_reload_state_module", + ) 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 @@ -1266,6 +1275,141 @@ def test_config_deps_recorded_against_load_root_when_rxconfig_chdirs( assert "chdir_dep_module" in reflex_base.config._config_module_deps +def test_same_root_reload_keeps_dependency_modules( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None +): + """Reloading from the same project keeps rxconfig's project-local modules. + + rxconfig.py itself is re-read from disk, but the modules it imports must + stay the objects the app already holds. Re-executing them creates a second + copy of every class they define, and pickling an instance of the app's copy + then fails because the qualified name resolves to the other class. + + Args: + tmp_path: The pytest tmp_path fixture. + monkeypatch: The pytest monkeypatch fixture. + clean_config_modules: Cleanup for modules left behind by the load. + """ + (tmp_path / "reload_dep_module.py").write_text("class Marker:\n pass\n") + rxconfig_template = textwrap.dedent( + """ + import reload_dep_module # noqa: F401 + import reflex as rx + + config = rx.Config(app_name={app_name!r}) + """ + ) + (tmp_path / "rxconfig.py").write_text(rxconfig_template.format(app_name="first")) + monkeypatch.chdir(tmp_path) + monkeypatch.delitem(sys.modules, "reload_dep_module", raising=False) + + assert reflex_base.config._get_config().app_name == "first" + module = sys.modules["reload_dep_module"] + marker = module.Marker() + + (tmp_path / "rxconfig.py").write_text( + rxconfig_template.format(app_name="second load") + ) + assert reflex_base.config._get_config().app_name == "second load" + assert sys.modules["reload_dep_module"] is module + assert type(pickle.loads(pickle.dumps(marker))) is module.Marker + assert "reload_dep_module" in reflex_base.config._config_module_deps + + +def test_other_root_load_evicts_dependency_modules( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None +): + """Loading a different project evicts the previous project's dependencies. + + Two projects with a same-named helper module must each resolve their own + copy, in whichever order they are loaded. + + Args: + tmp_path: The pytest tmp_path fixture. + monkeypatch: The pytest monkeypatch fixture. + clean_config_modules: Cleanup for modules left behind by the load. + """ + for name, value in (("first", 1), ("second", 2)): + project = tmp_path / name + project.mkdir() + (project / "shared_helper.py").write_text(f"VALUE = {value}\n") + (project / "rxconfig.py").write_text( + textwrap.dedent( + """ + import shared_helper + import reflex as rx + + config = rx.Config(app_name=f"app{shared_helper.VALUE}") + """ + ) + ) + monkeypatch.delitem(sys.modules, "shared_helper", raising=False) + + assert reflex_base.config._get_config(tmp_path / "first").app_name == "app1" + first_helper = sys.modules["shared_helper"] + assert reflex_base.config._get_config(tmp_path / "second").app_name == "app2" + assert sys.modules["shared_helper"] is not first_helper + assert reflex_base.config._get_config(tmp_path / "first").app_name == "app1" + assert sys.modules["shared_helper"] is not first_helper + assert sys.modules["shared_helper"].VALUE == 1 + + +def test_reload_config_keeps_state_module_registered( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None +): + """Reloading a config whose rxconfig.py imports a state module does not redefine the state. + + Re-importing the module would run the state class body again and trip the + shadowing check for the class still registered in the context. Covers both + a plain reload and a reload in a forked context, the shape AppHarness uses. + + 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( + textwrap.dedent( + """ + import reflex as rx + + + class ConfigReloadState(rx.State): + value: str = "" + """ + ) + ) + (tmp_path / "rxconfig.py").write_text( + textwrap.dedent( + """ + import config_reload_state_module # noqa: F401 + import reflex as rx + + config = rx.Config(app_name="statereload") + """ + ) + ) + monkeypatch.chdir(tmp_path) + monkeypatch.delitem(sys.modules, "config_reload_state_module", raising=False) + + with RegistrationContext() as ctx: + assert reflex_base.config.get_config().app_name == "statereload" + state_cls = sys.modules["config_reload_state_module"].ConfigReloadState + + assert reflex_base.config.reload_config().app_name == "statereload" + assert sys.modules["config_reload_state_module"].ConfigReloadState is state_cls + + forked = ctx.fork() + token = RegistrationContext._context_var.set(forked) + try: + assert reflex_base.config.reload_config().app_name == "statereload" + finally: + RegistrationContext._context_var.reset(token) + assert sys.modules["config_reload_state_module"].ConfigReloadState is state_cls + + def test_record_imports_never_rebinds_meta_path(): """Recording must mutate sys.meta_path in place, never rebind it. From 11496e3c8c96088322b842e5e15e23c9e77f5255 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Thu, 10 Sep 2026 10:47:43 +0200 Subject: [PATCH 2/5] chore: name news fragment after PR --- .../news/{+config-deps-same-root.bugfix.md => 7075.bugfix.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/reflex-base/news/{+config-deps-same-root.bugfix.md => 7075.bugfix.md} (100%) diff --git a/packages/reflex-base/news/+config-deps-same-root.bugfix.md b/packages/reflex-base/news/7075.bugfix.md similarity index 100% rename from packages/reflex-base/news/+config-deps-same-root.bugfix.md rename to packages/reflex-base/news/7075.bugfix.md From 76f3fb1278d6b658fc7e1b6ee2434cd3484df460 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Thu, 10 Sep 2026 11:00:51 +0200 Subject: [PATCH 3/5] fix(config): evict recorded deps on retry after a failed load --- .../reflex-base/src/reflex_base/config.py | 7 ++- tests/units/test_config.py | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 99ae4c95493..6ca963eac55 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -978,8 +978,13 @@ def _get_config(project_root: Path | None = None) -> Config: with _record_imports() as recorder: try: rxconfig = importlib.import_module(constants.Config.MODULE) + except BaseException: + # Nothing from a failed load is worth keeping: forget the + # root so the retry evicts what this load imported. + _config_module_deps_root = None + raise finally: - # Record even on failure so a retry evicts partially-imported deps. + # Record even on failure so the retry knows what to evict. for name in recorder.names: origin = getattr(sys.modules.get(name), "__file__", None) if ( diff --git a/tests/units/test_config.py b/tests/units/test_config.py index c4fde667326..ba20e799508 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -1104,6 +1104,7 @@ def clean_config_modules() -> Generator[None, None, None]: "reload_dep_module", "shared_helper", "config_reload_state_module", + "failing_dep_module", ) try: yield @@ -1316,6 +1317,50 @@ def test_same_root_reload_keeps_dependency_modules( assert "reload_dep_module" in reflex_base.config._config_module_deps +def test_retry_after_failed_load_evicts_dependency_modules( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None +): + """A retry after a failed load re-imports what the failed load pulled in. + + Nothing from a failed load is in use anywhere, so the retry must not keep + a helper module the developer has fixed in the meantime. + + Args: + tmp_path: The pytest tmp_path fixture. + monkeypatch: The pytest monkeypatch fixture. + clean_config_modules: Cleanup for modules left behind by the load. + """ + helper = tmp_path / "failing_dep_module.py" + helper.write_text("VALUE = 1\n") + (tmp_path / "rxconfig.py").write_text( + textwrap.dedent( + """ + import os + + import failing_dep_module + import reflex as rx + + if os.environ.get("RX_TEST_FAIL_LOAD"): + raise RuntimeError("broken rxconfig") + config = rx.Config(app_name=f"app{failing_dep_module.VALUE}") + """ + ) + ) + monkeypatch.chdir(tmp_path) + monkeypatch.delitem(sys.modules, "failing_dep_module", raising=False) + + monkeypatch.setenv("RX_TEST_FAIL_LOAD", "1") + with pytest.raises(RuntimeError, match="broken rxconfig"): + reflex_base.config._get_config() + failed_helper = sys.modules["failing_dep_module"] + assert "failing_dep_module" in reflex_base.config._config_module_deps + + monkeypatch.delenv("RX_TEST_FAIL_LOAD") + helper.write_text("VALUE = 22\n") + assert reflex_base.config._get_config().app_name == "app22" + assert sys.modules["failing_dep_module"] is not failed_helper + + def test_other_root_load_evicts_dependency_modules( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None ): From 8b503df19a31551f2a83bc781090f41c7c4ec9a8 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Thu, 10 Sep 2026 11:40:56 +0200 Subject: [PATCH 4/5] fix(config): drop only a failed load's own imports, keep last good deps --- .../reflex-base/src/reflex_base/config.py | 47 +++++++++---- tests/units/test_config.py | 67 +++++++++++++++++-- 2 files changed, 94 insertions(+), 20 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 6ca963eac55..b881c35783b 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, PureWindowsPath @@ -881,6 +881,28 @@ def find_spec(self, fullname: str, path: Any = None, target: Any = None) -> None _import_recorder = _ImportRecorder() +def _project_local_modules(names: Iterable[str], project_root: Path) -> set[str]: + """Filter recorded import names down to modules that live in the project. + + Args: + names: Module names observed by the import recorder. + project_root: The root that classifies a module as project-local. + + Returns: + The names whose module file is under project_root and not installed. + """ + project_local: set[str] = set() + 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 + ): + project_local.add(name) + return project_local + + @contextmanager def _record_imports() -> Iterator[_ImportRecorder]: """Record imports made on the current thread while rxconfig loads. @@ -979,20 +1001,17 @@ def _get_config(project_root: Path | None = None) -> Config: try: rxconfig = importlib.import_module(constants.Config.MODULE) except BaseException: - # Nothing from a failed load is worth keeping: forget the - # root so the retry evicts what this load imported. - _config_module_deps_root = None + # The recorder only sees modules this attempt imported + # fresh, so nothing here is in use yet. Drop them so the + # retry re-imports them; modules from an earlier successful + # load are untouched since the running app may hold them. + for name in _project_local_modules(recorder.names, project_root): + sys.modules.pop(name, None) + _config_module_deps.discard(name) raise - finally: - # Record even on failure so the retry knows what to evict. - 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) + _config_module_deps.update( + _project_local_modules(recorder.names, project_root) + ) return rxconfig.config finally: for i, entry in enumerate(sys.path): diff --git a/tests/units/test_config.py b/tests/units/test_config.py index ba20e799508..567dd8716d6 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -1105,6 +1105,8 @@ def clean_config_modules() -> Generator[None, None, None]: "shared_helper", "config_reload_state_module", "failing_dep_module", + "kept_helper", + "failed_only_helper", ) try: yield @@ -1320,10 +1322,10 @@ def test_same_root_reload_keeps_dependency_modules( def test_retry_after_failed_load_evicts_dependency_modules( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None ): - """A retry after a failed load re-imports what the failed load pulled in. + """A failed load drops the modules it imported so a retry re-imports them. - Nothing from a failed load is in use anywhere, so the retry must not keep - a helper module the developer has fixed in the meantime. + Nothing a failed load imported for the first time is in use anywhere, so + the retry must not keep a helper module the developer fixed in the meantime. Args: tmp_path: The pytest tmp_path fixture. @@ -1352,13 +1354,66 @@ def test_retry_after_failed_load_evicts_dependency_modules( monkeypatch.setenv("RX_TEST_FAIL_LOAD", "1") with pytest.raises(RuntimeError, match="broken rxconfig"): reflex_base.config._get_config() - failed_helper = sys.modules["failing_dep_module"] - assert "failing_dep_module" in reflex_base.config._config_module_deps + assert "failing_dep_module" not in sys.modules + assert "failing_dep_module" not in reflex_base.config._config_module_deps monkeypatch.delenv("RX_TEST_FAIL_LOAD") helper.write_text("VALUE = 22\n") assert reflex_base.config._get_config().app_name == "app22" - assert sys.modules["failing_dep_module"] is not failed_helper + assert "failing_dep_module" in reflex_base.config._config_module_deps + + +def test_failed_reload_keeps_modules_from_last_good_load( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None +): + """A failed same-root reload drops only what it imported itself. + + The running app may hold classes from the last successful load, so those + modules must survive both the failure and the retry after it. A module the + failed attempt imported for the first time is not in use and is dropped. + + Args: + tmp_path: The pytest tmp_path fixture. + monkeypatch: The pytest monkeypatch fixture. + clean_config_modules: Cleanup for modules left behind by the load. + """ + (tmp_path / "kept_helper.py").write_text("class Kept:\n pass\n") + (tmp_path / "failed_only_helper.py").write_text("VALUE = 1\n") + good_rxconfig = textwrap.dedent( + """ + import kept_helper # noqa: F401 + import reflex as rx + + config = rx.Config(app_name="good") + """ + ) + broken_rxconfig = textwrap.dedent( + """ + import kept_helper # noqa: F401 + import failed_only_helper # noqa: F401 + import reflex as rx + + raise RuntimeError("broken rxconfig") + """ + ) + (tmp_path / "rxconfig.py").write_text(good_rxconfig) + monkeypatch.chdir(tmp_path) + monkeypatch.delitem(sys.modules, "kept_helper", raising=False) + monkeypatch.delitem(sys.modules, "failed_only_helper", raising=False) + + assert reflex_base.config._get_config().app_name == "good" + kept = sys.modules["kept_helper"] + + (tmp_path / "rxconfig.py").write_text(broken_rxconfig) + with pytest.raises(RuntimeError, match="broken rxconfig"): + reflex_base.config._get_config() + assert sys.modules["kept_helper"] is kept + assert "failed_only_helper" not in sys.modules + assert "failed_only_helper" not in reflex_base.config._config_module_deps + + (tmp_path / "rxconfig.py").write_text(good_rxconfig) + assert reflex_base.config._get_config().app_name == "good" + assert sys.modules["kept_helper"] is kept def test_other_root_load_evicts_dependency_modules( From 987eb3e3052c47c2e825d56b889385a5abe1c311 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Thu, 10 Sep 2026 11:53:48 +0200 Subject: [PATCH 5/5] fix(config): never evict on a failed load, only record its imports --- .../reflex-base/src/reflex_base/config.py | 21 ++++---- tests/units/test_config.py | 52 +++++++++---------- 2 files changed, 34 insertions(+), 39 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index b881c35783b..600965eae40 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -1000,18 +1000,15 @@ def _get_config(project_root: Path | None = None) -> Config: with _record_imports() as recorder: try: rxconfig = importlib.import_module(constants.Config.MODULE) - except BaseException: - # The recorder only sees modules this attempt imported - # fresh, so nothing here is in use yet. Drop them so the - # retry re-imports them; modules from an earlier successful - # load are untouched since the running app may hold them. - for name in _project_local_modules(recorder.names, project_root): - sys.modules.pop(name, None) - _config_module_deps.discard(name) - raise - _config_module_deps.update( - _project_local_modules(recorder.names, project_root) - ) + finally: + # Record even on failure so a later load from another root + # evicts what this one imported. Nothing is evicted here: + # Python already drops a module whose execution failed, and + # one that imported completely may be held by another + # thread, so it is kept like on any same-root reload. + _config_module_deps.update( + _project_local_modules(recorder.names, project_root) + ) return rxconfig.config finally: for i, entry in enumerate(sys.path): diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 567dd8716d6..ed0671b0a31 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -1319,58 +1319,57 @@ def test_same_root_reload_keeps_dependency_modules( assert "reload_dep_module" in reflex_base.config._config_module_deps -def test_retry_after_failed_load_evicts_dependency_modules( +def test_failed_load_records_dependencies_for_other_root_eviction( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None ): - """A failed load drops the modules it imported so a retry re-imports them. + """A failed load still records its imports so another project evicts them. - Nothing a failed load imported for the first time is in use anywhere, so - the retry must not keep a helper module the developer fixed in the meantime. + Nothing is evicted at failure time: a module that imported completely may + already be held elsewhere. It is recorded, so a load from a different root + drops it like any other dependency of the previous project. Args: tmp_path: The pytest tmp_path fixture. monkeypatch: The pytest monkeypatch fixture. clean_config_modules: Cleanup for modules left behind by the load. """ - helper = tmp_path / "failing_dep_module.py" - helper.write_text("VALUE = 1\n") - (tmp_path / "rxconfig.py").write_text( + broken = tmp_path / "broken" + broken.mkdir() + (broken / "failing_dep_module.py").write_text("VALUE = 1\n") + (broken / "rxconfig.py").write_text( textwrap.dedent( """ - import os - - import failing_dep_module - import reflex as rx + import failing_dep_module # noqa: F401 - if os.environ.get("RX_TEST_FAIL_LOAD"): - raise RuntimeError("broken rxconfig") - config = rx.Config(app_name=f"app{failing_dep_module.VALUE}") + raise RuntimeError("broken rxconfig") """ ) ) - monkeypatch.chdir(tmp_path) + other = tmp_path / "other" + other.mkdir() + (other / "rxconfig.py").write_text( + "import reflex as rx\n\nconfig = rx.Config(app_name='other')\n" + ) monkeypatch.delitem(sys.modules, "failing_dep_module", raising=False) - monkeypatch.setenv("RX_TEST_FAIL_LOAD", "1") with pytest.raises(RuntimeError, match="broken rxconfig"): - reflex_base.config._get_config() + reflex_base.config._get_config(broken) + assert "failing_dep_module" in sys.modules + assert "failing_dep_module" in reflex_base.config._config_module_deps + + assert reflex_base.config._get_config(other).app_name == "other" assert "failing_dep_module" not in sys.modules assert "failing_dep_module" not in reflex_base.config._config_module_deps - monkeypatch.delenv("RX_TEST_FAIL_LOAD") - helper.write_text("VALUE = 22\n") - assert reflex_base.config._get_config().app_name == "app22" - assert "failing_dep_module" in reflex_base.config._config_module_deps - def test_failed_reload_keeps_modules_from_last_good_load( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None ): - """A failed same-root reload drops only what it imported itself. + """A failed same-root reload leaves the last good load's modules alone. The running app may hold classes from the last successful load, so those - modules must survive both the failure and the retry after it. A module the - failed attempt imported for the first time is not in use and is dropped. + modules must survive both the failure and the retry after it. What the + failed attempt imported is recorded alongside them. Args: tmp_path: The pytest tmp_path fixture. @@ -1408,8 +1407,7 @@ def test_failed_reload_keeps_modules_from_last_good_load( with pytest.raises(RuntimeError, match="broken rxconfig"): reflex_base.config._get_config() assert sys.modules["kept_helper"] is kept - assert "failed_only_helper" not in sys.modules - assert "failed_only_helper" not in reflex_base.config._config_module_deps + assert "failed_only_helper" in reflex_base.config._config_module_deps (tmp_path / "rxconfig.py").write_text(good_rxconfig) assert reflex_base.config._get_config().app_name == "good"