From 8e50467372387cb431dea611fa358e3527a15b41 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 14 Jul 2026 23:15:39 +0500 Subject: [PATCH 1/3] fix(hmr): stop unloaded-route edits from wedging browser HMR React Router queues manifest updates for lazy routes even when their modules aren't loaded in the browser. Its HMR runtime throws on those entries before clearing the queue, blocking every later hot update until a full page reload. Add a Vite plugin that patches the runtime to skip unloaded routes instead of throwing. --- news/10146.bugfix.md | 1 + .../src/reflex_base/compiler/templates.py | 26 ++++ .../integration/tests_playwright/test_hmr.py | 124 ++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 news/10146.bugfix.md create mode 100644 tests/integration/tests_playwright/test_hmr.py diff --git a/news/10146.bugfix.md b/news/10146.bugfix.md new file mode 100644 index 00000000000..5459bbd8994 --- /dev/null +++ b/news/10146.bugfix.md @@ -0,0 +1 @@ +Prevented edits to unloaded routes from poisoning React Router's browser-side HMR queue and blocking all later hot updates until a full page reload. diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 8e6e63a2bb3..99ac4875207 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -609,11 +609,37 @@ def vite_config_template( }}; }} +// React Router queues manifest updates for lazy routes even when their modules +// are not loaded. Its HMR runtime throws on those entries before clearing the +// queue, which blocks every later update until the browser is reloaded. +function patchReactRouterHmrRuntime() {{ + const unloadedRouteThrow = /if\s*\(!imported\)\s*\{{\s*throw\s+Error\(\s*`\[react-router:hmr\] No module update found for route [^`]+`,\s*\);\s*\}}/; + return {{ + name: "reflex-patch-react-router-hmr-runtime", + apply: "serve", + enforce: "post", + transform(code, id) {{ + if (id !== "\0virtual:react-router/hmr-runtime") return; + if (!unloadedRouteThrow.test(code)) {{ + this.warn( + "react-router hmr runtime changed; unloaded-route HMR patch skipped", + ); + return; + }} + return {{ + code: code.replace(unloadedRouteThrow, "if (!imported) continue;"), + map: null, + }}; + }}, + }}; +}} + export default defineConfig((config) => ({{ base: "{base}", plugins: [ alwaysUseReactDomServerNode(), reactRouter(), + patchReactRouterHmrRuntime(), safariCacheBustPlugin(), ].concat({"[fullReload()]" if force_full_reload else "[]"}), build: {{ diff --git a/tests/integration/tests_playwright/test_hmr.py b/tests/integration/tests_playwright/test_hmr.py new file mode 100644 index 00000000000..4547f4ff82a --- /dev/null +++ b/tests/integration/tests_playwright/test_hmr.py @@ -0,0 +1,124 @@ +"""Integration tests for browser hot module replacement.""" + +import json +import time +from collections.abc import Generator +from pathlib import Path + +import pytest +from playwright.sync_api import Page, WebSocket, expect + +from reflex.testing import AppHarness + + +def HmrApp(): + """Create an app with a route that remains unloaded in the browser.""" + import reflex as rx + + def index(): + return rx.text("index-v0", id="version") + + def unloaded(): + return rx.text("unloaded-v0", id="unloaded-version") + + app = rx.App() + app.add_page(index) + app.add_page(unloaded, route="/unloaded") + + +@pytest.fixture(scope="module") +def hmr_app(tmp_path_factory) -> Generator[AppHarness, None, None]: + """Run the HMR test app in development mode. + + Args: + tmp_path_factory: Pytest temporary path factory. + + Yields: + The running application harness. + """ + with AppHarness.create( + root=tmp_path_factory.mktemp("hmr_app"), + app_source=HmrApp, + ) as harness: + yield harness + + +def _replace_once(path: Path, old: str, new: str) -> None: + """Replace one occurrence in a generated route module. + + Args: + path: Generated route module to edit. + old: Existing source text. + new: Replacement source text. + """ + source = path.read_text() + assert source.count(old) == 1 + path.write_text(source.replace(old, new)) + + +def _wait_for_hmr_manifest( + page: Page, frames: list[str], route_id: str, start_index: int +) -> None: + """Wait until React Router receives a manifest update for a route. + + Args: + page: Playwright page driving the application. + frames: Captured Vite websocket frames. + route_id: React Router route identifier expected in the update. + start_index: Ignore frames captured before this index. + + Raises: + AssertionError: If the expected update is not received. + """ + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + page.wait_for_timeout(100) + for frame in frames[start_index:]: + if "react-router:hmr" not in frame: + continue + payload = json.loads(frame) + if payload.get("data", {}).get("route", {}).get("id") == route_id: + return + msg = f"no HMR manifest update received for {route_id}" + raise AssertionError(msg) + + +def test_unloaded_route_update_does_not_wedge_hmr( + hmr_app: AppHarness, page: Page +) -> None: + """An unopened route edit must not block a later visible route update. + + Args: + hmr_app: Running application harness. + page: Playwright page driving the application. + """ + assert hmr_app.frontend_url is not None + route_dir = hmr_app.app_path / ".web" / "app" / "routes" + index_route = route_dir / "_index.jsx" + unloaded_route = route_dir / "[unloaded]._index.jsx" + frames: list[str] = [] + + def capture_websocket(websocket: WebSocket) -> None: + def capture_frame(frame: bytes | str) -> None: + frames.append(frame.decode() if isinstance(frame, bytes) else frame) + + websocket.on("framereceived", capture_frame) + + page.on("websocket", capture_websocket) + page.goto(hmr_app.frontend_url) + expect(page.locator("#version")).to_have_text("index-v0") + + frame_index = len(frames) + _replace_once(unloaded_route, "unloaded-v0", "unloaded-v1") + _wait_for_hmr_manifest( + page, + frames, + "routes/[unloaded]._index", + frame_index, + ) + + _replace_once(index_route, "index-v0", "index-v1") + expect(page.locator("#version")).to_have_text("index-v1", timeout=10_000) + + page.goto(f"{hmr_app.frontend_url.rstrip('/')}/unloaded") + expect(page.locator("#unloaded-version")).to_have_text("unloaded-v1") From be53020e42566862bb3475a5abbc3888a0097df2 Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 15 Jul 2026 21:06:06 +0500 Subject: [PATCH 2/3] chore: move news fragment to reflex-base and fix PR number --- news/10146.bugfix.md => packages/reflex-base/news/6774.bugfix.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename news/10146.bugfix.md => packages/reflex-base/news/6774.bugfix.md (100%) diff --git a/news/10146.bugfix.md b/packages/reflex-base/news/6774.bugfix.md similarity index 100% rename from news/10146.bugfix.md rename to packages/reflex-base/news/6774.bugfix.md From 9d27690800653538439a8653689acc8001732f6f Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 15 Jul 2026 21:59:34 +0500 Subject: [PATCH 3/3] test(hmr): derive generated route paths instead of hardcoding, add frame scan cursor --- .../integration/tests_playwright/test_hmr.py | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/tests/integration/tests_playwright/test_hmr.py b/tests/integration/tests_playwright/test_hmr.py index 4547f4ff82a..cbb9ae3a5d8 100644 --- a/tests/integration/tests_playwright/test_hmr.py +++ b/tests/integration/tests_playwright/test_hmr.py @@ -43,6 +43,21 @@ def hmr_app(tmp_path_factory) -> Generator[AppHarness, None, None]: yield harness +def _find_route(route_dir: Path, marker: str) -> Path: + """Locate the generated route module containing a marker string. + + Args: + route_dir: Directory of generated route modules. + marker: Source text unique to one route. + + Returns: + The route module containing the marker. + """ + matches = [path for path in route_dir.glob("*.jsx") if marker in path.read_text()] + assert len(matches) == 1, f"expected one route containing {marker!r}, got {matches}" + return matches[0] + + def _replace_once(path: Path, old: str, new: str) -> None: """Replace one occurrence in a generated route module. @@ -71,9 +86,12 @@ def _wait_for_hmr_manifest( AssertionError: If the expected update is not received. """ deadline = time.monotonic() + 10 + scan_pos = start_index while time.monotonic() < deadline: page.wait_for_timeout(100) - for frame in frames[start_index:]: + while scan_pos < len(frames): + frame = frames[scan_pos] + scan_pos += 1 if "react-router:hmr" not in frame: continue payload = json.loads(frame) @@ -94,8 +112,10 @@ def test_unloaded_route_update_does_not_wedge_hmr( """ assert hmr_app.frontend_url is not None route_dir = hmr_app.app_path / ".web" / "app" / "routes" - index_route = route_dir / "_index.jsx" - unloaded_route = route_dir / "[unloaded]._index.jsx" + index_route = _find_route(route_dir, "index-v0") + unloaded_route = _find_route(route_dir, "unloaded-v0") + # React Router route ids are the app-relative module path without extension. + unloaded_route_id = f"routes/{unloaded_route.stem}" frames: list[str] = [] def capture_websocket(websocket: WebSocket) -> None: @@ -110,12 +130,7 @@ def capture_frame(frame: bytes | str) -> None: frame_index = len(frames) _replace_once(unloaded_route, "unloaded-v0", "unloaded-v1") - _wait_for_hmr_manifest( - page, - frames, - "routes/[unloaded]._index", - frame_index, - ) + _wait_for_hmr_manifest(page, frames, unloaded_route_id, frame_index) _replace_once(index_route, "index-v0", "index-v1") expect(page.locator("#version")).to_have_text("index-v1", timeout=10_000)