Skip to content

Commit 6b6da69

Browse files
fibibotbartlomieju
authored andcommitted
fix(vite): inject client entry on islands-free pages for HMR
The dev server emits `fresh:reload` over the Vite WebSocket whenever an SSR-only module changes, but the listener for that event lives in the client entry. The Vite plugin only injected the client entry when a page had at least one island, so projects with islands-free routes never had the `fresh:reload` listener attached and edits did not refresh the browser. Wire `hmrClientEntry` through the build snapshot so that in dev the SSR runtime always emits the boot script, which loads the client entry and attaches the HMR listener regardless of whether the route uses islands. Closes #3806
1 parent d738f22 commit 6b6da69

4 files changed

Lines changed: 40 additions & 0 deletions

File tree

packages/fresh/src/build_cache.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ export interface FileSnapshot {
1717
export interface BuildSnapshot<State> {
1818
version: string;
1919
clientEntry: string;
20+
/**
21+
* Pathname for an HMR-only client entry. When defined, the SSR runtime
22+
* always emits the boot script so HMR listeners attach to pages that
23+
* have no islands. Undefined outside of dev.
24+
*/
25+
hmrClientEntry?: string;
2026
fsRoutes: FsRouteFile<State>[];
2127
staticFiles: Map<string, FileSnapshot>;
2228
islands: ServerIslandRegistry;
@@ -51,13 +57,15 @@ export class ProdBuildCache<State> implements BuildCache<State> {
5157
#snapshot: BuildSnapshot<State>;
5258
islandRegistry: ServerIslandRegistry;
5359
clientEntry: string;
60+
hmrClientEntry: string | undefined;
5461
features = { errorOverlay: false };
5562

5663
constructor(public root: string, snapshot: BuildSnapshot<State>) {
5764
setBuildId(snapshot.version);
5865
this.#snapshot = snapshot;
5966
this.islandRegistry = snapshot.islands;
6067
this.clientEntry = snapshot.clientEntry;
68+
this.hmrClientEntry = snapshot.hmrClientEntry;
6169
}
6270

6371
getEntryAssets(): string[] {

packages/fresh/src/dev/dev_build_cache.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,7 @@ export async function generateSnapshotServer(
458458
outDir: string;
459459
buildId: string;
460460
clientEntry: string;
461+
hmrClientEntry?: string;
461462
islands: IslandModChunk[];
462463
// deno-lint-ignore no-explicit-any
463464
fsRoutesFiles: FsRouteFileNoMod<any>[];
@@ -524,12 +525,17 @@ export async function generateSnapshotServer(
524525
const entryAssets = options.entryAssets.map((url) => JSON.stringify(url))
525526
.join(",\n");
526527

528+
const hmrClientEntryDecl = options.hmrClientEntry !== undefined
529+
? `export const hmrClientEntry = ${JSON.stringify(options.hmrClientEntry)}`
530+
: "";
531+
527532
return `${EDIT_WARNING}
528533
import { IslandPreparer } from "fresh/internal";
529534
${islandImports}
530535
${fsRouteImports}
531536
532537
export const clientEntry = ${JSON.stringify(options.clientEntry)}
538+
${hmrClientEntryDecl}
533539
export const version = ${JSON.stringify(options.buildId)}
534540
535541
export const islands = new Map();

packages/plugin-vite/src/plugins/server_snapshot.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,18 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] {
165165
const staticFiles: PendingStaticFile[] = [];
166166
let islandMods: IslandModChunk[] = [];
167167
let clientEntry = "/@id/fresh:client-entry";
168+
let hmrClientEntry: string | undefined;
168169
let buildId = "";
169170
const entryAssets: string[] = [];
170171

171172
if (isDev && server !== undefined) {
173+
// The client entry hosts the HMR listener. Set hmrClientEntry so
174+
// the SSR runtime always emits a boot script in dev, even when
175+
// a page has zero islands. Without this, edits to islands-free
176+
// routes never trigger a browser reload because the
177+
// `fresh:reload` WebSocket listener is never attached.
178+
hmrClientEntry = clientEntry;
179+
172180
for (const id of islands.keys()) {
173181
const mod = server.environments.client.moduleGraph.getModuleById(
174182
id,
@@ -380,6 +388,7 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] {
380388
staticFiles,
381389
buildId,
382390
clientEntry,
391+
hmrClientEntry,
383392
entryAssets,
384393
fsRoutesFiles: result.routes,
385394
islands: islandMods,

packages/plugin-vite/tests/dev_server_test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,23 @@ integrationTest("vite dev - starts without islands/ dir", async () => {
6969
});
7070
});
7171

72+
// Issue: https://github.com/denoland/fresh/issues/3806
73+
// Pages without islands must still load the client entry in dev so the
74+
// HMR `fresh:reload` listener attaches and route edits trigger a refresh.
75+
integrationTest(
76+
"vite dev - injects client entry on islands-free pages for HMR",
77+
async () => {
78+
const fixture = path.join(FIXTURE_DIR, "no_islands");
79+
await withDevServer(fixture, async (address) => {
80+
const res = await fetch(`${address}/`);
81+
const text = await res.text();
82+
expect(text).toContain("ok");
83+
expect(text).toContain("/@id/fresh:client-entry");
84+
expect(text).toMatch(/import\s*\{\s*boot\s*\}/);
85+
});
86+
},
87+
);
88+
7289
integrationTest("vite dev - starts without routes/ dir", async () => {
7390
const fixture = path.join(FIXTURE_DIR, "no_routes");
7491
await withDevServer(fixture, async (address) => {

0 commit comments

Comments
 (0)