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(Plugin): + """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 provides_entry_client(self) -> bool: + """Declare that EmbedPlugin emits its own ``entry.client.js``. + + ``pre_compile`` registers ``_embed_entry_task`` which writes the + embed-aware entry template at ``constants.Embed.ENTRY_PATH``; the + framework's default ``update_entry_client`` must skip this path to + avoid stomping the embed entry on every compile. + + Returns: + Always ``True``. + """ + return True + + def update_env_json(self, **context): + """Contribute the mount target so the embed entry can read it. + + Args: + context: The context for the plugin. + + Returns: + A mapping containing ``MOUNT_TARGET`` for ``.web/env.json``. + """ + return {"MOUNT_TARGET": self.mount_target} + + def post_build(self, **context): + """Re-emit the hashed Vite entry chunk at a stable host-page URL. + + Vite hashes the entry chunk (``assets/entry.client-.js``), so a + host page can't `` + + +""" + (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/plugins/test_embed.py b/tests/units/plugins/test_embed.py new file mode 100644 index 00000000000..d8efd11f536 --- /dev/null +++ b/tests/units/plugins/test_embed.py @@ -0,0 +1,272 @@ +"""Unit tests for the embed plugin.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from pytest_mock import MockerFixture +from reflex_base import constants +from reflex_base.plugins.embed import ( + EmbedPlugin, + _inject_vite_dev_preview, + _mount_attrs_for_selector, + _render_dev_host_html, + compile_embed_manifest, + get_embed_plugin, +) + +from reflex.compiler import utils + + +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_update_env_json_returns_mount_target(): + plugin = EmbedPlugin(mount_target="#widget") + assert plugin.update_env_json() == {"MOUNT_TARGET": "#widget"} + + +def test_provides_entry_client_is_true(): + plugin = EmbedPlugin(mount_target="#widget") + assert plugin.provides_entry_client() is True + + +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 + + +_ENTRY_RE = re.compile( + r'\{\s*path:\s*"([^"]*)",\s*load:\s*\(\)\s*=>\s*import\("([^"]*)"\)\s*\}' +) + + +def test_compile_embed_manifest_pairs_translated_paths_with_specifiers( + mocker: MockerFixture, tmp_path: Path +): + """Each route produces one entry pairing its React-Router path with its import specifier.""" + mocker.patch("reflex.utils.prerequisites.get_web_dir", return_value=tmp_path) + + routes = [ + "index", + "users/[id]", + "posts/[[slug]]", + "docs/[[...splat]]", + constants.Page404.SLUG, + ] + expected_pairs = [ + ("", utils.get_page_import_specifier("index")), + ("users/:id", utils.get_page_import_specifier("users/[id]")), + ("posts/:slug?", utils.get_page_import_specifier("posts/[[slug]]")), + ("docs/*", utils.get_page_import_specifier("docs/[[...splat]]")), + ("*", utils.get_page_import_specifier(constants.Page404.SLUG)), + ] + + output_path, code = compile_embed_manifest(routes) + + assert output_path == str( + tmp_path / constants.Dirs.PAGES / constants.Embed.MANIFEST_FILE + ) + assert _ENTRY_RE.findall(code) == expected_pairs + + +def _make_static_dir(tmp_path: Path, entry_names: list[str]) -> Path: + """Create a fake Vite static dir with ``assets/`` for each entry. + + Returns: + The created static-dir path. + """ + static_dir = tmp_path / "client" + assets = static_dir / "assets" + assets.mkdir(parents=True) + for name in entry_names: + (assets / name).write_text("// chunk\n") + return static_dir + + +def test_post_build_emits_shim_pointing_at_hashed_asset(tmp_path: Path): + static_dir = _make_static_dir(tmp_path, ["entry.client-abc123.js"]) + plugin = EmbedPlugin(mount_target="#root") + + plugin.post_build(static_dir=static_dir) + + shim = static_dir / constants.Embed.ENTRY_PATH + assert shim.exists() + assert shim.read_text() == 'import "/assets/entry.client-abc123.js";\n' + + +def test_post_build_prefixes_embed_origin(tmp_path: Path): + static_dir = _make_static_dir(tmp_path, ["entry.client-deadbeef.js"]) + plugin = EmbedPlugin(mount_target="#root", embed_origin="https://cdn.example.com/") + + plugin.post_build(static_dir=static_dir) + + shim = static_dir / constants.Embed.ENTRY_PATH + assert ( + shim.read_text() + == 'import "https://cdn.example.com/assets/entry.client-deadbeef.js";\n' + ) + + +def test_post_build_raises_when_no_entry_chunk(tmp_path: Path): + static_dir = _make_static_dir(tmp_path, []) + plugin = EmbedPlugin(mount_target="#root") + + with pytest.raises(RuntimeError, match="Expected exactly one Vite entry chunk"): + plugin.post_build(static_dir=static_dir) + + +def test_post_build_raises_when_multiple_entry_chunks(tmp_path: Path): + static_dir = _make_static_dir( + tmp_path, ["entry.client-aaa.js", "entry.client-bbb.js"] + ) + plugin = EmbedPlugin(mount_target="#root") + + with pytest.raises( + RuntimeError, match=r"Expected exactly one Vite entry chunk.*found 2: \[" + ): + plugin.post_build(static_dir=static_dir) diff --git a/tests/units/utils/test_build.py b/tests/units/utils/test_build.py new file mode 100644 index 00000000000..86f6aa8637e --- /dev/null +++ b/tests/units/utils/test_build.py @@ -0,0 +1,74 @@ +"""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, Plugin +from reflex.utils import build + + +def _patch_env_json( + mocker: MockerFixture, tmp_path: Path, plugins: list[Plugin] | 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" + config.plugins = plugins or [] + 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_merges_plugin_contributions( + tmp_path: Path, mocker: MockerFixture +): + """``update_env_json`` returns merge on top of the base env dict.""" + web_dir = _patch_env_json( + mocker, tmp_path, plugins=[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_omits_plugin_keys_when_plugin_absent( + tmp_path: Path, mocker: MockerFixture +): + """Plugin-contributed keys are absent from env.json when no plugin contributes.""" + web_dir = _patch_env_json(mocker, tmp_path, plugins=[]) + + build.set_env_json() + + env = json.loads((web_dir / "env.json").read_text()) + assert "MOUNT_TARGET" not in env + assert env["TRANSPORT"] == "websocket" + + +def test_set_env_json_later_plugin_wins(tmp_path: Path, mocker: MockerFixture): + """Later plugins override earlier ones on conflicting keys.""" + + class FirstPlugin(Plugin): + def update_env_json(self, **context): + return {"SHARED": "first", "ONLY_FIRST": 1} + + class SecondPlugin(Plugin): + def update_env_json(self, **context): + return {"SHARED": "second", "ONLY_SECOND": 2} + + web_dir = _patch_env_json(mocker, tmp_path, plugins=[FirstPlugin(), SecondPlugin()]) + + build.set_env_json() + + env = json.loads((web_dir / "env.json").read_text()) + assert env["SHARED"] == "second" + assert env["ONLY_FIRST"] == 1 + assert env["ONLY_SECOND"] == 2