Skip to content

Commit 2981e71

Browse files
fix(hmr): stop unloaded-route edits from wedging browser HMR (reflex-dev#6774)
* 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. * chore: move news fragment to reflex-base and fix PR number * test(hmr): derive generated route paths instead of hardcoding, add frame scan cursor --------- Co-authored-by: Masen Furer <m_github@0x26.net>
1 parent a5511b2 commit 2981e71

3 files changed

Lines changed: 166 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

packages/reflex-base/src/reflex_base/compiler/templates.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,11 +610,37 @@ def vite_config_template(
610610
}};
611611
}}
612612
613+
// React Router queues manifest updates for lazy routes even when their modules
614+
// are not loaded. Its HMR runtime throws on those entries before clearing the
615+
// queue, which blocks every later update until the browser is reloaded.
616+
function patchReactRouterHmrRuntime() {{
617+
const unloadedRouteThrow = /if\s*\(!imported\)\s*\{{\s*throw\s+Error\(\s*`\[react-router:hmr\] No module update found for route [^`]+`,\s*\);\s*\}}/;
618+
return {{
619+
name: "reflex-patch-react-router-hmr-runtime",
620+
apply: "serve",
621+
enforce: "post",
622+
transform(code, id) {{
623+
if (id !== "\0virtual:react-router/hmr-runtime") return;
624+
if (!unloadedRouteThrow.test(code)) {{
625+
this.warn(
626+
"react-router hmr runtime changed; unloaded-route HMR patch skipped",
627+
);
628+
return;
629+
}}
630+
return {{
631+
code: code.replace(unloadedRouteThrow, "if (!imported) continue;"),
632+
map: null,
633+
}};
634+
}},
635+
}};
636+
}}
637+
613638
export default defineConfig((config) => ({{
614639
base: "{base}",
615640
plugins: [
616641
alwaysUseReactDomServerNode(),
617642
reactRouter(),
643+
patchReactRouterHmrRuntime(),
618644
safariCacheBustPlugin(),
619645
].concat({"[fullReload()]" if force_full_reload else "[]"}),
620646
build: {{
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Integration tests for browser hot module replacement."""
2+
3+
import json
4+
import time
5+
from collections.abc import Generator
6+
from pathlib import Path
7+
8+
import pytest
9+
from playwright.sync_api import Page, WebSocket, expect
10+
11+
from reflex.testing import AppHarness
12+
13+
14+
def HmrApp():
15+
"""Create an app with a route that remains unloaded in the browser."""
16+
import reflex as rx
17+
18+
def index():
19+
return rx.text("index-v0", id="version")
20+
21+
def unloaded():
22+
return rx.text("unloaded-v0", id="unloaded-version")
23+
24+
app = rx.App()
25+
app.add_page(index)
26+
app.add_page(unloaded, route="/unloaded")
27+
28+
29+
@pytest.fixture(scope="module")
30+
def hmr_app(tmp_path_factory) -> Generator[AppHarness, None, None]:
31+
"""Run the HMR test app in development mode.
32+
33+
Args:
34+
tmp_path_factory: Pytest temporary path factory.
35+
36+
Yields:
37+
The running application harness.
38+
"""
39+
with AppHarness.create(
40+
root=tmp_path_factory.mktemp("hmr_app"),
41+
app_source=HmrApp,
42+
) as harness:
43+
yield harness
44+
45+
46+
def _find_route(route_dir: Path, marker: str) -> Path:
47+
"""Locate the generated route module containing a marker string.
48+
49+
Args:
50+
route_dir: Directory of generated route modules.
51+
marker: Source text unique to one route.
52+
53+
Returns:
54+
The route module containing the marker.
55+
"""
56+
matches = [path for path in route_dir.glob("*.jsx") if marker in path.read_text()]
57+
assert len(matches) == 1, f"expected one route containing {marker!r}, got {matches}"
58+
return matches[0]
59+
60+
61+
def _replace_once(path: Path, old: str, new: str) -> None:
62+
"""Replace one occurrence in a generated route module.
63+
64+
Args:
65+
path: Generated route module to edit.
66+
old: Existing source text.
67+
new: Replacement source text.
68+
"""
69+
source = path.read_text()
70+
assert source.count(old) == 1
71+
path.write_text(source.replace(old, new))
72+
73+
74+
def _wait_for_hmr_manifest(
75+
page: Page, frames: list[str], route_id: str, start_index: int
76+
) -> None:
77+
"""Wait until React Router receives a manifest update for a route.
78+
79+
Args:
80+
page: Playwright page driving the application.
81+
frames: Captured Vite websocket frames.
82+
route_id: React Router route identifier expected in the update.
83+
start_index: Ignore frames captured before this index.
84+
85+
Raises:
86+
AssertionError: If the expected update is not received.
87+
"""
88+
deadline = time.monotonic() + 10
89+
scan_pos = start_index
90+
while time.monotonic() < deadline:
91+
page.wait_for_timeout(100)
92+
while scan_pos < len(frames):
93+
frame = frames[scan_pos]
94+
scan_pos += 1
95+
if "react-router:hmr" not in frame:
96+
continue
97+
payload = json.loads(frame)
98+
if payload.get("data", {}).get("route", {}).get("id") == route_id:
99+
return
100+
msg = f"no HMR manifest update received for {route_id}"
101+
raise AssertionError(msg)
102+
103+
104+
def test_unloaded_route_update_does_not_wedge_hmr(
105+
hmr_app: AppHarness, page: Page
106+
) -> None:
107+
"""An unopened route edit must not block a later visible route update.
108+
109+
Args:
110+
hmr_app: Running application harness.
111+
page: Playwright page driving the application.
112+
"""
113+
assert hmr_app.frontend_url is not None
114+
route_dir = hmr_app.app_path / ".web" / "app" / "routes"
115+
index_route = _find_route(route_dir, "index-v0")
116+
unloaded_route = _find_route(route_dir, "unloaded-v0")
117+
# React Router route ids are the app-relative module path without extension.
118+
unloaded_route_id = f"routes/{unloaded_route.stem}"
119+
frames: list[str] = []
120+
121+
def capture_websocket(websocket: WebSocket) -> None:
122+
def capture_frame(frame: bytes | str) -> None:
123+
frames.append(frame.decode() if isinstance(frame, bytes) else frame)
124+
125+
websocket.on("framereceived", capture_frame)
126+
127+
page.on("websocket", capture_websocket)
128+
page.goto(hmr_app.frontend_url)
129+
expect(page.locator("#version")).to_have_text("index-v0")
130+
131+
frame_index = len(frames)
132+
_replace_once(unloaded_route, "unloaded-v0", "unloaded-v1")
133+
_wait_for_hmr_manifest(page, frames, unloaded_route_id, frame_index)
134+
135+
_replace_once(index_route, "index-v0", "index-v1")
136+
expect(page.locator("#version")).to_have_text("index-v1", timeout=10_000)
137+
138+
page.goto(f"{hmr_app.frontend_url.rstrip('/')}/unloaded")
139+
expect(page.locator("#unloaded-version")).to_have_text("unloaded-v1")

0 commit comments

Comments
 (0)