Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/6630.feature.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/reflex-base/news/6630.feature.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 15 additions & 1 deletion packages/reflex-base/src/reflex_base/compiler/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -179,13 +181,25 @@ 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.
"""
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([
Expand Down Expand Up @@ -242,7 +256,7 @@ def app_root_template(
export default function App() {{
return jsx(Outlet, {{}});
}}

{hydrate_fallback_str}
"""


Expand Down
3 changes: 3 additions & 0 deletions packages/reflex-base/src/reflex_base/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
108 changes: 89 additions & 19 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import copy
import dataclasses
import functools
import importlib
import inspect
import json
import operator
Expand Down Expand Up @@ -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)
Comment thread
adhami3310 marked this conversation as resolved.

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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Comment thread
adhami3310 marked this conversation as resolved.
"""Check if the app should be compiled.

Expand Down
39 changes: 33 additions & 6 deletions reflex/compiler/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
)


Expand Down Expand Up @@ -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.
Expand All @@ -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


Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 1 addition & 3 deletions tests/integration/test_extra_overlay_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
19 changes: 19 additions & 0 deletions tests/units/compiler/test_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading