From 92450e6bb658fe7255df8b7dc1466ee6a26284bf Mon Sep 17 00:00:00 2001 From: Katja Lutz Date: Sat, 4 Jul 2026 17:11:06 +0200 Subject: [PATCH 1/3] fix: debounce fs route hmr events, avoid reload for existing routes --- packages/start/src/config/constants.ts | 4 +- .../start/src/config/fs-routes/fs-watcher.ts | 76 +++++++++---------- packages/start/src/config/fs-routes/router.ts | 19 +++-- packages/start/src/utils/debounce.ts | 10 +++ 4 files changed, 61 insertions(+), 48 deletions(-) create mode 100644 packages/start/src/utils/debounce.ts diff --git a/packages/start/src/config/constants.ts b/packages/start/src/config/constants.ts index 7538d2fd7..93a170582 100644 --- a/packages/start/src/config/constants.ts +++ b/packages/start/src/config/constants.ts @@ -14,6 +14,6 @@ export const VIRTUAL_MODULES = { } as const; export const VITE_ENVIRONMENTS = { - client: "client", - server: "ssr", + client: "client" as const, + server: "ssr" as const, }; diff --git a/packages/start/src/config/fs-routes/fs-watcher.ts b/packages/start/src/config/fs-routes/fs-watcher.ts index 518ff4b8b..9ba55b3e7 100644 --- a/packages/start/src/config/fs-routes/fs-watcher.ts +++ b/packages/start/src/config/fs-routes/fs-watcher.ts @@ -1,22 +1,10 @@ -import type { - EnvironmentModuleNode, - FSWatcher, - PluginOption, - ViteDevServer, -} from "vite"; +import type { EnvironmentModuleNode, FSWatcher, PluginOption, ViteDevServer } from "vite"; +import { debounce } from "../../utils/debounce.ts"; import { VITE_ENVIRONMENTS } from "../constants.ts"; import { moduleId } from "./index.ts"; import type { BaseFileSystemRouter } from "./router.ts"; -interface CompiledRouter { - removeRoute(path: string): void; - addRoute(path: string): void; - updateRoute(path: string): void; - addEventListener(event: "reload", handler: () => void): void; - removeEventListener(event: "reload", handler: () => void): void; -} - -function setupWatcher(watcher: FSWatcher, routes: CompiledRouter): void { +function setupWatcher(watcher: FSWatcher, routes: BaseFileSystemRouter): void { watcher.on("unlink", path => routes.removeRoute(path)); watcher.on("add", path => routes.addRoute(path)); watcher.on("change", path => routes.updateRoute(path)); @@ -24,33 +12,41 @@ function setupWatcher(watcher: FSWatcher, routes: CompiledRouter): void { function createRoutesReloader( server: ViteDevServer, - routes: CompiledRouter, + routes: BaseFileSystemRouter, environment: "client" | "ssr", -): () => void { - routes.addEventListener("reload", handleRoutesReload); - return () => routes.removeEventListener("reload", handleRoutesReload); +) { + const devEnv = server.environments[environment]!; + if (!devEnv?.moduleGraph) return; + + /** + * Debounce catches multiple route changes in a row + * Short timeout for inexpensive invalidations + */ + const invalidateModule = debounce((mod: EnvironmentModuleNode) => { + devEnv.moduleGraph.invalidateModule(mod); + }, 0); - function handleRoutesReload(): void { - const envName = - environment === "ssr" ? VITE_ENVIRONMENTS.server : VITE_ENVIRONMENTS.client; - const devEnv = server.environments[envName]; - if (!devEnv?.moduleGraph) return; + /** + * Long debounce timeout for expensive reloads + */ + const reloadModule = debounce((mod: EnvironmentModuleNode) => { + devEnv.reloadModule(mod); + }, 200); - const mod: EnvironmentModuleNode | undefined = - devEnv.moduleGraph.getModuleById(moduleId); - if (mod) { - const seen = new Set(); - devEnv.moduleGraph.invalidateModule(mod, seen); + return routes.on("reload", function handleRoutesReload(evt): void { + const mod = devEnv.moduleGraph.getModuleById(moduleId)!; + if (!mod) { + devEnv.hot.send({ type: "full-reload" }); + return; } - if (environment !== "ssr") { - if (mod) { - devEnv.reloadModule(mod); - } else if (devEnv.hot) { - devEnv.hot.send({ type: "full-reload" }); - } + if (environment === VITE_ENVIRONMENTS.client && evt.detail.type !== "update") { + // Client has to be reloaded when routes are added or removed + reloadModule(mod); + } else { + invalidateModule(mod); } - } + }); } export const fileSystemWatcher = ( @@ -59,11 +55,11 @@ export const fileSystemWatcher = ( const plugin: PluginOption = { name: "fs-watcher", async configureServer(server: ViteDevServer) { - Object.keys(routers).forEach(environment => { - const router = (globalThis as any).ROUTERS[environment]; + for (const environment of [VITE_ENVIRONMENTS.server, VITE_ENVIRONMENTS.client]) { + const router = routers[environment]; setupWatcher(server.watcher, router); - createRoutesReloader(server, router, environment as keyof typeof routers); - }); + createRoutesReloader(server, router, environment); + } }, }; return plugin; diff --git a/packages/start/src/config/fs-routes/router.ts b/packages/start/src/config/fs-routes/router.ts index 470eb95d5..590fcebd3 100644 --- a/packages/start/src/config/fs-routes/router.ts +++ b/packages/start/src/config/fs-routes/router.ts @@ -32,6 +32,8 @@ export function analyzeModule(src: string) { ); } +type RouterEvent = CustomEvent<{ route: string; type: "update" | "remove" | "add" }>; + export class BaseFileSystemRouter extends EventTarget { routes: Route[]; @@ -109,7 +111,7 @@ export class BaseFileSystemRouter extends EventTarget { const route = this.toRoute(src); if (route) { this._addRoute(route); - this.reload(route.path); + this.reload(route.path, "add"); } } catch (e) { console.error(e); @@ -117,12 +119,12 @@ export class BaseFileSystemRouter extends EventTarget { } } - reload(route: string) { + reload(route: string, type: "update" | "remove" | "add") { this.dispatchEvent( - new Event("reload", { - // @ts-ignore + new CustomEvent("reload", { detail: { route, + type, }, }), ); @@ -135,7 +137,7 @@ export class BaseFileSystemRouter extends EventTarget { const route = this.toRoute(src); if (route) { this._addRoute(route); - this.reload(route.path); + this.reload(route.path, "update"); } } catch (e) { console.error(e); @@ -152,10 +154,15 @@ export class BaseFileSystemRouter extends EventTarget { return; } this.routes = this.routes.filter(r => r.path !== path); - this.dispatchEvent(new Event("reload", {})); + this.reload(path, "remove"); } } + on(type: string, cb: (evt: RouterEvent) => void) { + this.addEventListener(type, cb as any); + return () => this.removeEventListener(type, cb as any); + } + buildRoutesPromise?: Promise; async getRoutes() { diff --git a/packages/start/src/utils/debounce.ts b/packages/start/src/utils/debounce.ts new file mode 100644 index 000000000..09ac249b5 --- /dev/null +++ b/packages/start/src/utils/debounce.ts @@ -0,0 +1,10 @@ +/** + * Creates a debounced function that delays the invocation of a function + */ +export const debounce = void>(cb: T, debounceMs: number) => { + let timeout: NodeJS.Timeout | undefined; + return (...args: Parameters) => { + clearTimeout(timeout); + timeout = setTimeout(() => cb(...args), debounceMs) + } +} From 741c9fd688bab4b950723cbe6f93f4e29b432b84 Mon Sep 17 00:00:00 2001 From: Katja Lutz Date: Sat, 4 Jul 2026 18:53:14 +0200 Subject: [PATCH 2/3] fix: properly hot replace routes missing default export --- packages/start/src/config/fs-routes/router.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/start/src/config/fs-routes/router.ts b/packages/start/src/config/fs-routes/router.ts index 590fcebd3..073f25a43 100644 --- a/packages/start/src/config/fs-routes/router.ts +++ b/packages/start/src/config/fs-routes/router.ts @@ -100,8 +100,11 @@ export class BaseFileSystemRouter extends EventTarget { update = undefined; _addRoute(route: Route) { - this.routes = this.routes.filter(r => r.path !== route.path); + const idx = this.routes.findIndex(r => r.path === route.path); + if (idx >= 0) this.routes.splice(idx, 1); this.routes.push(route); + + return idx >= 0; } async addRoute(src: string) { @@ -130,14 +133,16 @@ export class BaseFileSystemRouter extends EventTarget { ); } - async updateRoute(src: string) { - src = normalizePath(src); + async updateRoute(src_: string) { + const src = normalizePath(src_); if (this.isRoute(src)) { try { const route = this.toRoute(src); if (route) { - this._addRoute(route); - this.reload(route.path, "update"); + const updated = this._addRoute(route); + this.reload(route.path, updated ? "update" : "add"); + } else { + this.removeRoute(src_); } } catch (e) { console.error(e); @@ -153,7 +158,11 @@ export class BaseFileSystemRouter extends EventTarget { if (path === undefined) { return; } - this.routes = this.routes.filter(r => r.path !== path); + + const idx = this.routes.findIndex(r => r.path === path); + if (idx === -1) return; + + this.routes.splice(idx, 1); this.reload(path, "remove"); } } From 3f28b003beb876d1ccfd5d9e74001f51cf32d25d Mon Sep 17 00:00:00 2001 From: Katja Lutz Date: Mon, 6 Jul 2026 16:49:44 +0200 Subject: [PATCH 3/3] chore: add changeset --- .changeset/heavy-bottles-like.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/heavy-bottles-like.md diff --git a/.changeset/heavy-bottles-like.md b/.changeset/heavy-bottles-like.md new file mode 100644 index 000000000..5193854a2 --- /dev/null +++ b/.changeset/heavy-bottles-like.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Fixed changes in route files resulting in a reload instead of hot module replace. Reloads now only are triggered when adding or removing routes.