diff --git a/packages/static/e2e/fixture-multi-entry/src/entries.tsx b/packages/static/e2e/fixture-multi-entry/src/entries.tsx
index 6247567..2586517 100644
--- a/packages/static/e2e/fixture-multi-entry/src/entries.tsx
+++ b/packages/static/e2e/fixture-multi-entry/src/entries.tsx
@@ -12,5 +12,10 @@ export default function getEntries(): EntryDefinition[] {
root: () => import("./root"),
app: () => import("./pages/About"),
},
+ {
+ path: "hmr-test.html",
+ root: () => import("./root"),
+ app: () => import("./pages/HmrTest"),
+ },
];
}
diff --git a/packages/static/e2e/fixture-multi-entry/src/pages/HmrTest.tsx b/packages/static/e2e/fixture-multi-entry/src/pages/HmrTest.tsx
new file mode 100644
index 0000000..fc66885
--- /dev/null
+++ b/packages/static/e2e/fixture-multi-entry/src/pages/HmrTest.tsx
@@ -0,0 +1,9 @@
+export default function HmrTest() {
+ return (
+
+ HMR Test Page
+ hmr-test
+ initial content
+
+ );
+}
diff --git a/packages/static/e2e/tests-dev/multi-entry.spec.ts b/packages/static/e2e/tests-dev/multi-entry.spec.ts
index d6a3010..dfc520c 100644
--- a/packages/static/e2e/tests-dev/multi-entry.spec.ts
+++ b/packages/static/e2e/tests-dev/multi-entry.spec.ts
@@ -1,4 +1,6 @@
import { expect, test } from "@playwright/test";
+import { readFile, writeFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
const htmlHeaders = { Accept: "text/html" };
@@ -76,3 +78,38 @@ test.describe("Multi-entry page rendering (dev server)", () => {
expect(errors).toEqual([]);
});
});
+
+test.describe("Multi-entry HMR (dev server)", () => {
+ const hmrPagePath = fileURLToPath(
+ new URL("../fixture-multi-entry/src/pages/HmrTest.tsx", import.meta.url),
+ );
+
+ test("server code change re-renders the current non-first entry", async ({
+ page,
+ }) => {
+ const originalSource = await readFile(hmrPagePath, "utf-8");
+ try {
+ await page.goto("/hmr-test");
+ await expect(page.locator("h1")).toHaveText("HMR Test Page");
+ await expect(page.getByTestId("hmr-content")).toHaveText(
+ "initial content",
+ );
+
+ await writeFile(
+ hmrPagePath,
+ originalSource.replace("initial content", "updated content"),
+ );
+
+ // The edited content should appear via HMR without a page reload
+ await expect(page.getByTestId("hmr-content")).toHaveText(
+ "updated content",
+ { timeout: 15000 },
+ );
+ // The page must still show this entry's tree, not the first entry's
+ await expect(page.locator("h1")).toHaveText("HMR Test Page");
+ await expect(page.getByTestId("page-id")).toHaveText("hmr-test");
+ } finally {
+ await writeFile(hmrPagePath, originalSource);
+ }
+ });
+});
diff --git a/packages/static/src/client/entry.tsx b/packages/static/src/client/entry.tsx
index 64d55ae..0f0730d 100644
--- a/packages/static/src/client/entry.tsx
+++ b/packages/static/src/client/entry.tsx
@@ -33,9 +33,12 @@ async function devMain() {
// re-fetch RSC and trigger re-rendering
async function fetchRscPayload() {
- const payload = await createFromFetch(
- fetch(withBasePath(devMainRscPath)),
- );
+ // Pass the current page path so the server re-renders the entry
+ // being viewed rather than the first entry
+ const rscUrl = `${withBasePath(devMainRscPath)}?path=${encodeURIComponent(
+ location.pathname,
+ )}`;
+ const payload = await createFromFetch(fetch(rscUrl));
setPayload(payload);
}
diff --git a/packages/static/src/rsc/entry.tsx b/packages/static/src/rsc/entry.tsx
index 8bf07c3..24d6203 100644
--- a/packages/static/src/rsc/entry.tsx
+++ b/packages/static/src/rsc/entry.tsx
@@ -48,6 +48,20 @@ function findEntryForUrlPath(
return undefined;
}
+/**
+ * Resolves the entry for a URL path, falling back to the index entry
+ * (SPA fallback) when no entry matches.
+ */
+function resolveEntryForUrlPath(
+ entries: EntryDefinition[],
+ urlPath: string,
+): EntryDefinition | undefined {
+ return (
+ findEntryForUrlPath(entries, urlPath) ??
+ entries.find((e) => e.path === "index.html" || e.path === "index.htm")
+ );
+}
+
/**
* Renders a single entry to an HTML response.
*/
@@ -139,14 +153,7 @@ export async function serveHTML(request: Request): Promise {
const url = new URL(request.url);
const urlPath = stripBasePath(url.pathname);
- let entry = findEntryForUrlPath(entries, urlPath);
-
- // SPA fallback: if no entry matched, fall back to index.html or index.htm entry
- if (!entry) {
- entry = entries.find(
- (e) => e.path === "index.html" || e.path === "index.htm",
- );
- }
+ const entry = resolveEntryForUrlPath(entries, urlPath);
if (!entry) {
return new Response("Not Found", {
@@ -180,15 +187,20 @@ export async function serveRSC(request: Request): Promise {
const pathname = stripBasePath(url.pathname);
if (pathname === devMainRscPath) {
// root RSC stream is requested (HMR re-fetch always sends full tree)
- // For HMR, re-render the first entry (single-entry mode) or index.html entry
const entriesStart = performance.now();
const entries = await loadEntriesList();
timings.push(`entries;dur=${performance.now() - entriesStart}`);
- // Use the first entry for HMR re-fetch
- const entry = entries[0];
+ // Re-render the entry for the page the client is currently viewing,
+ // passed as the `path` query parameter (with the same SPA fallback as
+ // serveHTML). Fall back to the first entry if no path was provided.
+ const pagePath = url.searchParams.get("path");
+ const entry =
+ pagePath !== null
+ ? resolveEntryForUrlPath(entries, stripBasePath(pagePath))
+ : entries[0];
if (!entry) {
- throw new ServeRSCError("No entries defined", 404);
+ throw new ServeRSCError("No entry found for HMR re-fetch", 404);
}
const resolveStart = performance.now();