diff --git a/packages/fresh/src/build_cache.ts b/packages/fresh/src/build_cache.ts index 6a8161a2b77..f94e3450571 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; + /** + * 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[]; 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..52fc75391c5 100644 --- a/packages/plugin-vite/src/plugins/server_snapshot.ts +++ b/packages/plugin-vite/src/plugins/server_snapshot.ts @@ -165,10 +165,19 @@ 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) { + // 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()) { const mod = server.environments.client.moduleGraph.getModuleById( id, @@ -380,6 +389,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) => {