From 33f17649a49eb1927eca42c7ff4566d9adf16b50 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 8 Jun 2026 15:15:42 -0700 Subject: [PATCH 1/3] feat: add hydrate_fallback API for the page hydration window Render a component during the React Router hydration window instead of a blank white page. - rx.App(hydrate_fallback=...): a Component/ComponentCallable compiled to React Router's HydrateFallback export in root.jsx (inside the document Layout, so it gets theme/color-mode context). - Config.hydrate_fallback (env: REFLEX_HYDRATE_FALLBACK): dotted import path to a no-arg callable returning a component, used when App.hydrate_fallback is unset. App field takes precedence. Factor the import-path resolution shared with extra_overlay_function into _resolve_import_path / _component_from_import_path, switching from __import__ to importlib.import_module so nested module paths resolve correctly. Closes ENG-9724 --- .../src/reflex_base/compiler/templates.py | 16 +- .../reflex-base/src/reflex_base/config.py | 3 + reflex/app.py | 94 ++++++++-- reflex/compiler/compiler.py | 39 ++++- .../test_extra_overlay_function.py | 4 +- tests/units/compiler/test_compiler.py | 19 ++ tests/units/test_app.py | 163 ++++++++++++++++++ 7 files changed, 309 insertions(+), 29 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 9541eab5a24..4c3bd0cc6cb 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -169,6 +169,8 @@ def app_root_template( window_libraries: list[tuple[str, str]], render: dict[str, Any], dynamic_imports: set[str], + hydrate_fallback_render: dict[str, Any] | str | None = None, + hydrate_fallback_hooks: dict[str, VarData | None] | None = None, ): """Template for the App root. @@ -179,6 +181,8 @@ def app_root_template( window_libraries: The list of window libraries. render: The dictionary of render functions. dynamic_imports: The set of dynamic imports. + hydrate_fallback_render: The render of the component shown while hydrating, or None for no fallback. + hydrate_fallback_hooks: The hooks for the hydrate fallback component. Returns: Rendered App root component as string. @@ -186,6 +190,16 @@ def app_root_template( imports_str = "\n".join([_RenderUtils.get_import(mod) for mod in imports]) dynamic_imports_str = "\n".join(dynamic_imports) + hydrate_fallback_str = "" + if hydrate_fallback_render is not None: + hydrate_fallback_hooks_str = _render_hooks(hydrate_fallback_hooks or {}) + hydrate_fallback_str = f""" +export function HydrateFallback() {{ +{hydrate_fallback_hooks_str} + return ({_RenderUtils.render(hydrate_fallback_render)}) +}} +""" + custom_code_str = "\n".join(custom_codes) import_window_libraries = "\n".join([ @@ -242,7 +256,7 @@ def app_root_template( export default function App() {{ return jsx(Outlet, {{}}); }} - +{hydrate_fallback_str} """ diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 1bf0bc4a074..5ea80afb439 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -179,6 +179,7 @@ class BaseConfig: show_built_with_reflex: Whether to display the sticky "Built with Reflex" badge on all pages. is_reflex_cloud: Whether the app is running in the reflex cloud environment. extra_overlay_function: Extra overlay function to run after the app is built. Formatted such that `from path_0.path_1... import path[-1]`, and calling it with no arguments would work. For example, "reflex_components_moment.moment". + hydrate_fallback: Function returning the component shown while the page is hydrating (React Router's HydrateFallback), used when App.hydrate_fallback is not set. Formatted such that `from path_0.path_1... import path[-1]`, and calling it with no arguments would work. For example, "my_app.components.loading". plugins: List of plugins to use in the app. disable_plugins: List of plugin types to disable in the app. transport: The transport method for client-server communication. @@ -255,6 +256,8 @@ class BaseConfig: extra_overlay_function: str | None = None + hydrate_fallback: str | None = None + plugins: list[Plugin] = dataclasses.field(default_factory=list) disable_plugins: list[type[Plugin]] = dataclasses.field(default_factory=list) diff --git a/reflex/app.py b/reflex/app.py index 267b02c3ec1..09ec6b47a98 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -7,6 +7,7 @@ import copy import dataclasses import functools +import importlib import inspect import json import operator @@ -160,32 +161,63 @@ def default_backend_exception_handler(exception: Exception) -> EventSpec: ) -def extra_overlay_function() -> Component | None: - """Extra overlay function to add to the overlay component. +def _resolve_import_path(import_path: str) -> Any: + """Resolve a dotted import path to the object it refers to. + + The path is split on the final dot: everything before it is imported as a + module, and the final segment is read as an attribute of that module + (``from path_0.path_1... import path[-1]``). + + Args: + import_path: The dotted import path (e.g. "my_app.components.loading"). Returns: - The extra overlay function. + The object referenced by the import path. """ - config = get_config() + module, _, attribute_name = import_path.rpartition(".") + return getattr(importlib.import_module(module), attribute_name) - extra_config = config.extra_overlay_function - config_overlay = None - if extra_config: - module, _, function_name = extra_config.rpartition(".") - try: - module = __import__(module) - config_overlay = Fragment.create(getattr(module, function_name)()) - config_overlay._get_all_imports() - except Exception as e: - from reflex.compiler.utils import save_error - log_path = save_error(e) +def _component_from_import_path( + import_path: str, feature_name: str +) -> Component | None: + """Resolve a dotted import path and render its callable into a component. - console.error( - f"Error loading extra_overlay_function {extra_config}. Error saved to {log_path}" - ) + The final segment of the path must be a no-arg callable returning a component. - return config_overlay + Args: + import_path: The dotted import path to the component callable. + feature_name: The config name to reference in the error message on failure. + + Returns: + The resolved component, or None if it could not be loaded. + """ + try: + component = Fragment.create(_resolve_import_path(import_path)()) + component._get_all_imports() + except Exception as e: + from reflex.compiler.utils import save_error + + log_path = save_error(e) + + console.error( + f"Error loading {feature_name} {import_path}. Error saved to {log_path}" + ) + return None + + return component + + +def extra_overlay_function() -> Component | None: + """Extra overlay function to add to the overlay component. + + Returns: + The extra overlay function. + """ + extra_config = get_config().extra_overlay_function + if extra_config: + return _component_from_import_path(extra_config, "extra_overlay_function") + return None def default_overlay_component() -> Component: @@ -301,6 +333,7 @@ class App(MiddlewareMixin, LifespanMixin): backend_exception_handler: Backend error handler function. toaster: Put the toast provider in the app wrap. api_transformer: Transform the ASGI app before running it. + hydrate_fallback: Component to render while the page is hydrating (React Router's HydrateFallback). Takes precedence over the hydrate_fallback config (REFLEX_HYDRATE_FALLBACK). """ theme: Component | None = dataclasses.field(default=None) @@ -389,6 +422,8 @@ class App(MiddlewareMixin, LifespanMixin): toaster: Component | None = dataclasses.field(default_factory=toast.provider) + hydrate_fallback: Component | ComponentCallable | None = None + api_transformer: ( Sequence[Callable[[ASGIApp], ASGIApp] | Starlette] | Callable[[ASGIApp], ASGIApp] @@ -1139,6 +1174,27 @@ def reducer(parent: Component, key: tuple[int, str]) -> Component: ) return root + def _resolve_hydrate_fallback(self) -> Component | None: + """Resolve the component shown while the page is hydrating. + + The App-level ``hydrate_fallback`` takes precedence; otherwise the + ``hydrate_fallback`` config (settable via ``REFLEX_HYDRATE_FALLBACK``) + is resolved from its dotted import path. + + Returns: + The resolved hydrate fallback component, or None if none is configured. + """ + from reflex.compiler.compiler import into_component + + if self.hydrate_fallback is not None: + return into_component(self.hydrate_fallback) + hydrate_fallback_config = get_config().hydrate_fallback + if hydrate_fallback_config: + return _component_from_import_path( + hydrate_fallback_config, "hydrate_fallback" + ) + return None + def _should_compile(self) -> bool: """Check if the app should be compiled. diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 7ce838968cf..a07c4d9a8dc 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -127,11 +127,12 @@ def _normalize_library_name(lib: str) -> str: return lib.replace("$/", "").replace("@", "").replace("/", "_").replace("-", "_") -def _compile_app(app_root: Component) -> str: +def _compile_app(app_root: Component, hydrate_fallback: Component | None = None) -> str: """Compile the app template component. Args: app_root: The app root to compile. + hydrate_fallback: The component shown while hydrating, or None for no fallback. Returns: The compiled app. @@ -147,13 +148,29 @@ def _compile_app(app_root: Component) -> str: app_root_imports = app_root._get_all_imports() _apply_common_imports(app_root_imports) + custom_codes = list(app_root._get_all_custom_code()) + dynamic_imports = set(app_root._get_all_dynamic_imports()) + + hydrate_fallback_render = None + hydrate_fallback_hooks = None + if hydrate_fallback is not None: + app_root_imports = utils.merge_imports( + app_root_imports, hydrate_fallback._get_all_imports() + ) + custom_codes.extend(hydrate_fallback._get_all_custom_code()) + dynamic_imports |= hydrate_fallback._get_all_dynamic_imports() + hydrate_fallback_render = hydrate_fallback.render() + hydrate_fallback_hooks = hydrate_fallback._get_all_hooks() + return templates.app_root_template( imports=utils.compile_imports(app_root_imports), - custom_codes=app_root._get_all_custom_code(), + custom_codes=custom_codes, hooks=app_root._get_all_hooks(), window_libraries=window_libraries_deduped, render=app_root.render(), - dynamic_imports=app_root._get_all_dynamic_imports(), + dynamic_imports=dynamic_imports, + hydrate_fallback_render=hydrate_fallback_render, + hydrate_fallback_hooks=hydrate_fallback_hooks, ) @@ -544,11 +561,14 @@ def compile_document_root( return output_path, code -def compile_app_root(app_root: Component) -> tuple[str, str]: +def compile_app_root( + app_root: Component, hydrate_fallback: Component | None = None +) -> tuple[str, str]: """Compile the app root. Args: app_root: The app root component to compile. + hydrate_fallback: The component shown while hydrating, or None for no fallback. Returns: The path and code of the compiled app wrapper. @@ -559,7 +579,7 @@ def compile_app_root(app_root: Component) -> tuple[str, str]: ) # Compile the document root. - code = _compile_app(app_root) + code = _compile_app(app_root, hydrate_fallback) return output_path, code @@ -1111,6 +1131,13 @@ def compile_app( app_root = app._app_root(app_wrappers) all_imports = utils.merge_imports(all_imports, app_root._get_all_imports()) + hydrate_fallback = app._resolve_hydrate_fallback() + if hydrate_fallback is not None: + hydrate_fallback._add_style_recursive(app.style) + all_imports = utils.merge_imports( + all_imports, hydrate_fallback._get_all_imports() + ) + memo_component_files, memo_components_imports = compile_memo_components( ( *MEMOS.values(), @@ -1203,7 +1230,7 @@ def add_save_task( ) progress.advance(task) - compile_results.append(compile_app_root(app_root)) + compile_results.append(compile_app_root(app_root, hydrate_fallback)) progress.advance(task) progress.stop() diff --git a/tests/integration/test_extra_overlay_function.py b/tests/integration/test_extra_overlay_function.py index e766360f8ba..963aa88b8c6 100644 --- a/tests/integration/test_extra_overlay_function.py +++ b/tests/integration/test_extra_overlay_function.py @@ -24,9 +24,7 @@ def index(): ) app = rx.App() - rx.config.get_config().extra_overlay_function = ( - "reflex_components_radix.themes.components.button" - ) + rx.config.get_config().extra_overlay_function = "reflex_components_radix.button" app.add_page(index) diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index e2f1b769668..5c6e73ad997 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -382,6 +382,25 @@ def test_compile_app_root_omits_radix_window_library_by_default(): assert "@radix-ui/themes" not in code +def test_compile_app_root_omits_hydrate_fallback_by_default(): + """Apps without a hydrate_fallback should not export a HydrateFallback.""" + reset_bundled_libraries() + + _, code = compiler.compile_app_root(rx.el.div("hello")) + + assert "HydrateFallback" not in code + + +def test_compile_app_root_with_hydrate_fallback_exports_hydrate_fallback(): + """A hydrate_fallback should be emitted as the HydrateFallback export.""" + reset_bundled_libraries() + + _, code = compiler.compile_app_root(rx.el.div("hello"), rx.el.div("loading...")) + + assert "export function HydrateFallback()" in code + assert "loading..." in code + + def test_compile_app_root_includes_radix_window_library_when_bundled(): """Bundled Radix libraries should be exposed to window.__reflex.""" reset_bundled_libraries() diff --git a/tests/units/test_app.py b/tests/units/test_app.py index bc20334d70e..780a1616c58 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -2199,6 +2199,169 @@ def test_compile_without_radix_components_skips_radix_plugin( mock_deprecate.assert_not_called() +def test_compile_hydrate_fallback_emits_hydrate_fallback( + compilable_app: tuple[App, Path], + mocker: MockerFixture, +): + """A hydrate_fallback should compile into the root HydrateFallback export.""" + conf = rx.Config(app_name="testing") + mocker.patch("reflex_base.config._get_config", return_value=conf) + app, web_dir = compilable_app + mocker.patch("reflex.utils.prerequisites.get_web_dir", return_value=web_dir) + + app.hydrate_fallback = rx.el.div("Hydrating...") + app.add_page(lambda: rx.el.div("Index"), route="/") + app._compile() + + app_root = ( + web_dir / constants.Dirs.PAGES / constants.PageNames.APP_ROOT + ).read_text() + + assert "export function HydrateFallback()" in app_root + assert "Hydrating..." in app_root + + +def _example_hydrate_fallback() -> rx.Component: + """Hydrate fallback component referenced by dotted import path in tests. + + Returns: + A simple fallback component. + """ + return rx.el.div("Fallback from config...") + + +def test_compile_hydrate_fallback_from_config( + compilable_app: tuple[App, Path], + mocker: MockerFixture, +): + """The hydrate_fallback config (env-settable) should define the HydrateFallback.""" + conf = rx.Config( + app_name="testing", + hydrate_fallback="tests.units.test_app._example_hydrate_fallback", + ) + mocker.patch("reflex_base.config._get_config", return_value=conf) + app, web_dir = compilable_app + mocker.patch("reflex.utils.prerequisites.get_web_dir", return_value=web_dir) + + app.add_page(lambda: rx.el.div("Index"), route="/") + app._compile() + + app_root = ( + web_dir / constants.Dirs.PAGES / constants.PageNames.APP_ROOT + ).read_text() + + assert "export function HydrateFallback()" in app_root + assert "Fallback from config..." in app_root + + +def test_app_hydrate_fallback_takes_precedence_over_config( + compilable_app: tuple[App, Path], + mocker: MockerFixture, +): + """App.hydrate_fallback should win over the hydrate_fallback config.""" + conf = rx.Config( + app_name="testing", + hydrate_fallback="tests.units.test_app._example_hydrate_fallback", + ) + mocker.patch("reflex_base.config._get_config", return_value=conf) + app, web_dir = compilable_app + mocker.patch("reflex.utils.prerequisites.get_web_dir", return_value=web_dir) + + app.hydrate_fallback = rx.el.div("Fallback from app...") + app.add_page(lambda: rx.el.div("Index"), route="/") + app._compile() + + app_root = ( + web_dir / constants.Dirs.PAGES / constants.PageNames.APP_ROOT + ).read_text() + + assert "Fallback from app..." in app_root + assert "Fallback from config..." not in app_root + + +def test_resolve_import_path_resolves_nested_attribute(): + """A dotted path should resolve to the attribute of its nested module.""" + from reflex_components_radix.themes.components.button import button + + from reflex.app import _resolve_import_path + + resolved = _resolve_import_path( + "reflex_components_radix.themes.components.button.button" + ) + + assert resolved is button + + +def test_resolve_import_path_raises_for_missing_module(): + """An unresolvable path should raise (caller handles the failure).""" + from reflex.app import _resolve_import_path + + with pytest.raises(ModuleNotFoundError): + _resolve_import_path("nonexistent_module.does_not_exist") + + +def test_component_from_import_path_resolves_callable(): + """A dotted path to a component callable should resolve to a component.""" + from reflex_components_core.base.fragment import Fragment + from reflex_components_core.el.elements.typography import Div + + from reflex.app import _component_from_import_path + + component = _component_from_import_path( + "tests.units.test_app._example_hydrate_fallback", "hydrate_fallback" + ) + + assert isinstance(component, Fragment) + assert isinstance(component.children[0], Div) + + +def test_component_from_import_path_resolves_nested_module(): + """A multi-segment module path should resolve via importlib, not the top package.""" + from reflex_components_core.base.fragment import Fragment + from reflex_components_radix.themes.components.button import Button + + from reflex.app import _component_from_import_path + + # The callable lives in a deeply nested module; ``__import__`` would have + # returned the top-level package instead of this submodule. + component = _component_from_import_path( + "reflex_components_radix.themes.components.button.button", + "extra_overlay_function", + ) + + assert isinstance(component, Fragment) + assert isinstance(component.children[0], Button) + + +def test_component_from_import_path_invalid_returns_none(mocker: MockerFixture): + """An unresolvable path should be logged and return None instead of raising.""" + from reflex.app import _component_from_import_path + + mocker.patch("reflex.compiler.utils.save_error", return_value="/tmp/error.log") + mock_error = mocker.patch("reflex_base.utils.console.error") + + component = _component_from_import_path( + "nonexistent_module.does_not_exist", "hydrate_fallback" + ) + + assert component is None + mock_error.assert_called_once() + + +def test_component_from_import_path_non_callable_returns_none(mocker: MockerFixture): + """A path resolving to a non-callable attribute should return None.""" + from reflex.app import _component_from_import_path + + mocker.patch("reflex.compiler.utils.save_error", return_value="/tmp/error.log") + mock_error = mocker.patch("reflex_base.utils.console.error") + + # ``reflex.constants`` is a module, not a callable returning a component. + component = _component_from_import_path("reflex.constants", "hydrate_fallback") + + assert component is None + mock_error.assert_called_once() + + def test_compile_with_radix_component_auto_enables_radix_plugin( compilable_app: tuple[App, Path], mocker: MockerFixture, From 295795549afd1e635262ec326e53f2ddc0a7d437 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 8 Jun 2026 15:19:23 -0700 Subject: [PATCH 2/3] docs: add changelog fragments for hydrate_fallback API --- news/6630.feature.md | 1 + packages/reflex-base/news/6630.feature.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 news/6630.feature.md create mode 100644 packages/reflex-base/news/6630.feature.md diff --git a/news/6630.feature.md b/news/6630.feature.md new file mode 100644 index 00000000000..dd43dc15e10 --- /dev/null +++ b/news/6630.feature.md @@ -0,0 +1 @@ +Added `App.hydrate_fallback`, a component rendered during the page's hydration window (React Router's `HydrateFallback`) instead of a blank white page. It can also be configured without code through the `hydrate_fallback` config — a dotted import path to a no-arg callable returning a component, settable via the `REFLEX_HYDRATE_FALLBACK` environment variable — with the `App` argument taking precedence. Note that the fallback only covers the hydration window after the JS bundle has loaded, not the initial bundle download. diff --git a/packages/reflex-base/news/6630.feature.md b/packages/reflex-base/news/6630.feature.md new file mode 100644 index 00000000000..f15d1cdd6ef --- /dev/null +++ b/packages/reflex-base/news/6630.feature.md @@ -0,0 +1 @@ +Added a `hydrate_fallback` config option (settable via the `REFLEX_HYDRATE_FALLBACK` environment variable), a dotted import path to a callable returning the component shown while the page is hydrating. The app root template now emits a React Router `HydrateFallback` export when a fallback is provided, and the import-path resolution shared with `extra_overlay_function` resolves nested module paths correctly. From 81c033add88b24543238b1512e794fe1bf68e8f0 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 8 Jun 2026 18:57:42 -0700 Subject: [PATCH 3/3] fix: clearer error for single-segment hydrate_fallback import path Guard _resolve_import_path against paths with no dot (module would be empty, yielding an opaque 'Empty module name' error). Document the deliberate fail-fast vs graceful-degradation split between the App-field and config resolution paths. Addresses review feedback on #6630. --- reflex/app.py | 14 ++++++++++++++ tests/units/test_app.py | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/reflex/app.py b/reflex/app.py index 09ec6b47a98..9c0d2af8db0 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -173,8 +173,17 @@ def _resolve_import_path(import_path: str) -> Any: Returns: The object referenced by the import path. + + Raises: + ValueError: If the path has no dot separating the module from the attribute. """ module, _, attribute_name = import_path.rpartition(".") + if not module: + msg = ( + f"Invalid import path {import_path!r}: expected a dotted " + "'module.attribute' path (e.g. 'my_app.components.loading')." + ) + raise ValueError(msg) return getattr(importlib.import_module(module), attribute_name) @@ -1181,6 +1190,11 @@ def _resolve_hydrate_fallback(self) -> Component | None: ``hydrate_fallback`` config (settable via ``REFLEX_HYDRATE_FALLBACK``) is resolved from its dotted import path. + Error handling differs between the two by design: an App-level callable + that raises propagates (fail fast, like ``add_page``), since it was + passed explicitly in code; the config/env path degrades gracefully (logs + and returns None) as it is ambient deployment configuration. + Returns: The resolved hydrate fallback component, or None if none is configured. """ diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 780a1616c58..4fb80c25045 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -2300,6 +2300,14 @@ def test_resolve_import_path_raises_for_missing_module(): _resolve_import_path("nonexistent_module.does_not_exist") +def test_resolve_import_path_raises_for_single_segment(): + """A path without a dot should raise a descriptive error, not an opaque one.""" + from reflex.app import _resolve_import_path + + with pytest.raises(ValueError, match="expected a dotted"): + _resolve_import_path("mymodule") + + def test_component_from_import_path_resolves_callable(): """A dotted path to a component callable should resolve to a component.""" from reflex_components_core.base.fragment import Fragment