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. diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 9541eab5a24..0d2f750e9eb 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -169,6 +169,7 @@ def app_root_template( window_libraries: list[tuple[str, str]], render: dict[str, Any], dynamic_imports: set[str], + hydrate_fallback_export: str | None = None, ): """Template for the App root. @@ -179,6 +180,7 @@ 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_export: The exported name of the hydrate-fallback memo module to re-export as ``HydrateFallback``, or None for no fallback. Returns: Rendered App root component as string. @@ -186,6 +188,13 @@ 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_export is not None: + hydrate_fallback_str = ( + f"export {{ {hydrate_fallback_export} as HydrateFallback }} " + f'from "$/{constants.Dirs.COMPONENTS_PATH}/{hydrate_fallback_export}";' + ) + custom_code_str = "\n".join(custom_codes) import_window_libraries = "\n".join([ @@ -242,7 +251,7 @@ def app_root_template( export default function App() {{ return jsx(Outlet, {{}}); }} - +{hydrate_fallback_str} """ diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index c5011454e51..c31e37dd9db 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -1738,6 +1738,32 @@ def passthrough(children: Var[Component]) -> Component: return _create_component_wrapper(definition), definition +def create_component_memo(component: Component, name: str) -> MemoComponentDefinition: + """Create an unregistered component memo that renders a standalone component. + + Unlike `create_passthrough_component_memo`, the memo body renders the full + component (no `{children}` hole), so it works where the memo is referenced + without children — e.g. re-exported as a route's `HydrateFallback`. Register + the returned definition (e.g. in `CompileContext.auto_memo_components`) so it + compiles to its own JS module under `$/{components}/{export_name}`. + + Args: + component: The component to render in the memo body. + name: The Python name used to derive the exported React component name. + + Returns: + The component memo definition. + """ + + def snapshot() -> Component: + return component + + snapshot.__name__ = name + snapshot.__qualname__ = name + snapshot.__module__ = __name__ + return _create_component_definition(snapshot, Component) + + _MemoVarT = TypeVar("_MemoVarT") @@ -1889,6 +1915,7 @@ def memo(fn: Callable[..., Any]) -> _MemoComponentWrapper | _MemoFunctionWrapper "MemoComponentDefinition", "MemoDefinition", "MemoFunctionDefinition", + "create_component_memo", "create_passthrough_component_memo", "memo", ] 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/pyi_hashes.json b/pyi_hashes.json index f7cde8facda..76830e406cc 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -120,5 +120,5 @@ "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "2c5fadcc014056f041cd4d916137d9e7", "reflex/__init__.pyi": "674cc55e646deb97c0e414e1d0e850ef", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", - "reflex/experimental/memo.pyi": "97f96db9b67acdd103f71f9da3548600" + "reflex/experimental/memo.pyi": "79573b03f5cd29222d9c7edd926541b1" } diff --git a/reflex/app.py b/reflex/app.py index 267b02c3ec1..9c0d2af8db0 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,72 @@ 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. + + Raises: + ValueError: If the path has no dot separating the module from the attribute. """ - config = get_config() + 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) - 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. + + 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 config_overlay + 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 +342,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 +431,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 +1183,32 @@ 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. + + 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. + """ + 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..c835335afee 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -21,6 +21,7 @@ MemoComponentDefinition, MemoDefinition, MemoFunctionDefinition, + create_component_memo, ) from reflex_base.config import get_config from reflex_base.constants.compiler import PageNames, ResetStylesheet @@ -127,11 +128,15 @@ 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_export: str | None = None +) -> str: """Compile the app template component. Args: app_root: The app root to compile. + hydrate_fallback_export: The exported name of the hydrate-fallback memo + component to re-export as ``HydrateFallback``, or None for no fallback. Returns: The compiled app. @@ -154,6 +159,7 @@ def _compile_app(app_root: Component) -> str: window_libraries=window_libraries_deduped, render=app_root.render(), dynamic_imports=app_root._get_all_dynamic_imports(), + hydrate_fallback_export=hydrate_fallback_export, ) @@ -544,11 +550,15 @@ 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_export: str | None = None +) -> tuple[str, str]: """Compile the app root. Args: app_root: The app root component to compile. + hydrate_fallback_export: The exported name of the hydrate-fallback memo + component to re-export as ``HydrateFallback``, or None for no fallback. Returns: The path and code of the compiled app wrapper. @@ -559,7 +569,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_export) return output_path, code @@ -1111,6 +1121,20 @@ 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() + hydrate_fallback_export = None + if hydrate_fallback is not None: + hydrate_fallback._add_style_recursive(app.style) + # Compile the fallback through the memo pipeline so it lands in its own + # JS module; root.jsx then re-exports it as HydrateFallback. + hydrate_fallback_definition = create_component_memo( + hydrate_fallback, "hydrate_fallback" + ) + compile_ctx.auto_memo_components[hydrate_fallback_definition.export_name] = ( + hydrate_fallback_definition + ) + hydrate_fallback_export = hydrate_fallback_definition.export_name + memo_component_files, memo_components_imports = compile_memo_components( ( *MEMOS.values(), @@ -1203,7 +1227,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_export)) 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..1dbb4ab27bc 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -382,6 +382,27 @@ 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 memo export should be re-exported as HydrateFallback.""" + reset_bundled_libraries() + + _, code = compiler.compile_app_root(rx.el.div("hello"), "MyFallback") + + assert ( + "export { MyFallback as HydrateFallback } " + 'from "$/utils/components/MyFallback";' 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..66453a3d8a7 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -2199,6 +2199,189 @@ def test_compile_without_radix_components_skips_radix_plugin( mock_deprecate.assert_not_called() +def _hydrate_fallback_module(web_dir: Path) -> Path: + """Path to the compiled HydrateFallback memo module under a web dir. + + Args: + web_dir: The app's compiled web directory. + + Returns: + The path to the HydrateFallback memo module. + """ + return ( + web_dir / constants.Dirs.COMPONENTS_PATH / f"HydrateFallback{constants.Ext.JSX}" + ) + + +def test_compile_hydrate_fallback_emits_hydrate_fallback( + compilable_app: tuple[App, Path], + mocker: MockerFixture, +): + """A hydrate_fallback should compile to a memo module re-exported by root.jsx.""" + 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 { HydrateFallback as HydrateFallback } " + 'from "$/utils/components/HydrateFallback";' in app_root + ) + assert "Hydrating..." in _hydrate_fallback_module(web_dir).read_text() + + +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 'from "$/utils/components/HydrateFallback";' in app_root + assert "Fallback from config..." in _hydrate_fallback_module(web_dir).read_text() + + +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() + + fallback_module = _hydrate_fallback_module(web_dir).read_text() + assert "Fallback from app..." in fallback_module + assert "Fallback from config..." not in fallback_module + + +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_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 + 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,