From 6b6da691cf31f5ef4d1d0818fe9b02a17dac2212 Mon Sep 17 00:00:00 2001 From: fibibot Date: Thu, 14 May 2026 20:47:27 +0000 Subject: [PATCH 1/2] 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 denoland/fresh#3806 --- packages/fresh/src/build_cache.ts | 8 ++++++++ packages/fresh/src/dev/dev_build_cache.ts | 6 ++++++ .../plugin-vite/src/plugins/server_snapshot.ts | 9 +++++++++ packages/plugin-vite/tests/dev_server_test.ts | 17 +++++++++++++++++ 4 files changed, 40 insertions(+) diff --git a/packages/fresh/src/build_cache.ts b/packages/fresh/src/build_cache.ts index 6a8161a2b77..709976901fb 100644 --- a/packages/fresh/src/build_cache.ts +++ b/packages/fresh/src/build_cache.ts @@ -17,6 +17,12 @@ export interface FileSnapshot { export interface BuildSnapshot { version: string; clientEntry: string; + /** + * Pathname for an HMR-only client entry. When defined, the SSR runtime + * always emits the boot script so HMR listeners attach to pages that + * have no islands. Undefined outside of dev. + */ + hmrClientEntry?: string; fsRoutes: FsRouteFile[]; staticFiles: Map; islands: ServerIslandRegistry; @@ -51,6 +57,7 @@ export class ProdBuildCache implements BuildCache { #snapshot: BuildSnapshot; islandRegistry: ServerIslandRegistry; clientEntry: string; + hmrClientEntry: string | undefined; features = { errorOverlay: false }; constructor(public root: string, snapshot: BuildSnapshot) { @@ -58,6 +65,7 @@ export class ProdBuildCache implements BuildCache { this.#snapshot = snapshot; this.islandRegistry = snapshot.islands; this.clientEntry = snapshot.clientEntry; + this.hmrClientEntry = snapshot.hmrClientEntry; } getEntryAssets(): string[] { diff --git a/packages/fresh/src/dev/dev_build_cache.ts b/packages/fresh/src/dev/dev_build_cache.ts index dd985551113..e9e844e66db 100644 --- a/packages/fresh/src/dev/dev_build_cache.ts +++ b/packages/fresh/src/dev/dev_build_cache.ts @@ -458,6 +458,7 @@ export async function generateSnapshotServer( outDir: string; buildId: string; clientEntry: string; + hmrClientEntry?: string; islands: IslandModChunk[]; // deno-lint-ignore no-explicit-any fsRoutesFiles: FsRouteFileNoMod[]; @@ -524,12 +525,17 @@ export async function generateSnapshotServer( const entryAssets = options.entryAssets.map((url) => JSON.stringify(url)) .join(",\n"); + const hmrClientEntryDecl = options.hmrClientEntry !== undefined + ? `export const hmrClientEntry = ${JSON.stringify(options.hmrClientEntry)}` + : ""; + return `${EDIT_WARNING} import { IslandPreparer } from "fresh/internal"; ${islandImports} ${fsRouteImports} export const clientEntry = ${JSON.stringify(options.clientEntry)} +${hmrClientEntryDecl} export const version = ${JSON.stringify(options.buildId)} export const islands = new Map(); diff --git a/packages/plugin-vite/src/plugins/server_snapshot.ts b/packages/plugin-vite/src/plugins/server_snapshot.ts index cf37c0452dd..6b1117092e2 100644 --- a/packages/plugin-vite/src/plugins/server_snapshot.ts +++ b/packages/plugin-vite/src/plugins/server_snapshot.ts @@ -165,10 +165,18 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] { const staticFiles: PendingStaticFile[] = []; let islandMods: IslandModChunk[] = []; let clientEntry = "/@id/fresh:client-entry"; + let hmrClientEntry: string | undefined; let buildId = ""; const entryAssets: string[] = []; if (isDev && server !== undefined) { + // The client entry hosts the HMR listener. Set hmrClientEntry so + // the SSR runtime always emits a boot script in dev, even when + // a page has zero islands. Without this, edits to islands-free + // routes never trigger a browser reload because the + // `fresh:reload` WebSocket listener is never attached. + hmrClientEntry = clientEntry; + for (const id of islands.keys()) { const mod = server.environments.client.moduleGraph.getModuleById( id, @@ -380,6 +388,7 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] { staticFiles, buildId, clientEntry, + hmrClientEntry, entryAssets, fsRoutesFiles: result.routes, islands: islandMods, diff --git a/packages/plugin-vite/tests/dev_server_test.ts b/packages/plugin-vite/tests/dev_server_test.ts index ffc30f92ee2..2220e52c899 100644 --- a/packages/plugin-vite/tests/dev_server_test.ts +++ b/packages/plugin-vite/tests/dev_server_test.ts @@ -69,6 +69,23 @@ integrationTest("vite dev - starts without islands/ dir", async () => { }); }); +// Issue: https://github.com/denoland/fresh/issues/3806 +// Pages without islands must still load the client entry in dev so the +// HMR `fresh:reload` listener attaches and route edits trigger a refresh. +integrationTest( + "vite dev - injects client entry on islands-free pages for HMR", + async () => { + const fixture = path.join(FIXTURE_DIR, "no_islands"); + await withDevServer(fixture, async (address) => { + const res = await fetch(`${address}/`); + const text = await res.text(); + expect(text).toContain("ok"); + expect(text).toContain("/@id/fresh:client-entry"); + expect(text).toMatch(/import\s*\{\s*boot\s*\}/); + }); + }, +); + integrationTest("vite dev - starts without routes/ dir", async () => { const fixture = path.join(FIXTURE_DIR, "no_routes"); await withDevServer(fixture, async (address) => { From 494cda76e89aa034fd043e4c105f219008660ee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Wed, 27 May 2026 07:47:59 +0200 Subject: [PATCH 2/2] address review: clarify hmrClientEntry is a presence marker, not a path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reword JSDoc and inline comment to make it explicit that only the presence of hmrClientEntry matters — the value itself is not read by any consumer, so assigning clientEntry to it is intentional marker semantics, not a real path reference. --- packages/fresh/src/build_cache.ts | 6 +++--- packages/plugin-vite/src/plugins/server_snapshot.ts | 11 ++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/fresh/src/build_cache.ts b/packages/fresh/src/build_cache.ts index 709976901fb..f94e3450571 100644 --- a/packages/fresh/src/build_cache.ts +++ b/packages/fresh/src/build_cache.ts @@ -18,9 +18,9 @@ export interface BuildSnapshot { version: string; clientEntry: string; /** - * Pathname for an HMR-only client entry. When defined, the SSR runtime - * always emits the boot script so HMR listeners attach to pages that - * have no islands. Undefined outside of dev. + * When defined, forces the boot script to be emitted in dev so HMR + * listeners attach even on island-free pages. The value itself is + * currently unused — only its presence matters. Undefined outside of dev. */ hmrClientEntry?: string; fsRoutes: FsRouteFile[]; diff --git a/packages/plugin-vite/src/plugins/server_snapshot.ts b/packages/plugin-vite/src/plugins/server_snapshot.ts index 6b1117092e2..52fc75391c5 100644 --- a/packages/plugin-vite/src/plugins/server_snapshot.ts +++ b/packages/plugin-vite/src/plugins/server_snapshot.ts @@ -170,11 +170,12 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] { const entryAssets: string[] = []; if (isDev && server !== undefined) { - // The client entry hosts the HMR listener. Set hmrClientEntry so - // the SSR runtime always emits a boot script in dev, even when - // a page has zero islands. Without this, edits to islands-free - // routes never trigger a browser reload because the - // `fresh:reload` WebSocket listener is never attached. + // Set hmrClientEntry so the SSR runtime always emits a boot script + // in dev, even when a page has zero islands. Without this, edits + // to island-free routes never trigger a browser reload because the + // `fresh:reload` WebSocket listener is never attached. The value + // is used as a marker only — its presence is what matters, not + // what it points to. hmrClientEntry = clientEntry; for (const id of islands.keys()) {