diff --git a/.changeset/proud-pandas-reload.md b/.changeset/proud-pandas-reload.md new file mode 100644 index 000000000..7352a1c44 --- /dev/null +++ b/.changeset/proud-pandas-reload.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Reload the browser when `entry-server` or the middleware changes in dev. Both are server-only, so Vite had nothing to send the browser and the page kept showing the previously rendered output until it was refreshed by hand. diff --git a/packages/start/src/config/dev-server.spec.ts b/packages/start/src/config/dev-server.spec.ts index 33aa05af5..d0e0b08fd 100644 --- a/packages/start/src/config/dev-server.spec.ts +++ b/packages/start/src/config/dev-server.spec.ts @@ -1,9 +1,19 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { + createServer, + type DevEnvironment, + type EnvironmentModuleNode, + type HotUpdateOptions, + normalizePath, + type Plugin, + type ViteDevServer, +} from "vite"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { isHtmlResponse, resolvePreviewServerEntry } from "./dev-server.ts"; +import { VITE_ENVIRONMENTS } from "./constants.ts"; +import { isHtmlResponse, resolvePreviewServerEntry, serverOnlyReload } from "./dev-server.ts"; const temporaryDirectories: string[] = []; @@ -19,7 +29,10 @@ function createServerEntry(extension: "js" | "mjs") { return { root, serverEntry }; } -afterEach(() => { +const servers: ViteDevServer[] = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => server.close())); for (const directory of temporaryDirectories.splice(0)) { rmSync(directory, { recursive: true }); } @@ -42,6 +55,81 @@ describe("resolvePreviewServerEntry", () => { }); }); +describe("serverOnlyReload", () => { + /** An app whose middleware is configured without a file extension */ + async function createApp() { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "solid-start-server-reload-"))); + temporaryDirectories.push(root); + + mkdirSync(join(root, "src")); + writeFileSync(join(root, "package.json"), '{ "type": "module" }'); + for (const file of ["entry-server.tsx", "middleware.ts", "app.tsx"]) { + writeFileSync(join(root, "src", file), "export default {};"); + } + + const plugin = serverOnlyReload("./src/entry-server.tsx", "./src/middleware") as Plugin; + const server = await createServer({ + configFile: false, + logLevel: "silent", + root, + server: { middlewareMode: true }, + plugins: [plugin], + }); + servers.push(server); + + const send = vi.spyOn(server.environments[VITE_ENVIRONMENTS.client]!.hot, "send"); + const hotUpdate = plugin.hotUpdate as ( + this: { environment: DevEnvironment }, + options: HotUpdateOptions, + ) => Promise; + + /** Stands in for Vite calling the hook when a watched file changes */ + const change = ( + file: string, + { environment = VITE_ENVIRONMENTS.server, inGraph = true } = {}, + ) => + hotUpdate.call( + { environment: server.environments[environment]! }, + { + type: "update", + file: normalizePath(join(root, file)), + timestamp: Date.now(), + modules: inGraph ? [{} as EnvironmentModuleNode] : [], + read: async () => "", + server, + }, + ); + + return { change, send }; + } + + it.each(["src/entry-server.tsx", "src/middleware.ts"])( + "reloads the browser when %s changes", + async file => { + const { change, send } = await createApp(); + + await change(file); + + await vi.waitFor(() => expect(send).toHaveBeenCalledWith({ type: "full-reload" })); + }, + ); + + it("leaves every other change to Vite's own HMR", async () => { + const { change, send } = await createApp(); + + // Also rendered on the client, so the client environment updates it + await change("src/app.tsx"); + // Already handled by the environment the change was reported for + await change("src/entry-server.tsx", { environment: VITE_ENVIRONMENTS.client }); + // Nothing was rendered from it, so there is nothing to see + await change("src/entry-server.tsx", { inGraph: false }); + + // Longer than the debounce the reload is coalesced with + await new Promise(resolve => setTimeout(resolve, 200)); + expect(send).not.toHaveBeenCalled(); + }); +}); + describe("isHtmlResponse", () => { it("recognizes HTML responses with content type parameters", () => { const response = new Response(null, { diff --git a/packages/start/src/config/dev-server.ts b/packages/start/src/config/dev-server.ts index 3a5ac418a..b434ac6bd 100644 --- a/packages/start/src/config/dev-server.ts +++ b/packages/start/src/config/dev-server.ts @@ -1,17 +1,22 @@ import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { NodeRequest, sendNodeResponse } from "srvx/node"; import { type Connect, + type DevEnvironment, isRunnableDevEnvironment, + normalizePath, type PluginOption, type ViteDevServer, } from "vite"; +import { debounce } from "../utils/debounce.ts"; import { VITE_ENVIRONMENTS } from "./constants.ts"; +import { parseIdQuery } from "./utils.ts"; -export function devServer(serverEntryPath: string): Array { +export function devServer(serverEntryPath: string, middlewarePath?: string): Array { return [ + serverOnlyReload(serverEntryPath, middlewarePath), { name: "solid-start-dev-server", configurePreviewServer(server) { @@ -125,6 +130,72 @@ export function devServer(serverEntryPath: string): Array { ]; } +/** + * Reloads the browser when the server entry or the middleware changes. + * + * Both are server-only, so they have no module in the client graph and Vite has + * nothing to send the browser when they change. The server picks the edit up on + * the next request, but the page the developer is looking at keeps showing the + * output rendered from the previous version until it is refreshed by hand. + * + * Only these two entries are handled: modules that also exist on the client + * (routes, components, server functions) are left to Vite, since reloading on + * top of a client HMR update would needlessly throw away the page's state. + * + * @see https://github.com/solidjs/solid-start/issues/1611 + */ +export function serverOnlyReload(serverEntryPath: string, middlewarePath?: string): PluginOption { + /** + * Resolved the same way the runtime resolves them, so that an extensionless + * or aliased middleware path still matches. Both are stable for the lifetime + * of the server, so this is only done once. + */ + let serverOnlyFiles: Promise> | undefined; + + /** Coalesces the reload when a save touches both entries at once */ + const reload = debounce((server: ViteDevServer) => { + server.environments[VITE_ENVIRONMENTS.client]?.hot.send({ type: "full-reload" }); + }, 50); + + return { + name: "solid-start-server-only-reload", + async hotUpdate({ file, modules, server }) { + if (this.environment.name !== VITE_ENVIRONMENTS.server) return; + // Nothing was server rendered from this file + if (modules.length === 0) return; + + serverOnlyFiles ??= resolveFiles(this.environment, server.config.root, [ + serverEntryPath, + middlewarePath, + ]); + + if ((await serverOnlyFiles).has(file)) reload(server); + }, + }; +} + +async function resolveFiles( + environment: DevEnvironment, + root: string, + paths: Array, +): Promise> { + const files = new Set(); + + for (const path of paths) { + if (!path) continue; + + let resolved: string | undefined; + try { + resolved = (await environment.pluginContainer.resolveId(path))?.id; + } catch { + // Fall back to the path as written, e.g. when a plugin throws on resolve + } + files.add(normalizePath(parseIdQuery(resolved ?? resolve(root, path)).filename)); + } + + return files; +} + export function resolvePreviewServerEntry(root: string): string { const serverDirectory = join(root, "dist/server"); const serverEntry = ["js", "mjs"] diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 73527bf8d..42e47743f 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -380,7 +380,7 @@ export function solidStart(options?: SolidStartOptions): Array { (globalThis as any).START_CLIENT_OUT_DIR = options.dir; }, }, - devServer(handlers.server), + devServer(handlers.server, start.middleware), solid({ ...start.solid, ssr: true,