From b0933be1caf3ff5f4fd8c4c1695f0cfdc107cebe Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 6 May 2026 02:57:02 +0500 Subject: [PATCH 1/6] feat: support embedding Reflex apps into host pages via mount_target Add and config options for embedding a Reflex app into an arbitrary DOM node on a host page. When is set, the entry client mounts a memory data router into the target element via instead of hydrating the document, the build emits a stable shim re-exporting the hashed Vite chunk, and a route manifest is generated at compile time so the embedded app owns its own URL space. now sources from the data router's location so the backend sees the in-widget URL rather than the host page's. --- .../.templates/web/app/entry.client.js | 56 +++++- .../reflex_base/.templates/web/utils/state.js | 23 ++- .../src/reflex_base/compiler/templates.py | 23 ++- .../reflex-base/src/reflex_base/config.py | 6 + .../src/reflex_base/constants/__init__.py | 2 + .../src/reflex_base/constants/compiler.py | 18 ++ reflex/compiler/compiler.py | 69 ++++++++ reflex/compiler/utils.py | 14 ++ reflex/utils/build.py | 40 ++++- reflex/utils/frontend_skeleton.py | 43 +++-- reflex/utils/path_ops.py | 2 +- .../tests_playwright/test_mount_target.py | 166 ++++++++++++++++++ tests/units/compiler/test_compiler.py | 45 +++++ tests/units/utils/test_build.py | 45 +++++ 14 files changed, 514 insertions(+), 38 deletions(-) create mode 100644 tests/integration/tests_playwright/test_mount_target.py create mode 100644 tests/units/utils/test_build.py diff --git a/packages/reflex-base/src/reflex_base/.templates/web/app/entry.client.js b/packages/reflex-base/src/reflex_base/.templates/web/app/entry.client.js index 9545bc28309..a895323e99e 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/app/entry.client.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/app/entry.client.js @@ -1,8 +1,52 @@ -import { startTransition } from "react"; -import { hydrateRoot } from "react-dom/client"; +import { startTransition, createElement } from "react"; +import { createRoot, hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; -import { createElement } from "react"; +import env from "$/env.json"; -startTransition(() => { - hydrateRoot(document, createElement(HydratedRouter)); -}); +const selector = env.MOUNT_TARGET; +const target = selector ? document.querySelector(selector) : null; + +if (target) { + // @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 })); + }); + }); +} 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..2c5bf161a56 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,12 @@ 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. Set by useEventLoop once mounted. +const locationRef = { current: null }; + /** * Generate a UUID (Used for session tokens). * Taken from: https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid @@ -378,16 +384,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 +908,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..33aebabe9ba 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -214,7 +214,7 @@ def app_root_template( }} -export function Layout({{children}}) {{ +function ReflexProviders({{children}}) {{ useEffect(() => {{ // Make contexts and state objects available globally for dynamic eval'd components let windowImports = {{ @@ -223,17 +223,26 @@ 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) ) ) ); }} +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/config.py b/packages/reflex-base/src/reflex_base/config.py index 9a522628e6a..30d15da86e2 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -181,6 +181,8 @@ class BaseConfig: 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. + mount_target: CSS selector to mount the app into a host-page element instead of taking over the document. + embed_origin: Absolute origin URL where the embed bundle is served. Set when the host page is on a different origin than the bundle. """ app_name: str @@ -193,6 +195,10 @@ class BaseConfig: frontend_path: str = "" + mount_target: str | None = None + + embed_origin: str | None = None + backend_port: int | None = None backend_path: str = "" 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..4348e13c58e 100644 --- a/packages/reflex-base/src/reflex_base/constants/compiler.py +++ b/packages/reflex-base/src/reflex_base/constants/compiler.py @@ -100,6 +100,24 @@ 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. `` + + +""" + (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/utils/test_build.py b/tests/units/utils/test_build.py new file mode 100644 index 00000000000..0cd5c6b6d60 --- /dev/null +++ b/tests/units/utils/test_build.py @@ -0,0 +1,45 @@ +"""Tests for reflex.utils.build.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from pytest_mock import MockerFixture + +from reflex.utils import build + + +def _patch_env_json(mocker: MockerFixture, tmp_path: Path, mount_target: str | 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" + config.mount_target = mount_target + mocker.patch("reflex.utils.build.get_config", return_value=config) + 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 configured.""" + web_dir = _patch_env_json(mocker, tmp_path, "#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 not configured.""" + web_dir = _patch_env_json(mocker, tmp_path, None) + + build.set_env_json() + + env = json.loads((web_dir / "env.json").read_text()) + assert env["MOUNT_TARGET"] is None From f75fa355bb54f71810bfb82357d8fc7d49a2b747 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 6 May 2026 03:07:30 +0500 Subject: [PATCH 2/6] refactor: keep entry.client.js byte-identical when mount_target is unset Split the embed entry into a separate template and only swap it in when mount_target is set, so non-embed builds produce the exact same entry.client.js as before this feature landed. --- .../.templates/web/app/entry.client.embed.js | 52 +++++++++++++++++ .../.templates/web/app/entry.client.js | 56 ++----------------- .../src/reflex_base/constants/compiler.py | 3 + reflex/utils/frontend_skeleton.py | 17 ++++-- 4 files changed, 72 insertions(+), 56 deletions(-) create mode 100644 packages/reflex-base/src/reflex_base/.templates/web/app/entry.client.embed.js 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..a895323e99e --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/app/entry.client.embed.js @@ -0,0 +1,52 @@ +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; +const target = selector ? document.querySelector(selector) : null; + +if (target) { + // @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 })); + }); + }); +} else { + startTransition(() => { + hydrateRoot(document, createElement(HydratedRouter)); + }); +} diff --git a/packages/reflex-base/src/reflex_base/.templates/web/app/entry.client.js b/packages/reflex-base/src/reflex_base/.templates/web/app/entry.client.js index a895323e99e..9545bc28309 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/app/entry.client.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/app/entry.client.js @@ -1,52 +1,8 @@ -import { startTransition, createElement } from "react"; -import { createRoot, hydrateRoot } from "react-dom/client"; +import { startTransition } from "react"; +import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; -import env from "$/env.json"; +import { createElement } from "react"; -const selector = env.MOUNT_TARGET; -const target = selector ? document.querySelector(selector) : null; - -if (target) { - // @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 })); - }); - }); -} else { - startTransition(() => { - hydrateRoot(document, createElement(HydratedRouter)); - }); -} +startTransition(() => { + hydrateRoot(document, createElement(HydratedRouter)); +}); diff --git a/packages/reflex-base/src/reflex_base/constants/compiler.py b/packages/reflex-base/src/reflex_base/constants/compiler.py index 4348e13c58e..a5dd51b3925 100644 --- a/packages/reflex-base/src/reflex_base/constants/compiler.py +++ b/packages/reflex-base/src/reflex_base/constants/compiler.py @@ -114,6 +114,9 @@ class Embed(SimpleNamespace): # In dev, Vite serves the source file at this URL; in prod, a shim emitted # by ``_emit_stable_entry_bootloader`` re-exports the hashed Vite entry. ENTRY_PATH = "app/entry.client.js" + # Embed-aware variant of the entry, swapped in only when ``mount_target`` + # is set so non-embed builds remain byte-identical to the framework default. + ENTRY_EMBED_TEMPLATE = "app/entry.client.embed.js" # Compile-time-emitted route manifest the embed entry imports at runtime. MANIFEST_FILE = "__reflex_embed_manifest.js" diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index dd477951b7a..6ffc362d6c1 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -184,17 +184,22 @@ def initialize_web_directory(): @functools.cache -def _entry_client_template() -> str: - return ( - constants.Templates.Dirs.WEB_TEMPLATE / constants.Embed.ENTRY_PATH - ).read_text() +def _entry_client_template(embed: bool) -> str: + name = constants.Embed.ENTRY_EMBED_TEMPLATE if embed else constants.Embed.ENTRY_PATH + return (constants.Templates.Dirs.WEB_TEMPLATE / name).read_text() def update_entry_client(): - """Refresh .web/app/entry.client.js from the bundled template on each compile.""" + """Refresh ``.web/app/entry.client.js`` from the bundled template on each compile. + + When ``mount_target`` is unset, the original framework-mode entry is + written so the produced bundle is byte-identical to the non-embed default; + when set, the embed-aware variant is swapped in. Toggling the config + between compiles flips back to the right file without a re-init. + """ write_file( get_web_dir() / constants.Embed.ENTRY_PATH, - _entry_client_template(), + _entry_client_template(embed=bool(get_config().mount_target)), ) From a49993f08391c6bf6b5820ae117a056a326e6c90 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 6 May 2026 03:20:05 +0500 Subject: [PATCH 3/6] test: satisfying tests by moving text --- .../src/reflex_base/compiler/templates.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 33aebabe9ba..08bb95a71d9 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -208,12 +208,6 @@ def app_root_template( {custom_code_str} -function AppWrap({{children}}) {{ -{_render_hooks(hooks)} -return ({_RenderUtils.render(render)}) -}} - - function ReflexProviders({{children}}) {{ useEffect(() => {{ // Make contexts and state objects available globally for dynamic eval'd components @@ -232,6 +226,12 @@ def app_root_template( ); }} + +function AppWrap({{children}}) {{ +{_render_hooks(hooks)} +return ({_RenderUtils.render(render)}) +}} + export function Layout({{children}}) {{ return jsx(AppLayout, {{}}, jsx(ReflexProviders, {{}}, children)); }} From 0ffc153c18b79dfbf28da29488da05ae3131a152 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 6 May 2026 03:26:53 +0500 Subject: [PATCH 4/6] grepfix: surface embed mount/load failures and seed locationRef before mount Log a clear error when MOUNT_TARGET points at a missing element or the dynamic embed imports reject, instead of silently no-op'ing. Pre-seed locationRef to / in embed mode so events dispatched before the first effect commit don't briefly fall back to the host page's URL. Drop the template read cache so toggling mount_target between compiles picks up the right entry without a re-init. --- .../.templates/web/app/entry.client.embed.js | 89 +++++++++++-------- .../reflex_base/.templates/web/utils/state.js | 9 +- reflex/utils/frontend_skeleton.py | 2 - 3 files changed, 57 insertions(+), 43 deletions(-) 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 index a895323e99e..57ef621468e 100644 --- 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 @@ -4,47 +4,58 @@ import { HydratedRouter } from "react-router/dom"; import env from "$/env.json"; const selector = env.MOUNT_TARGET; -const target = selector ? document.querySelector(selector) : null; -if (target) { - // @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: ["/"] }, +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.`, ); - startTransition(() => { - createRoot(target).render(createElement(RouterProvider, { router })); - }); - }); + } 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 2c5bf161a56..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 @@ -45,8 +45,13 @@ 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. Set by useEventLoop once mounted. -const locationRef = { current: null }; +// 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). diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index 6ffc362d6c1..c3e938562e4 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -1,6 +1,5 @@ """This module provides utility functions to initialize the frontend skeleton.""" -import functools import json import random from pathlib import Path @@ -183,7 +182,6 @@ def initialize_web_directory(): init_reflex_json(project_hash=project_hash) -@functools.cache def _entry_client_template(embed: bool) -> str: name = constants.Embed.ENTRY_EMBED_TEMPLATE if embed else constants.Embed.ENTRY_PATH return (constants.Templates.Dirs.WEB_TEMPLATE / name).read_text() From 76c8ea6632db5c6277b3e4149cc9405ffe1dc847 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 6 May 2026 14:08:56 +0500 Subject: [PATCH 5/6] refactor: move embed config from BaseConfig into EmbedPlugin Embedding is now opt-in via `rx.plugins.EmbedPlugin(mount_target=...)` in `rx.Config.plugins` rather than top-level `mount_target` / `embed_origin` fields on `BaseConfig`. The plugin owns the embed entry and route-manifest save tasks (no more conditional dispatch in `compile_app`) and gains an optional `dev_preview` mode that injects a Vite middleware serving a minimal host wrapper at `/`, so the embed can be exercised without a separately served host page. `mount_target` / `embed_origin` still fall back to `REFLEX_MOUNT_TARGET` / `REFLEX_EMBED_ORIGIN` so `REFLEX_PLUGINS=...EmbedPlugin` works without constructor args. Compiler and build code now query the active plugin via `get_embed_plugin()` instead of reading config attributes. --- .../reflex-base/src/reflex_base/config.py | 6 - .../src/reflex_base/plugins/__init__.py | 5 +- .../src/reflex_base/plugins/embed.py | 247 ++++++++++++++++++ reflex/compiler/compiler.py | 5 - reflex/plugins/__init__.py | 4 + reflex/utils/build.py | 11 +- reflex/utils/frontend_skeleton.py | 31 ++- .../tests_playwright/test_mount_target.py | 17 +- tests/units/plugins/test_embed.py | 163 ++++++++++++ tests/units/utils/test_build.py | 17 +- 10 files changed, 460 insertions(+), 46 deletions(-) create mode 100644 packages/reflex-base/src/reflex_base/plugins/embed.py create mode 100644 tests/units/plugins/test_embed.py diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 30d15da86e2..9a522628e6a 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -181,8 +181,6 @@ class BaseConfig: 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. - mount_target: CSS selector to mount the app into a host-page element instead of taking over the document. - embed_origin: Absolute origin URL where the embed bundle is served. Set when the host page is on a different origin than the bundle. """ app_name: str @@ -195,10 +193,6 @@ class BaseConfig: frontend_path: str = "" - mount_target: str | None = None - - embed_origin: str | None = None - backend_port: int | None = None backend_path: str = "" diff --git a/packages/reflex-base/src/reflex_base/plugins/__init__.py b/packages/reflex-base/src/reflex_base/plugins/__init__.py index f3ef5aa971c..bec44565662 100644 --- a/packages/reflex-base/src/reflex_base/plugins/__init__.py +++ b/packages/reflex-base/src/reflex_base/plugins/__init__.py @@ -1,6 +1,6 @@ """Reflex Plugin System.""" -from . import sitemap, tailwind_v3, tailwind_v4 +from . import embed, sitemap, tailwind_v3, tailwind_v4 from ._screenshot import ScreenshotPlugin as _ScreenshotPlugin from .base import CommonContext, Plugin, PreCompileContext from .compiler import ( @@ -11,6 +11,7 @@ PageContext, PageDefinition, ) +from .embed import EmbedPlugin from .sitemap import SitemapPlugin from .tailwind_v3 import TailwindV3Plugin from .tailwind_v4 import TailwindV4Plugin @@ -21,6 +22,7 @@ "CompileContext", "CompilerHooks", "ComponentAndChildren", + "EmbedPlugin", "PageContext", "PageDefinition", "Plugin", @@ -29,6 +31,7 @@ "TailwindV3Plugin", "TailwindV4Plugin", "_ScreenshotPlugin", + "embed", "sitemap", "tailwind_v3", "tailwind_v4", diff --git a/packages/reflex-base/src/reflex_base/plugins/embed.py b/packages/reflex-base/src/reflex_base/plugins/embed.py new file mode 100644 index 00000000000..867cc230c7e --- /dev/null +++ b/packages/reflex-base/src/reflex_base/plugins/embed.py @@ -0,0 +1,247 @@ +"""Plugin that compiles a Reflex app for embedding into a host page. + +When this plugin is registered in ``rx.Config.plugins``, the compiler swaps the +framework-mode entry for an embed-aware variant that mounts the app into a +host-page DOM element (selected by ``mount_target``) instead of taking over the +document. A route manifest is emitted alongside so the embed entry can lazy- +load page modules through a memory router. +""" + +from __future__ import annotations + +import html +import json +import os +import re +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from reflex_base import constants + +from .base import Plugin as PluginBase + +if TYPE_CHECKING: + from reflex.app import UnevaluatedPage + + +_DEV_HOST_FALLBACK_ID = "reflex-dev-root" +_SELECTOR_TOKEN = re.compile( + r"(?P[#.])(?P[A-Za-z_][\w-]*)" + r"|\[(?P[A-Za-z_][\w-]*)(?:=(?P[\"']?)(?P[^\"'\]]*)(?P=quote))?\]" +) + + +def _embed_entry_task() -> tuple[str, str]: + template = ( + constants.Templates.Dirs.WEB_TEMPLATE / constants.Embed.ENTRY_EMBED_TEMPLATE + ) + return constants.Embed.ENTRY_PATH, template.read_text() + + +def _embed_manifest_task( + unevaluated_pages: Sequence[UnevaluatedPage], +) -> tuple[str, str]: + from reflex.compiler.compiler import compile_embed_manifest + + return compile_embed_manifest(page.route for page in unevaluated_pages) + + +def _mount_attrs_for_selector(selector: str) -> tuple[dict[str, str], bool]: + """Translate a CSS selector into HTML attributes for the host wrapper. + + Args: + selector: The ``mount_target`` selector configured on the plugin. + + Returns: + ``(attrs, ok)`` where ``attrs`` is a mapping of attribute names to + values for the wrapper element and ``ok`` indicates whether the parsed + attributes are guaranteed to satisfy the selector. + """ + attrs: dict[str, str] = {} + classes: list[str] = [] + consumed = 0 + for match in _SELECTOR_TOKEN.finditer(selector): + if match.start() != consumed: + break + consumed = match.end() + kind = match.group("kind") + if kind == "#": + attrs["id"] = match.group("value") + elif kind == ".": + classes.append(match.group("value")) + else: + attrs[match.group("attr")] = match.group("attr_value") or "" + if classes: + attrs["class"] = " ".join(classes) + ok = consumed == len(selector) and bool(attrs) + if not ok: + attrs = {"id": _DEV_HOST_FALLBACK_ID} + return attrs, ok + + +def _format_attrs(attrs: dict[str, str]) -> str: + parts: list[str] = [] + for name, value in attrs.items(): + if value == "": + parts.append(name) + else: + parts.append(f'{name}="{html.escape(value, quote=True)}"') + return (" " + " ".join(parts)) if parts else "" + + +def _render_dev_host_html(mount_target: str) -> str: + from reflex_base.utils import console + + attrs, ok = _mount_attrs_for_selector(mount_target) + if not ok: + console.warn( + f"EmbedPlugin: dev_preview cannot synthesize an element for " + f"selector {mount_target!r}; using id={_DEV_HOST_FALLBACK_ID!r} " + "instead. Open a hand-written host page if your selector matches " + "something more complex." + ) + return ( + "\n" + '\n' + "\n" + '\n' + '\n' + "Reflex embed dev preview\n" + "\n" + "\n" + f"\n" + f'\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 9a49f6f4f98..8ea4d6797f9 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -1162,11 +1162,6 @@ def compile_app( if page_ctx.output_path is not None and page_ctx.output_code is not None ] - if config.mount_target: - compile_results.append( - compile_embed_manifest(compile_ctx.compiled_pages.keys()) - ) - # Reinitialize vite config in case runtime options have changed. compile_results.append(( constants.ReactRouter.VITE_CONFIG_FILE, 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 39fdd824e5b..931330cb081 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -9,6 +9,7 @@ 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 @@ -18,13 +19,14 @@ 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": config.transport, "TEST_MODE": is_in_app_harness(), - "MOUNT_TARGET": config.mount_target, + "MOUNT_TARGET": embed_plugin.mount_target if embed_plugin else None, }, ) @@ -195,7 +197,7 @@ def _emit_stable_entry_bootloader(static_dir: Path) -> None: 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 ``mount_target`` is set, write a one-line + stable URL directly. When ``EmbedPlugin`` is registered, write a one-line re-export shim at ``Embed.ENTRY_PATH`` so the same ``