diff --git a/news/7096.bugfix.md b/news/7096.bugfix.md new file mode 100644 index 00000000000..ba337a34007 --- /dev/null +++ b/news/7096.bugfix.md @@ -0,0 +1 @@ +Persist bundled-library metadata for backend-only workers so state hydration can serialize values that reference libraries included in the frontend build. diff --git a/packages/reflex-base/news/7096.bugfix.md b/packages/reflex-base/news/7096.bugfix.md new file mode 100644 index 00000000000..9a78cd12ad0 --- /dev/null +++ b/packages/reflex-base/news/7096.bugfix.md @@ -0,0 +1 @@ +Persist the bundled-library registry used by backend-only workers when serializing state hydration data. diff --git a/packages/reflex-base/src/reflex_base/constants/base.py b/packages/reflex-base/src/reflex_base/constants/base.py index b5c9079517e..9387ec6d9c9 100644 --- a/packages/reflex-base/src/reflex_base/constants/base.py +++ b/packages/reflex-base/src/reflex_base/constants/base.py @@ -54,16 +54,18 @@ class Dirs(SimpleNamespace): # module, kept separate from other ``.web`` output so a mirrored module # path can't collide with framework files (e.g. ``app/``, ``utils/``). APP_COMPONENTS = "app_components" + # Where compilation artifacts for the backend are stored. + BACKEND = "backend" # The name of the env json file. ENV_JSON = "env.json" + # The compiled bundled-library registry consumed by backend-only workers. + BUNDLED_LIBRARIES = BACKEND + "/bundled_libraries.json" # The name of the reflex json file. REFLEX_JSON = "reflex.json" # The name of the postcss config file. POSTCSS_JS = "postcss.config.js" # The name of the states directory. STATES = ".states" - # Where compilation artifacts for the backend are stored. - BACKEND = "backend" # JSON-encoded list of page routes that need to be evaluated on the backend. STATEFUL_PAGES = "stateful_pages.json" # Marker file indicating that upload component was used in the frontend. diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 23534ea9726..487b3845963 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -1241,6 +1241,7 @@ def compile_app( logger.debug(f"BE Evaluating stateful page: {route}") app._compile_page(route, save_page=False) if app._state is not None: + utils._restore_bundled_libraries() utils._compile_initial_state(app._state) app._add_optional_endpoints() return False @@ -1261,6 +1262,7 @@ def compile_app( app._write_stateful_pages_marker() if app._state is not None: + utils._restore_bundled_libraries() utils._compile_initial_state(app._state) app._add_optional_endpoints() return False @@ -1463,13 +1465,14 @@ def add_save_task( compile_results.append(result) progress.advance(task) - compile_results.append( + compile_results.extend([ compile_contexts( app._state, radix_themes_plugin.get_theme(), component_imports=all_imports, - ) - ) + ), + utils._compile_bundled_libraries(), + ]) progress.advance(task) compile_results.append(compile_app_root(app_root, hydrate_fallback_export)) diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index a0c5865b5b6..b5d2beb2f74 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -273,6 +273,33 @@ def serialize_initial_value(value: Any) -> Any: ) +def _compile_bundled_libraries() -> tuple[str, str]: + """Return the bundled-library registry as a frontend build artifact. + + Returns: + The output path and serialized registry. + """ + bundled_libraries = RegistrationContext.ensure_context().bundled_libraries + return constants.Dirs.BUNDLED_LIBRARIES, format.json_dumps(bundled_libraries) + + +def _restore_bundled_libraries() -> None: + """Restore the registry emitted by the most recent frontend compile.""" + path = get_web_dir() / constants.Dirs.BUNDLED_LIBRARIES + try: + bundled_libraries = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return + if not isinstance(bundled_libraries, list) or not all( + isinstance(library, str) for library in bundled_libraries + ): + return + context = RegistrationContext.ensure_context() + context.bundled_libraries[:] = list( + dict.fromkeys([*bundled_libraries, *context.bundled_libraries]) + ) + + def _compile_client_storage_field( field: Field, ) -> ( diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 922c728437b..5b02be0ad80 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -28,6 +28,7 @@ import reflex as rx from reflex.compiler import compiler, utils from reflex.state import BaseState +from reflex.utils import prerequisites @pytest.mark.parametrize( @@ -874,9 +875,7 @@ def test_compile_registers_plugin_routes_on_backend_early_return( mocker.patch.object(app, "_should_compile", return_value=False) compile_page = mocker.patch.object(app, "_compile_page") mocker.patch.object(app, "_add_optional_endpoints") - mocker.patch.object( - compiler.prerequisites, "get_backend_dir", return_value=tmp_path - ) + mocker.patch.object(prerequisites, "get_backend_dir", return_value=tmp_path) mocker.patch.object(compiler, "get_config", return_value=config) if with_stateful_marker: @@ -892,6 +891,65 @@ def test_compile_registers_plugin_routes_on_backend_early_return( compile_page.assert_not_called() +@pytest.mark.usefixtures("clean_registration_context") +def test_backend_compile_restores_registry_before_initial_state_serialization( + tmp_path: Path, mocker: MockerFixture +): + """Backend-only compilation restores frontend metadata before serializing state.""" + + class BackendState(rx.State): + """State used to exercise backend-only compilation.""" + + app = rx.App(_state=BackendState) + mocker.patch.object(app, "_apply_decorated_pages") + mocker.patch.object(app, "_should_compile", return_value=False) + mocker.patch.object(app, "_add_optional_endpoints") + mocker.patch.object(prerequisites, "get_backend_dir", return_value=tmp_path) + mocker.patch.object( + compiler, "get_config", return_value=rx.Config(app_name="testing", plugins=[]) + ) + calls = [] + restore = mocker.patch.object( + utils, "_restore_bundled_libraries", side_effect=lambda: calls.append("restore") + ) + compile_initial_state = mocker.patch.object( + utils, + "_compile_initial_state", + side_effect=lambda _state: calls.append("initial_state"), + ) + + assert compiler.compile_app(app, use_rich=False) is False + + assert restore.call_args_list == [mocker.call()] + assert compile_initial_state.call_args_list == [mocker.call(BackendState)] + assert calls == ["restore", "initial_state"] + + +@pytest.mark.usefixtures("clean_registration_context") +def test_frontend_compile_emits_bundled_library_registry(mocker: MockerFixture): + """Frontend compilation emits the registry consumed by backend-only workers.""" + app = rx.App() + + def index(): + """Render an empty page. + + Returns: + The empty page. + """ + return rx.el.div() + + app.add_page(index) + emitted_registry = mocker.patch.object( + utils, + "_compile_bundled_libraries", + return_value=(constants.Dirs.BUNDLED_LIBRARIES, "[]"), + ) + + assert compiler.compile_app(app, dry_run=True, use_rich=False) is True + + emitted_registry.assert_called_once_with() + + def test_register_plugin_routes_exposes_app_type_not_mutable_app(): """The hook can validate the app class without bypassing staged writes.""" observed: dict[str, object] = {} diff --git a/tests/units/compiler/test_compiler_utils.py b/tests/units/compiler/test_compiler_utils.py index 8055295b332..879dafb18b3 100644 --- a/tests/units/compiler/test_compiler_utils.py +++ b/tests/units/compiler/test_compiler_utils.py @@ -3,10 +3,12 @@ import asyncio import pytest +from reflex_base.registry import RegistrationContext from reflex_components_core.base.fragment import Fragment from reflex_components_core.base.script import Script from reflex_components_core.el.elements.metadata import Link +from reflex.compiler import utils from reflex.compiler.utils import compile_state, create_document_root from reflex.compiler.utils import write_file as compiler_write_file from reflex.constants.state import FIELD_MARKER @@ -20,6 +22,88 @@ def test_write_file_reexport() -> None: assert compiler_write_file is write_file +def test_bundled_libraries_artifact_round_trip( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Backend-only workers can restore the registry from the frontend build.""" + monkeypatch.setattr(utils, "get_web_dir", lambda: tmp_path) + with RegistrationContext() as context: + context.bundled_libraries.append("@radix-ui/themes") + output_path, output = utils._compile_bundled_libraries() + artifact_path = tmp_path / output_path + artifact_path.parent.mkdir() + artifact_path.write_text(output, encoding="utf-8") + context.bundled_libraries[:] = ["react"] + + utils._restore_bundled_libraries() + + assert context.bundled_libraries == [ + "react", + "@emotion/react", + "$/utils/context", + "$/utils/state", + "@radix-ui/themes", + ] + + +def test_restore_bundled_libraries_preserves_page_registrations( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Restoring frontend metadata retains libraries discovered by page evaluation.""" + monkeypatch.setattr(utils, "get_web_dir", lambda: tmp_path) + artifact_path = tmp_path / utils.constants.Dirs.BUNDLED_LIBRARIES + artifact_path.parent.mkdir() + artifact_path.write_text('["@radix-ui/themes"]', encoding="utf-8") + with RegistrationContext() as context: + context.bundled_libraries.append("page-library") + + utils._restore_bundled_libraries() + + assert "@radix-ui/themes" in context.bundled_libraries + assert "page-library" in context.bundled_libraries + + +def test_restore_bundled_libraries_ignores_invalid_utf8( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Malformed registry artifacts do not interrupt backend-only startup.""" + monkeypatch.setattr(utils, "get_web_dir", lambda: tmp_path) + artifact_path = tmp_path / utils.constants.Dirs.BUNDLED_LIBRARIES + artifact_path.parent.mkdir() + artifact_path.write_bytes(b"\xff") + + utils._restore_bundled_libraries() + + +@pytest.mark.parametrize( + "contents", + ["{", '"@radix-ui/themes"', '["@radix-ui/themes", 1]'], +) +def test_restore_bundled_libraries_ignores_invalid_json( + tmp_path, monkeypatch: pytest.MonkeyPatch, contents: str +) -> None: + """Malformed registry data does not change backend registrations.""" + monkeypatch.setattr(utils, "get_web_dir", lambda: tmp_path) + artifact_path = tmp_path / utils.constants.Dirs.BUNDLED_LIBRARIES + artifact_path.parent.mkdir() + artifact_path.write_text(contents, encoding="utf-8") + with RegistrationContext() as context: + original = list(context.bundled_libraries) + + utils._restore_bundled_libraries() + + assert context.bundled_libraries == original + + +def test_restore_bundled_libraries_ignores_missing_artifact( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A missing frontend artifact does not interrupt backend-only startup.""" + monkeypatch.setattr(utils, "get_web_dir", lambda: tmp_path) + + utils._restore_bundled_libraries() + + def test_document_preloads_the_global_stylesheet(): """Render-blocking CSS should be discoverable alongside early resource hints.""" head = create_document_root().children[0]