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();