From 4b9deb30a344d4a92f442113be2b1cbed5ea4ca0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 18:04:38 +0000 Subject: [PATCH 1/2] Restore deprecated DECORATED_PAGES and get_config(reload=True) shims 0.9.9a1 pre-release testing (FINDING-023, FINDING-008) found two 0.9.8 public names that #6382 removed outright, breaking downstream code with confusing errors: - `from reflex.page import DECORATED_PAGES` raised "ImportError: cannot import name 'DECORATED_PAGES' from 'PageNamespace' (unknown location)" (breaks the published reflex-enterprise flow demo at import). Because the page namespace class replaces the module in sys.modules, a plain module __getattr__ is never consulted, so the shim lives in a PageNamespaceMeta.__getattr__ that emits console.deprecate (0.9.9 -> 1.0) and returns a defaultdict mapping the app name to the active RegistrationContext's decorated_pages list, matching the 0.9.8 shape. - `get_config(reload=True)` raised a bare TypeError. The reload keyword is restored as a deprecated alias that warns and delegates to reload_config(). Also document two approved #6382/#6593 behavior changes in the changelog: a second bare rx.App() in one process now raises ReflexRuntimeError (use a fresh RegistrationContext/fork() for multiple apps), and supersedes-based on_load cancellation on navigation also cancels on_load handlers that are background tasks. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EMjBXPozsNeQNSBZecNH8x --- news/+decorated-pages-shim.deprecation.md | 1 + news/6382.breaking.md | 1 + news/6593.bugfix.1.md | 1 + .../+get-config-reload-shim.deprecation.md | 1 + .../reflex-base/src/reflex_base/config.py | 14 ++++- reflex/page.py | 48 +++++++++++++++- tests/units/test_config.py | 24 ++++++++ tests/units/test_page.py | 56 +++++++++++++++++++ 8 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 news/+decorated-pages-shim.deprecation.md create mode 100644 news/6382.breaking.md create mode 100644 news/6593.bugfix.1.md create mode 100644 packages/reflex-base/news/+get-config-reload-shim.deprecation.md diff --git a/news/+decorated-pages-shim.deprecation.md b/news/+decorated-pages-shim.deprecation.md new file mode 100644 index 00000000000..4954214b9fb --- /dev/null +++ b/news/+decorated-pages-shim.deprecation.md @@ -0,0 +1 @@ +`reflex.page.DECORATED_PAGES` is deprecated (removal in 1.0) but keeps working: reading it emits a deprecation warning and resolves to a mapping of the app name to the active `RegistrationContext`'s `decorated_pages` list of `(render_fn, kwargs)` entries. Use `RegistrationContext.ensure_context().decorated_pages` instead. diff --git a/news/6382.breaking.md b/news/6382.breaking.md new file mode 100644 index 00000000000..0cd811a6429 --- /dev/null +++ b/news/6382.breaking.md @@ -0,0 +1 @@ +A `RegistrationContext` can only be associated with a single `App` instance, so creating a second bare `rx.App()` in one process now raises `ReflexRuntimeError` (0.9.8 allowed it); use a fresh `RegistrationContext` (e.g. `RegistrationContext.fork()`) to create multiple apps. The module-level `reflex.page.DECORATED_PAGES` registry has moved onto the active `RegistrationContext` as `decorated_pages`; reading the old name still works as a deprecated shim that is removed in 1.0. diff --git a/news/6593.bugfix.1.md b/news/6593.bugfix.1.md new file mode 100644 index 00000000000..ddcce2ad62f --- /dev/null +++ b/news/6593.bugfix.1.md @@ -0,0 +1 @@ +Cancelling stale `on_load` work on navigation also applies to `on_load` handlers that are background tasks (`@rx.event(background=True)`): navigating away now cancels such a task mid-flight, where 0.9.8 let it run to completion. Background tasks started from regular (non-superseding) events are unaffected. diff --git a/packages/reflex-base/news/+get-config-reload-shim.deprecation.md b/packages/reflex-base/news/+get-config-reload-shim.deprecation.md new file mode 100644 index 00000000000..f361e0b19c9 --- /dev/null +++ b/packages/reflex-base/news/+get-config-reload-shim.deprecation.md @@ -0,0 +1 @@ +`get_config(reload=True)` is deprecated (removal in 1.0) but keeps working: passing `reload=True` emits a deprecation warning and delegates to `reload_config()`, which forces a fresh load of the config into the current `RegistrationContext`. diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index a17bdbbe40b..2bb9d87226d 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -898,16 +898,28 @@ def _load_config() -> Config: sys.path.extend(orig_sys_path) -def get_config() -> Config: +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. + Args: + reload: Deprecated; force a fresh load of the config. Use + reload_config() instead. + Returns: The app config. """ + if reload: + console.deprecate( + feature_name="get_config(reload=True)", + reason="Use reload_config() to force a fresh load of the config", + deprecation_version="0.9.9", + removal_version="1.0", + ) + return reload_config() ctx = RegistrationContext.ensure_context() if ctx._config is None: # Serialize check/load/set so threads sharing a context load once. diff --git a/reflex/page.py b/reflex/page.py index 5b636bc0598..8f234753c64 100644 --- a/reflex/page.py +++ b/reflex/page.py @@ -3,9 +3,12 @@ from __future__ import annotations import sys +from collections import defaultdict from typing import TYPE_CHECKING +from reflex_base.config import get_config from reflex_base.registry import RegistrationContext +from reflex_base.utils import console if TYPE_CHECKING: from collections.abc import Callable @@ -13,6 +16,8 @@ from reflex_base.event import EventType + DECORATED_PAGES: defaultdict[str, list[tuple[Callable, dict[str, Any]]]] + def page( route: str | None = None, @@ -69,7 +74,48 @@ def decorator(render_fn: Callable): return decorator -class PageNamespace: +class PageNamespaceMeta(type): + """Metaclass serving deprecated module-level globals on the page namespace.""" + + def __getattr__(cls, name: str) -> Any: + """Provide the module-level globals that moved onto `RegistrationContext`. + + Kept so 0.9.8-era code doing `from reflex.page import DECORATED_PAGES` + keeps working (the namespace class replaces this module in `sys.modules`, + so a plain module `__getattr__` would never be consulted). + + Args: + name: The name of the attribute to look up. + + Returns: + The relocated value, resolved against the active `RegistrationContext`. + + Raises: + AttributeError: If the attribute is not a relocated global. + """ + if name == "DECORATED_PAGES": + console.deprecate( + feature_name="reflex.page.DECORATED_PAGES", + reason=( + "Decorated pages now live on the active RegistrationContext. " + "Use RegistrationContext.ensure_context().decorated_pages to " + "read the list of (render_fn, kwargs) entries" + ), + deprecation_version="0.9.9", + removal_version="1.0", + ) + pages: defaultdict[str, list[tuple[Callable, dict[str, Any]]]] = ( + defaultdict(list) + ) + pages[get_config().app_name] = ( + RegistrationContext.ensure_context().decorated_pages + ) + return pages + msg = f"module {cls.__module__!r} has no attribute {name!r}" + raise AttributeError(msg) + + +class PageNamespace(metaclass=PageNamespaceMeta): """A namespace for page names.""" def __new__( diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 361a52dd537..3fddf1d8090 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -890,3 +890,27 @@ def worker(i: int) -> None: assert load_count == 1 assert all(config is results[0] for config in results) + + +def test_get_config_reload_deprecated(mocker: MockerFixture): + """get_config(reload=True) reloads the config and warns about deprecation. + + Args: + mocker: The pytest-mock fixture. + """ + from reflex_base.registry import RegistrationContext + + deprecate = mocker.patch("reflex_base.utils.console.deprecate") + first = rx.Config(app_name="first") + second = rx.Config(app_name="second") + mocker.patch.object(reflex_base.config, "_load_config", side_effect=[first, second]) + + with RegistrationContext(): + assert reflex_base.config.get_config() is first + deprecate.assert_not_called() + assert reflex_base.config.get_config(reload=True) is second + deprecate.assert_called_once() + assert deprecate.call_args.kwargs["feature_name"] == "get_config(reload=True)" + # The freshly loaded config stays cached on the context afterwards. + assert reflex_base.config.get_config() is second + deprecate.assert_called_once() diff --git a/tests/units/test_page.py b/tests/units/test_page.py index 61ba5c886e0..09172b89005 100644 --- a/tests/units/test_page.py +++ b/tests/units/test_page.py @@ -1,3 +1,8 @@ +import importlib + +import pytest +from pytest_mock import MockerFixture +from reflex_base.config import get_config from reflex_base.registry import RegistrationContext from reflex import text @@ -59,3 +64,54 @@ def load_foo(): "script_tags": ["foo-script"], "title": "Foo", } + + +def test_decorated_pages_shim_from_import( + clean_registration_context: RegistrationContext, mocker: MockerFixture +): + """The deprecated `from reflex.page import DECORATED_PAGES` still works. + + Args: + clean_registration_context: A fresh registration context. + mocker: The pytest-mock fixture. + """ + deprecate = mocker.patch("reflex_base.utils.console.deprecate") + + def foo_(): + return text("foo") + + page(route="foo")(foo_) + + from reflex.page import DECORATED_PAGES + + assert ( + next(iter(DECORATED_PAGES.values())) + is clean_registration_context.decorated_pages + ) + deprecate.assert_called_once() + assert deprecate.call_args.kwargs["feature_name"] == "reflex.page.DECORATED_PAGES" + + +def test_decorated_pages_shim_module_attribute( + clean_registration_context: RegistrationContext, mocker: MockerFixture +): + """`reflex.page.DECORATED_PAGES` maps the app name to the context's pages. + + Args: + clean_registration_context: A fresh registration context. + mocker: The pytest-mock fixture. + """ + mocker.patch("reflex_base.utils.console.deprecate") + page_module = importlib.import_module("reflex.page") + + pages = page_module.DECORATED_PAGES + assert pages[get_config().app_name] is clean_registration_context.decorated_pages + # 0.9.8 exposed a defaultdict(list), so unknown keys resolve to empty lists. + assert pages["some-other-app"] == [] + + +def test_page_namespace_unknown_attribute_raises(): + """Unknown attributes on the page namespace raise AttributeError.""" + page_module = importlib.import_module("reflex.page") + with pytest.raises(AttributeError, match=r"reflex\.page"): + _ = page_module.definitely_not_an_attribute From dbaa9edf22b3979c1b9c5d48af0fdb75e007e67a Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Fri, 28 Aug 2026 11:21:39 -0700 Subject: [PATCH 2/2] Hold _load_config_lock during `reload_config()` avoid racing threads caching different copies of the reloaded config Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- packages/reflex-base/src/reflex_base/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 2bb9d87226d..f11fd4fb01d 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -919,7 +919,8 @@ def get_config(reload: bool = False) -> Config: deprecation_version="0.9.9", removal_version="1.0", ) - return reload_config() + with _load_config_lock: + return reload_config() ctx = RegistrationContext.ensure_context() if ctx._config is None: # Serialize check/load/set so threads sharing a context load once.