From 7c3ce8de143f545ce39ebbdf631a0bd51b4ddc38 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 10:03:20 +0000 Subject: [PATCH] fix: resolve current entry for dev HMR re-fetch in multi-entry mode In dev, server-code changes trigger an RSC re-fetch from the client, but the server handler always rendered the first entry, replacing the current page's content with the first entry's tree when viewing any other page (which affects every fs-routes app). The client now sends the current page path as a query parameter, and the server resolves it with findEntryForUrlPath plus the same SPA fallback as serveHTML. Closes #140 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VfH2tK77WCGbfh24sU4yRp --- .../e2e/fixture-multi-entry/src/entries.tsx | 5 +++ .../fixture-multi-entry/src/pages/HmrTest.tsx | 9 +++++ .../static/e2e/tests-dev/multi-entry.spec.ts | 37 +++++++++++++++++++ packages/static/src/client/entry.tsx | 9 +++-- packages/static/src/rsc/entry.tsx | 36 ++++++++++++------ 5 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 packages/static/e2e/fixture-multi-entry/src/pages/HmrTest.tsx 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();