Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/static/e2e/fixture-multi-entry/src/entries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
];
}
9 changes: 9 additions & 0 deletions packages/static/e2e/fixture-multi-entry/src/pages/HmrTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default function HmrTest() {
return (
<main>
<h1>HMR Test Page</h1>
<p data-testid="page-id">hmr-test</p>
<p data-testid="hmr-content">initial content</p>
</main>
);
}
37 changes: 37 additions & 0 deletions packages/static/e2e/tests-dev/multi-entry.spec.ts
Original file line number Diff line number Diff line change
@@ -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" };

Expand Down Expand Up @@ -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);
}
});
});
9 changes: 6 additions & 3 deletions packages/static/src/client/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ async function devMain() {

// re-fetch RSC and trigger re-rendering
async function fetchRscPayload() {
const payload = await createFromFetch<RscPayload>(
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<RscPayload>(fetch(rscUrl));
setPayload(payload);
}

Expand Down
36 changes: 24 additions & 12 deletions packages/static/src/rsc/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -139,14 +153,7 @@ export async function serveHTML(request: Request): Promise<Response> {

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", {
Expand Down Expand Up @@ -180,15 +187,20 @@ export async function serveRSC(request: Request): Promise<Response> {
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();
Expand Down