diff --git a/packages/reflex-base/src/reflex_base/.templates/web/app/entry.client.embed.js b/packages/reflex-base/src/reflex_base/.templates/web/app/entry.client.embed.js new file mode 100644 index 00000000000..57ef621468e --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/app/entry.client.embed.js @@ -0,0 +1,63 @@ +import { startTransition, createElement } from "react"; +import { createRoot, hydrateRoot } from "react-dom/client"; +import { HydratedRouter } from "react-router/dom"; +import env from "$/env.json"; + +const selector = env.MOUNT_TARGET; + +if (selector) { + const target = document.querySelector(selector); + if (!target) { + console.error( + `[Reflex embed] No element matching MOUNT_TARGET selector ${JSON.stringify(selector)}; widget will not mount.`, + ); + } else { + // @react-router/dev injects a preamble check at the top of every + // transformed JSX module that throws when this flag is unset. Framework- + // mode prerendered HTML installs it via ; embed mode does not, + // so we install it before any user JSX module loads (the imports below + // are dynamic). + window.__vite_plugin_react_preamble_installed__ = true; + window.$RefreshReg$ = () => {}; + window.$RefreshSig$ = () => (type) => type; + + // No __reactRouterContext on the host page; mount through a memory data + // router so the widget owns its URL space (host's window.location is + // unrelated) and react-router hooks like useLoaderData resolve. The route + // table is generated at compile time into __reflex_embed_manifest.js. + Promise.all([ + import("$/styles/__reflex_global_styles.css"), + import("react-router"), + import("$/app/root"), + import("$/app/__reflex_embed_manifest"), + ]) + .then(([, reactRouter, root, manifest]) => { + const { createMemoryRouter, RouterProvider, Outlet } = reactRouter; + const children = manifest.default.map(({ path, load }) => { + const lazy = async () => ({ Component: (await load()).default }); + if (path === "") return { index: true, lazy }; + return { path, lazy }; + }); + const router = createMemoryRouter( + [ + { + Component: () => + createElement(root.EmbedLayout, null, createElement(Outlet)), + children, + }, + ], + { initialEntries: ["/"] }, + ); + startTransition(() => { + createRoot(target).render(createElement(RouterProvider, { router })); + }); + }) + .catch((err) => { + console.error("[Reflex embed] Failed to load:", err); + }); + } +} else { + startTransition(() => { + hydrateRoot(document, createElement(HydratedRouter)); + }); +} diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index d931a19299d..ddaa466aba8 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -42,6 +42,17 @@ export const refs = {}; // Array holding pending events to be processed. const event_queue = []; +// Mirrors the data router's location so applyEvent can populate router_data +// with the in-widget URL. In embed mode the host page's window.location is +// unrelated to the Reflex route, so the backend's on_load and dynamic-route +// matching rely on this ref instead. Updated by useEventLoop once mounted; +// pre-seeded in embed mode with the memory router's initial path (see +// initialEntries in entry.client.embed.js) so events dispatched before the +// first effect commit don't briefly fall back to the host page's URL. +const locationRef = { + current: env.MOUNT_TARGET ? { pathname: "/", search: "", hash: "" } : null, +}; + /** * Generate a UUID (Used for session tokens). * Taken from: https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid @@ -378,16 +389,15 @@ export const applyEvent = async (event, socket, navigate, params) => { event.router_data === undefined || Object.keys(event.router_data).length === 0 ) { - // Since we don't have router directly, we need to get info from our hooks + const loc = locationRef.current ?? window.location; + const search = loc.search ?? ""; + const hash = loc.hash ?? ""; event.router_data = { - pathname: window.location.pathname, - asPath: - window.location.pathname + - window.location.search + - window.location.hash, + pathname: loc.pathname, + asPath: loc.pathname + search + hash, }; const query = { - ...Object.fromEntries(new URLSearchParams(window.location.search)), + ...Object.fromEntries(new URLSearchParams(search)), ...params.current, }; if (query && Object.keys(query).length > 0) { @@ -903,6 +913,10 @@ export const useEventLoop = ( } }, [paramsR]); + useEffect(() => { + locationRef.current = location; + }, [location]); + const ensureSocketConnected = useCallback(async () => { if (!mounted.current) { // During hot reload, some components may still have a reference to diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 3c525e99f4e..08bb95a71d9 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -208,13 +208,7 @@ def app_root_template( {custom_code_str} -function AppWrap({{children}}) {{ -{_render_hooks(hooks)} -return ({_RenderUtils.render(render)}) -}} - - -export function Layout({{children}}) {{ +function ReflexProviders({{children}}) {{ useEffect(() => {{ // Make contexts and state objects available globally for dynamic eval'd components let windowImports = {{ @@ -223,17 +217,32 @@ def app_root_template( window["__reflex"] = windowImports; }}, []); - return jsx(AppLayout, {{}}, - jsx(ThemeProvider, {{defaultTheme: defaultColorMode, attribute: "class"}}, - jsx(StateProvider, {{}}, - jsx(EventLoopProvider, {{}}, - jsx(AppWrap, {{}}, children) - ) + return jsx(ThemeProvider, {{defaultTheme: defaultColorMode, attribute: "class"}}, + jsx(StateProvider, {{}}, + jsx(EventLoopProvider, {{}}, + jsx(AppWrap, {{}}, children) ) ) ); }} + +function AppWrap({{children}}) {{ +{_render_hooks(hooks)} +return ({_RenderUtils.render(render)}) +}} + +export function Layout({{children}}) {{ + return jsx(AppLayout, {{}}, jsx(ReflexProviders, {{}}, children)); +}} + +// Used by entry.client.js when mount_target is configured: skips the document +// shell (which renders react-router's // and requires a +// framework router context) but keeps the runtime providers. +export function EmbedLayout({{children}}) {{ + return jsx(ReflexProviders, {{}}, children); +}} + export default function App() {{ return jsx(Outlet, {{}}); }} diff --git a/packages/reflex-base/src/reflex_base/constants/__init__.py b/packages/reflex-base/src/reflex_base/constants/__init__.py index e790572a13a..b308500593a 100644 --- a/packages/reflex-base/src/reflex_base/constants/__init__.py +++ b/packages/reflex-base/src/reflex_base/constants/__init__.py @@ -29,6 +29,7 @@ CompileContext, CompileVars, ComponentName, + Embed, Ext, Hooks, Imports, @@ -90,6 +91,7 @@ "DefaultPage", "DefaultPorts", "Dirs", + "Embed", "Endpoint", "Env", "EventTriggers", diff --git a/packages/reflex-base/src/reflex_base/constants/compiler.py b/packages/reflex-base/src/reflex_base/constants/compiler.py index 8113f522484..a5dd51b3925 100644 --- a/packages/reflex-base/src/reflex_base/constants/compiler.py +++ b/packages/reflex-base/src/reflex_base/constants/compiler.py @@ -100,6 +100,27 @@ class PageNames(SimpleNamespace): STATEFUL_COMPONENTS = "stateful_components" +class Embed(SimpleNamespace): + """Public artifacts for ``mount_target`` (embed) builds. + + These paths form the host-page contract: a host script tag points at + ``ENTRY_PATH`` (relative to the frontend origin), which loads the route + manifest at ``MANIFEST_FILE`` and dispatches into the embedded app. They + are intentionally stable across dev and prod so the same host HTML works + in both modes. + """ + + # Host pages reference this path (e.g. ``\n' + "\n" + "\n" + ) + + +_VITE_DEV_PREVIEW_PLUGIN_DEF = """ +function reflexEmbedDevPreview(html) { + return { + name: "reflex-embed-dev-preview", + enforce: "pre", + apply: "serve", + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = (req.url || "").split("?")[0]; + if (url === "/" || url === "/index.html") { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(html); + return; + } + next(); + }); + } + }; +} +""" + + +def _inject_vite_dev_preview(mount_target: str): + """Build a modify task that registers the dev-preview Vite middleware. + + Args: + mount_target: The selector used to synthesize the host wrapper element. + + Returns: + A function suitable for ``add_modify_task`` that injects the plugin + definition into ``vite.config.js`` and prepends it to the plugins array. + """ + html_literal = json.dumps(_render_dev_host_html(mount_target)) + define_anchor = "export default defineConfig" + plugins_anchor = "alwaysUseReactDomServerNode()," + + def modify(content: str) -> str: + if "reflexEmbedDevPreview" in content: + return content + if define_anchor not in content or plugins_anchor not in content: + msg = ( + "EmbedPlugin dev_preview cannot patch vite.config.js: expected " + f"anchors {define_anchor!r} and {plugins_anchor!r} were not " + "found. The Vite config template may have changed upstream." + ) + raise RuntimeError(msg) + return content.replace( + define_anchor, + _VITE_DEV_PREVIEW_PLUGIN_DEF + "\nexport default defineConfig", + 1, + ).replace( + plugins_anchor, + f"reflexEmbedDevPreview({html_literal}),\n {plugins_anchor}", + 1, + ) + + return modify + + +@dataclass +class EmbedPlugin(PluginBase): + """Compile the app to mount into a host-page element instead of the document. + + When ``mount_target`` is omitted, the value is read from the + ``REFLEX_MOUNT_TARGET`` environment variable so the plugin remains usable + when registered through ``REFLEX_PLUGINS`` (which instantiates plugins + with no constructor args). Same fallback for ``embed_origin`` / + ``REFLEX_EMBED_ORIGIN``. + + When ``dev_preview`` is ``True``, a dev-only Vite middleware serves a + minimal host wrapper at ``http://localhost:3000/`` so the embedded app + can be previewed without a separately served host page. Off by default so + the bundle root stays predictable for production embeds. + """ + + mount_target: str | None = field( + default_factory=lambda: os.getenv("REFLEX_MOUNT_TARGET") + ) + embed_origin: str | None = field( + default_factory=lambda: os.getenv("REFLEX_EMBED_ORIGIN") + ) + dev_preview: bool = False + + def __post_init__(self): + """Validate that a mount target is configured. + + Raises: + ValueError: If neither the constructor arg nor + ``REFLEX_MOUNT_TARGET`` provides a value. + """ + if not self.mount_target: + msg = ( + "EmbedPlugin requires a mount_target (constructor arg or " + "REFLEX_MOUNT_TARGET environment variable)." + ) + raise ValueError(msg) + + def pre_compile(self, **context): + """Register save tasks for the embed entry and the route manifest. + + Args: + context: The pre-compile plugin context. + """ + context["add_save_task"](_embed_entry_task) + context["add_save_task"](_embed_manifest_task, context["unevaluated_pages"]) + if self.dev_preview: + assert self.mount_target is not None + context["add_modify_task"]( + constants.ReactRouter.VITE_CONFIG_FILE, + _inject_vite_dev_preview(self.mount_target), + ) + + +def get_embed_plugin() -> EmbedPlugin | None: + """Return the EmbedPlugin instance from the active config, if any. + + Returns: + The first ``EmbedPlugin`` registered in ``config.plugins``, or ``None``. + """ + from reflex_base.config import get_config + + return next( + (p for p in get_config().plugins if isinstance(p, EmbedPlugin)), + None, + ) + + +Plugin = EmbedPlugin diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 39ff4931a9e..8ea4d6797f9 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -593,6 +593,68 @@ def compile_app_root(app_root: Component) -> tuple[str, str]: return output_path, code +def _embed_manifest_path_for(route: str) -> str: + """Translate a Reflex route into a React Router 7 path string. + + Args: + route: The registered Reflex route (e.g. ``"index"``, ``"users/[id]"``). + + Returns: + The React Router 7 path: ``""`` for the index (consumer treats empty + as the index route), ``"*"`` for the 404 slug or splat-catchall, + ``":name"`` for ``[name]``, ``":name?"`` for ``[[name]]``; static + parts pass through. + """ + if route == constants.Page404.SLUG: + return "*" + if route == "index": + return "" + parts: list[str] = [] + for part in route.split("/"): + if part == constants.RouteRegex.SPLAT_CATCHALL: + parts.append("*") + continue + optional = constants.RouteRegex.OPTIONAL_ARG.fullmatch(part) + if optional: + parts.append(":" + optional.group(1) + "?") + continue + arg = constants.RouteRegex.ARG.fullmatch(part) + if arg: + parts.append(":" + arg.group(1)) + continue + parts.append(part) + return "/".join(parts) + + +def _embed_manifest_entry(route: str) -> str: + path = _embed_manifest_path_for(route) + specifier = utils.get_page_import_specifier(route) + return ( + f"{{ path: {json.dumps(path)}, load: () => import({json.dumps(specifier)}) }}" + ) + + +def compile_embed_manifest(routes: Iterable[str]) -> tuple[str, str]: + """Emit the route manifest consumed by entry.client.js in embed mode. + + Args: + routes: Registered Reflex routes (e.g. ``"index"``, ``"about"``, + ``"users/[id]"``, ``"404"``). + + Returns: + The path and code of the manifest module. + """ + output_path = str( + get_web_dir() / constants.Dirs.PAGES / constants.Embed.MANIFEST_FILE + ) + entries = [_embed_manifest_entry(route) for route in routes] + code = ( + "// Generated by reflex; do not edit.\n" + "export default [\n" + "".join(f" {entry},\n" for entry in entries) + "];\n" + ) + return output_path, code + + def compile_theme(style: ComponentStyle) -> tuple[str, str]: """Compile the theme. @@ -1241,6 +1303,8 @@ def add_save_task( if page_file.is_file() and page_file not in keep_files: page_file.unlink() + frontend_skeleton.update_entry_client() + output_mapping: dict[Path, str] = {} for output_path, code in compile_results: path = utils.resolve_path_of_web_dir(output_path) diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index c2bdf618650..7f8fe249bf3 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -738,6 +738,20 @@ def get_page_path(path: str) -> str: ) +def get_page_import_specifier(path: str) -> str: + """Get the ``$``-aliased module specifier for the given page. + + Args: + path: The path of the page. + + Returns: + The import specifier (e.g. ``"$/pages/routes/users.$id._index"``). + """ + return ( + f"$/{constants.Dirs.PAGES}/{constants.Dirs.ROUTES}/{_path_to_file_stem(path)}" + ) + + def get_theme_path() -> str: """Get the path of the base theme style. diff --git a/reflex/plugins/__init__.py b/reflex/plugins/__init__.py index 9bd4335ab02..e28a0c3cdde 100644 --- a/reflex/plugins/__init__.py +++ b/reflex/plugins/__init__.py @@ -6,6 +6,7 @@ CompileContext, CompilerHooks, ComponentAndChildren, + EmbedPlugin, PageContext, Plugin, PreCompileContext, @@ -13,6 +14,7 @@ TailwindV3Plugin, TailwindV4Plugin, _ScreenshotPlugin, + embed, sitemap, tailwind_v3, tailwind_v4, @@ -25,6 +27,7 @@ "CompileContext", "CompilerHooks", "ComponentAndChildren", + "EmbedPlugin", "PageContext", "Plugin", "PreCompileContext", @@ -33,6 +36,7 @@ "TailwindV3Plugin", "TailwindV4Plugin", "_ScreenshotPlugin", + "embed", "sitemap", "tailwind_v3", "tailwind_v4", diff --git a/reflex/utils/build.py b/reflex/utils/build.py index c25bb5660c1..931330cb081 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -2,12 +2,14 @@ from __future__ import annotations +import json import os import zipfile from pathlib import Path, PosixPath from reflex_base import constants from reflex_base.config import get_config +from reflex_base.plugins.embed import get_embed_plugin from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn from reflex.utils import console, js_runtimes, path_ops, prerequisites, processes @@ -16,12 +18,15 @@ def set_env_json(): """Write the upload url to a REFLEX_JSON.""" + config = get_config() + embed_plugin = get_embed_plugin() path_ops.update_json_file( str(prerequisites.get_web_dir() / constants.Dirs.ENV_JSON), { **{endpoint.name: endpoint.get_url() for endpoint in constants.Endpoint}, - "TRANSPORT": get_config().transport, + "TRANSPORT": config.transport, "TEST_MODE": is_in_app_harness(), + "MOUNT_TARGET": embed_plugin.mount_target if embed_plugin else None, }, ) @@ -187,6 +192,41 @@ def _duplicate_index_html_to_parent_directory(directory: Path): _duplicate_index_html_to_parent_directory(child) +def _emit_stable_entry_bootloader(static_dir: Path) -> None: + """In embed mode, emit a stable ``Embed.ENTRY_PATH`` shim for host pages. + + The Vite build emits the entry chunk with a content hash + (``assets/entry.client-.js``), so a host page can't reference a + stable URL directly. When ``EmbedPlugin`` is registered, write a one-line + re-export shim at ``Embed.ENTRY_PATH`` so the same `` + + +""" + (static_dir / name).write_text(host_html) + + +def test_app_mounts_into_host_container(mount_target_app: AppHarnessProd, page: Page): + """A host HTML page with #reflex-root receives the mounted app.""" + static = _static_dir(mount_target_app) + _write_host(static) + + base = mount_target_app.frontend_url + assert base is not None + page.goto(f"{base.rstrip('/')}/host.html") + + expect(page.locator("#reflex-root #count")).to_contain_text("count:") + expect(page.locator('[data-host-marker="yes"]')).to_have_count(2) + + +def test_in_widget_navigation_keeps_host_url( + mount_target_app: AppHarnessProd, page: Page +): + """Clicking a Reflex link routes inside the widget; host URL stays put.""" + static = _static_dir(mount_target_app) + _write_host(static) + + base = mount_target_app.frontend_url + assert base is not None + host_url = f"{base.rstrip('/')}/host.html" + page.goto(host_url) + + expect(page.locator("#reflex-root #index-marker")).to_be_visible() + + page.locator("#reflex-root #link-about").click() + + expect(page.locator("#reflex-root #about-marker")).to_be_visible() + # Host URL must not have changed despite the in-widget navigation. + assert page.url == host_url + # The about page's on_load handler writes the path it observed; the + # backend's route matcher only resolves the on_load if router_data.pathname + # is the in-widget URL "/about" (not the host's "/host.html"). + expect(page.locator("#reflex-root #loaded-path")).to_have_text("loaded: /about") + + +def test_on_load_fires_for_embedded_route(mount_target_app: AppHarnessProd, page: Page): + """An on_load handler tied to a non-index route fires after navigation.""" + static = _static_dir(mount_target_app) + _write_host(static) + + base = mount_target_app.frontend_url + assert base is not None + page.goto(f"{base.rstrip('/')}/host.html") + + expect(page.locator("#reflex-root #count")).to_have_text("count: 0") + page.locator("#reflex-root #link-counter").click() + expect(page.locator("#reflex-root #counter-marker")).to_be_visible() + expect(page.locator("#reflex-root #count")).to_have_text("count: 1") + expect(page.locator("#reflex-root #loaded-path")).to_have_text("loaded: /counter") diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index e2f1b769668..dc25f153e30 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -514,3 +514,48 @@ def test_create_document_root_with_meta_viewport(): assert str(root.children[0].children[2].name) == '"viewport"' # pyright: ignore [reportAttributeAccessIssue] assert str(root.children[0].children[2].content) == '"foo"' # pyright: ignore [reportAttributeAccessIssue] assert str(root.children[0].children[3].char_set) == '"utf-8"' # pyright: ignore [reportAttributeAccessIssue] + + +@pytest.mark.parametrize( + ("route", "expected_path"), + [ + ("index", ""), + ("about", "about"), + ("users/[id]", "users/:id"), + ("posts/[[slug]]", "posts/:slug?"), + ("docs/[[...splat]]", "docs/*"), + (constants.Page404.SLUG, "*"), + ], +) +def test_embed_manifest_path_for(route: str, expected_path: str): + """Reflex routes translate to React Router 7 path strings.""" + assert compiler._embed_manifest_path_for(route) == expected_path + + +def test_compile_embed_manifest(mocker: MockerFixture, tmp_path: Path): + """``compile_embed_manifest`` writes one entry per route with matching stems.""" + mocker.patch("reflex.compiler.compiler.get_web_dir", return_value=tmp_path) + + routes = [ + "index", + "about", + "users/[id]", + "posts/[[slug]]", + "docs/[[...splat]]", + constants.Page404.SLUG, + ] + output_path, code = compiler.compile_embed_manifest(routes) + + assert output_path == str( + tmp_path / constants.Dirs.PAGES / "__reflex_embed_manifest.js" + ) + assert code.startswith("// Generated by reflex") + assert "export default [" in code + + expected_paths = ["", "about", "users/:id", "posts/:slug?", "docs/*", "*"] + for path in expected_paths: + assert f'path: "{path}"' in code + + for route in routes: + specifier = utils.get_page_import_specifier(route) + assert f'load: () => import("{specifier}")' in code diff --git a/tests/units/plugins/test_embed.py b/tests/units/plugins/test_embed.py new file mode 100644 index 00000000000..5d9b7a30a0b --- /dev/null +++ b/tests/units/plugins/test_embed.py @@ -0,0 +1,163 @@ +"""Unit tests for the embed plugin.""" + +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture +from reflex_base.plugins.embed import ( + EmbedPlugin, + _inject_vite_dev_preview, + _mount_attrs_for_selector, + _render_dev_host_html, + get_embed_plugin, +) + + +def test_explicit_args_set_fields(): + plugin = EmbedPlugin(mount_target="#root", embed_origin="https://cdn.example") + assert plugin.mount_target == "#root" + assert plugin.embed_origin == "https://cdn.example" + + +def test_env_fallback_populates_fields(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("REFLEX_MOUNT_TARGET", "#widget") + monkeypatch.setenv("REFLEX_EMBED_ORIGIN", "https://cdn.example") + plugin = EmbedPlugin() + assert plugin.mount_target == "#widget" + assert plugin.embed_origin == "https://cdn.example" + + +def test_explicit_args_override_env(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("REFLEX_MOUNT_TARGET", "#env-target") + plugin = EmbedPlugin(mount_target="#explicit") + assert plugin.mount_target == "#explicit" + + +def test_missing_mount_target_raises(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("REFLEX_MOUNT_TARGET", raising=False) + monkeypatch.delenv("REFLEX_EMBED_ORIGIN", raising=False) + with pytest.raises(ValueError, match="mount_target"): + EmbedPlugin() + + +def test_pre_compile_registers_save_tasks(): + plugin = EmbedPlugin(mount_target="#root") + saved: list[tuple] = [] + + def add_save_task(task, *args, **kwargs): + saved.append((task, args, kwargs)) + + plugin.pre_compile( + add_save_task=add_save_task, + add_modify_task=lambda *_args, **_kwargs: None, + radix_themes_plugin=None, + unevaluated_pages=[], + ) + + assert len(saved) == 2 + task_names = {task.__name__ for task, _, _ in saved} + assert task_names == {"_embed_entry_task", "_embed_manifest_task"} + + +def test_get_embed_plugin_returns_instance(mocker: MockerFixture): + plugin = EmbedPlugin(mount_target="#root") + config = mocker.Mock() + config.plugins = [plugin] + mocker.patch("reflex_base.config.get_config", return_value=config) + assert get_embed_plugin() is plugin + + +def test_get_embed_plugin_returns_none_when_absent(mocker: MockerFixture): + config = mocker.Mock() + config.plugins = [] + mocker.patch("reflex_base.config.get_config", return_value=config) + assert get_embed_plugin() is None + + +@pytest.mark.parametrize( + ("selector", "expected_attrs"), + [ + ("#reflex-root", {"id": "reflex-root"}), + (".widget", {"class": "widget"}), + ("[data-mount]", {"data-mount": ""}), + ('[data-mount="x"]', {"data-mount": "x"}), + ("#reflex-root.widget", {"id": "reflex-root", "class": "widget"}), + ], +) +def test_mount_attrs_for_selector_parses_simple_selectors( + selector: str, expected_attrs: dict[str, str] +): + attrs, ok = _mount_attrs_for_selector(selector) + assert ok is True + assert attrs == expected_attrs + + +def test_mount_attrs_for_selector_falls_back_for_complex(): + attrs, ok = _mount_attrs_for_selector("div > .child") + assert ok is False + assert attrs == {"id": "reflex-dev-root"} + + +def test_render_dev_host_html_is_minimal(): + html = _render_dev_host_html("#reflex-root") + assert 'id="reflex-root"' in html + assert 'src="/app/entry.client.js"' in html + assert " None: + return None + + +def test_dev_preview_off_by_default_skips_modify_task(): + plugin = EmbedPlugin(mount_target="#root") + modified: list = [] + plugin.pre_compile( + add_save_task=_noop_save_task, + add_modify_task=lambda path, fn: modified.append((path, fn)), + radix_themes_plugin=None, + unevaluated_pages=[], + ) + assert modified == [] + + +def test_dev_preview_enabled_registers_modify_task(): + plugin = EmbedPlugin(mount_target="#root", dev_preview=True) + modified: list = [] + plugin.pre_compile( + add_save_task=_noop_save_task, + add_modify_task=lambda path, fn: modified.append((path, fn)), + radix_themes_plugin=None, + unevaluated_pages=[], + ) + assert any(path == "vite.config.js" for path, _ in modified) + + +def test_inject_vite_dev_preview_adds_plugin_to_array(): + original = ( + 'import { reactRouter } from "@react-router/dev/vite";\n' + "function alwaysUseReactDomServerNode() { return {}; }\n" + "export default defineConfig((config) => ({\n" + ' base: "/",\n' + " plugins: [\n" + " alwaysUseReactDomServerNode(),\n" + " reactRouter(),\n" + " ],\n" + "}));\n" + ) + modify = _inject_vite_dev_preview("#reflex-root") + out = modify(original) + assert "function reflexEmbedDevPreview" in out + assert out.index("reflexEmbedDevPreview(") < out.index( + "alwaysUseReactDomServerNode()," + ) + + +def test_inject_vite_dev_preview_is_idempotent(): + original = ( + "function reflexEmbedDevPreview() {}\n" + "export default defineConfig((config) => ({}));" + ) + modify = _inject_vite_dev_preview("#reflex-root") + assert modify(original) == original diff --git a/tests/units/utils/test_build.py b/tests/units/utils/test_build.py new file mode 100644 index 00000000000..ba4ade7187c --- /dev/null +++ b/tests/units/utils/test_build.py @@ -0,0 +1,50 @@ +"""Tests for reflex.utils.build.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from pytest_mock import MockerFixture + +from reflex.plugins import EmbedPlugin +from reflex.utils import build + + +def _patch_env_json( + mocker: MockerFixture, tmp_path: Path, embed_plugin: EmbedPlugin | None = None +): + web_dir = tmp_path / ".web" + web_dir.mkdir() + mocker.patch("reflex.utils.build.prerequisites.get_web_dir", return_value=web_dir) + config = mocker.Mock() + config.transport = "websocket" + mocker.patch("reflex.utils.build.get_config", return_value=config) + mocker.patch("reflex.utils.build.get_embed_plugin", return_value=embed_plugin) + mocker.patch("reflex.utils.build.is_in_app_harness", return_value=False) + return web_dir + + +def test_set_env_json_includes_mount_target(tmp_path: Path, mocker: MockerFixture): + """MOUNT_TARGET appears in env.json when EmbedPlugin is registered.""" + web_dir = _patch_env_json( + mocker, tmp_path, embed_plugin=EmbedPlugin(mount_target="#reflex-root") + ) + + build.set_env_json() + + env = json.loads((web_dir / "env.json").read_text()) + assert env["MOUNT_TARGET"] == "#reflex-root" + assert env["TRANSPORT"] == "websocket" + + +def test_set_env_json_mount_target_null_when_unset( + tmp_path: Path, mocker: MockerFixture +): + """MOUNT_TARGET is null in env.json when EmbedPlugin is not registered.""" + web_dir = _patch_env_json(mocker, tmp_path, embed_plugin=None) + + build.set_env_json() + + env = json.loads((web_dir / "env.json").read_text()) + assert env["MOUNT_TARGET"] is None