diff --git a/packages/plugin-vite/src/plugins/server_snapshot.ts b/packages/plugin-vite/src/plugins/server_snapshot.ts index cf37c0452dd..96926aad0ac 100644 --- a/packages/plugin-vite/src/plugins/server_snapshot.ts +++ b/packages/plugin-vite/src/plugins/server_snapshot.ts @@ -98,28 +98,37 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] { } } + const relRoutes = path.relative(options.routeDir, filePath); + const isRouteFile = !relRoutes.startsWith("..") && + !path.isAbsolute(relRoutes) && + !/[\\/]+\(_[^)]+\)[\\/]+/.test(filePath); + // Check for route files. We need to invalidate the snapshot if // they are removed or added. - if ( - (ev === "add" || ev === "unlink") && - !/[\\/]+\(_[^)]+\)[\\/]+/.test(filePath) - ) { - const relRoutes = path.relative(options.routeDir, filePath); - if (!relRoutes.startsWith("..")) { - const mod = ssr.moduleGraph.getModuleById(`\0${modName}`); - if (mod !== undefined) { - // Clear state - islands.clear(); - islandsByFile.clear(); - islandSpecByName.clear(); - - ssr.moduleGraph.invalidateModule(mod); - } + if ((ev === "add" || ev === "unlink") && isRouteFile) { + const mod = ssr.moduleGraph.getModuleById(`\0${modName}`); + if (mod !== undefined) { + // Clear state + islands.clear(); + islandsByFile.clear(); + islandSpecByName.clear(); + + ssr.moduleGraph.invalidateModule(mod); } } - // Finally, notify the client - viteServer.ws.send("fresh:reload"); + // Only notify the client when the changed file is actually + // relevant to SSR — either a route file, or something already + // tracked in the SSR module graph. Vite attaches us directly to + // its chokidar instance, so `server.watch.ignored` does not + // filter events before we see them. Without this gate, any + // unrelated write (e.g. Deno KV `main-shm`/`main-wal` journals + // or editor temp files on Windows) would trigger a full reload. + // See https://github.com/denoland/fresh/issues/3637. + const ssrMods = ssr.moduleGraph.getModulesByFile(filePath); + if (isRouteFile || (ssrMods !== undefined && ssrMods.size > 0)) { + viteServer.ws.send("fresh:reload"); + } }); }, resolveId: { diff --git a/packages/plugin-vite/tests/dev_server_test.ts b/packages/plugin-vite/tests/dev_server_test.ts index ffc30f92ee2..239b9872b24 100644 --- a/packages/plugin-vite/tests/dev_server_test.ts +++ b/packages/plugin-vite/tests/dev_server_test.ts @@ -471,6 +471,77 @@ integrationTest("vite dev - source mapped stack traces", async () => { expect(text).toContain("throw.tsx:5:11"); }); +// issue: https://github.com/denoland/fresh/issues/3637 +integrationTest( + "vite dev - unrelated file changes do not trigger fresh:reload", + async () => { + const fixture = path.join(FIXTURE_DIR, "no_static"); + await using tmp = await prepareDevServer(fixture); + await using devServer = await spawnDevServer(tmp.dir); + + const httpUrl = devServer.address(); + + // Load the route into the SSR module graph so the sanity check + // below has a known-good baseline to compare against. + const res = await fetch(`${httpUrl}/`); + await res.body?.cancel(); + + const wsUrl = httpUrl.replace(/^http/, "ws"); + const ws = new WebSocket(wsUrl, "vite-hmr"); + const messages: string[] = []; + ws.onmessage = (ev) => { + if (typeof ev.data === "string") messages.push(ev.data); + }; + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = (err) => reject(new Error(String(err))); + }); + + try { + // Mimic the kind of file Deno KV or an editor would write + // alongside the project (this is the original Windows repro + // from the issue). It is not in routes/ and is not imported + // by anything in the SSR module graph. + const stray = path.join(tmp.dir, "main-shm"); + await Deno.writeTextFile(stray, "garbage"); + try { + await new Promise((r) => setTimeout(r, 1500)); + const reloads = messages.filter((m) => m.includes("fresh:reload")); + expect(reloads).toEqual([]); + } finally { + await Deno.remove(stray).catch(() => {}); + } + + // Sanity check: editing a route file (which is in the SSR module + // graph after the fetch above) must still trigger fresh:reload — + // i.e. the gating did not throw the baby out with the bathwater. + messages.length = 0; + const routeFile = path.join(tmp.dir, "routes", "index.tsx"); + const original = await Deno.readTextFile(routeFile); + try { + await Deno.writeTextFile( + routeFile, + original.replace("

ok

", "

ok 2

"), + ); + + const start = Date.now(); + while ( + Date.now() - start < 5000 && + !messages.some((m) => m.includes("fresh:reload")) + ) { + await new Promise((r) => setTimeout(r, 50)); + } + + expect(messages.some((m) => m.includes("fresh:reload"))).toBe(true); + } finally { + await Deno.writeTextFile(routeFile, original); + } + } finally { + ws.close(); + } + }, +); + integrationTest("vite dev - client side ", async () => { await withBrowser(async (page) => { await page.goto(`${demoServer.address()}/tests/head_counter`, {