Skip to content

Commit 55086b2

Browse files
fix: don't send browser full-reload for server-only modules by default
hotUpdate hook now separates server-only vs shared modules. Browser full-reload only sent when serverReload option is true (default false). Shared modules continue through normal HMR pipeline. Adds e2e tests for both default (no reload) and serverReload:true behaviors.
1 parent f663e76 commit 55086b2

4 files changed

Lines changed: 194 additions & 12 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { defineConfig } from "vite";
2+
import { nitro } from "nitro/vite";
3+
import react from "@vitejs/plugin-react";
4+
5+
export default defineConfig({
6+
plugins: [nitro({ experimental: { vite: { serverReload: true } } }), react()],
7+
environments: {
8+
client: {
9+
build: { rollupOptions: { input: "./src/entry-client.tsx" } },
10+
},
11+
},
12+
});

src/build/vite/plugin.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -238,31 +238,33 @@ function nitroMain(ctx: NitroPluginContext): VitePlugin {
238238
return configureViteDevServer(ctx, server);
239239
},
240240

241-
// Automatically reload the client when a server module is updated
241+
// Invalidate server-only modules and optionally reload the browser
242242
// see: https://github.com/vitejs/vite/issues/19114
243243
async hotUpdate({ server, modules, timestamp }) {
244244
const env = this.environment;
245-
if (
246-
ctx.pluginConfig.experimental?.vite.serverReload === false ||
247-
env.config.consumer === "client"
248-
) {
245+
if (env.config.consumer === "client") {
249246
return;
250247
}
251248
const clientEnvs = Object.values(server.environments).filter(
252249
(env) => env.config.consumer === "client"
253250
);
254-
let hasServerOnlyModule = false;
251+
const serverOnlyModules: EnvironmentModuleNode[] = [];
252+
const sharedModules: EnvironmentModuleNode[] = [];
255253
const invalidated = new Set<EnvironmentModuleNode>();
256254
for (const mod of modules) {
257255
if (mod.id && !clientEnvs.some((env) => env.moduleGraph.getModuleById(mod.id!))) {
258-
hasServerOnlyModule = true;
256+
serverOnlyModules.push(mod);
259257
env.moduleGraph.invalidateModule(mod, invalidated, timestamp, false);
258+
} else {
259+
sharedModules.push(mod);
260260
}
261261
}
262-
if (hasServerOnlyModule) {
262+
if (serverOnlyModules.length > 0) {
263263
env.hot.send({ type: "full-reload" });
264-
server.ws.send({ type: "full-reload" });
265-
return [];
264+
if (sharedModules.length === 0 && ctx.pluginConfig.experimental?.vite.serverReload) {
265+
server.ws.send({ type: "full-reload" });
266+
}
267+
return sharedModules;
266268
}
267269
},
268270
};

src/build/vite/types.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,14 @@ export interface NitroPluginConfig extends NitroConfig {
3030
assetsImport?: boolean;
3131

3232
/**
33-
* Reload the page when a server module is updated.
33+
* Reload the browser page when a server-only module is updated.
3434
*
35-
* @default true
35+
* When disabled, server-only module changes will still invalidate the
36+
* server runtime but won't trigger a browser full-reload. This prevents
37+
* destroying client-side HMR updates when a file produces both client
38+
* and server-only modules.
39+
*
40+
* @default false
3641
*/
3742
serverReload?: boolean;
3843

test/hot-update.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { join } from "node:path";
2+
import { readFileSync, writeFileSync } from "node:fs";
3+
import { fileURLToPath } from "node:url";
4+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
5+
import type { ViteDevServer } from "vite";
6+
7+
const { createServer } = (await import(
8+
process.env.NITRO_VITE_PKG || "vite"
9+
)) as typeof import("vite");
10+
11+
const rootDir = fileURLToPath(new URL("../examples/vite-ssr-react", import.meta.url));
12+
13+
function getBaseURL(server: ViteDevServer): string {
14+
const addr = server.httpServer?.address() as {
15+
port: number;
16+
address: string;
17+
family: string;
18+
};
19+
return `http://${addr.family === "IPv6" ? `[${addr.address}]` : addr.address}:${addr.port}`;
20+
}
21+
22+
/**
23+
* Intercept messages sent via server.ws.send and collect them for a duration.
24+
*/
25+
function collectWsMessages(server: ViteDevServer, duration: number): Promise<any[]> {
26+
return new Promise((resolve) => {
27+
const messages: any[] = [];
28+
const origSend = server.ws.send.bind(server.ws);
29+
server.ws.send = function (payload: any) {
30+
messages.push(payload);
31+
return origSend(payload);
32+
};
33+
setTimeout(() => {
34+
server.ws.send = origSend;
35+
resolve(messages);
36+
}, duration);
37+
});
38+
}
39+
40+
describe("vite hotUpdate", () => {
41+
let server: ViteDevServer;
42+
const appFile = join(rootDir, "src/app.tsx");
43+
const serverFile = join(rootDir, "src/entry-server.tsx");
44+
let originalApp: string;
45+
let originalServer: string;
46+
47+
beforeAll(async () => {
48+
originalApp = readFileSync(appFile, "utf-8");
49+
originalServer = readFileSync(serverFile, "utf-8");
50+
51+
process.chdir(rootDir);
52+
server = await createServer({ root: rootDir });
53+
await server.listen("0" as unknown as number);
54+
55+
// Fetch the page so the SSR environment loads all modules
56+
await fetch(getBaseURL(server) + "/");
57+
await new Promise((r) => setTimeout(r, 1000));
58+
}, 30_000);
59+
60+
afterAll(async () => {
61+
writeFileSync(appFile, originalApp);
62+
writeFileSync(serverFile, originalServer);
63+
await server?.close();
64+
});
65+
66+
test("editing shared module does not trigger browser full-reload", async () => {
67+
const collecting = collectWsMessages(server, 2000);
68+
69+
const modified = originalApp.replace("Nitro + Vite + React", "Nitro + Vite + React EDITED");
70+
writeFileSync(appFile, modified);
71+
72+
const messages = await collecting;
73+
74+
const fullReloads = messages.filter((m) => typeof m === "object" && m?.type === "full-reload");
75+
expect(
76+
fullReloads,
77+
"browser should not receive full-reload for shared module edits"
78+
).toHaveLength(0);
79+
80+
// Server should render updated content on next request
81+
const html = await fetch(getBaseURL(server) + "/").then((r) => r.text());
82+
expect(html).toContain("Nitro + Vite + React EDITED");
83+
84+
// Restore
85+
writeFileSync(appFile, originalApp);
86+
await new Promise((r) => setTimeout(r, 500));
87+
});
88+
89+
test("editing server-only module does not trigger browser full-reload by default", async () => {
90+
const collecting = collectWsMessages(server, 2000);
91+
92+
const modified = originalServer.replace(
93+
'<meta name="viewport"',
94+
'<meta name="description" content="hot-update-test" />\n <meta name="viewport"'
95+
);
96+
writeFileSync(serverFile, modified);
97+
98+
const messages = await collecting;
99+
100+
const fullReloads = messages.filter((m) => typeof m === "object" && m?.type === "full-reload");
101+
expect(
102+
fullReloads,
103+
"browser should not receive full-reload when serverReload is disabled (default)"
104+
).toHaveLength(0);
105+
106+
// Server should render updated content on next request
107+
const html = await fetch(getBaseURL(server) + "/").then((r) => r.text());
108+
expect(html).toContain('content="hot-update-test"');
109+
110+
// Restore
111+
writeFileSync(serverFile, originalServer);
112+
});
113+
});
114+
115+
describe("vite hotUpdate (serverReload: true)", () => {
116+
let server: ViteDevServer;
117+
const serverFile = join(rootDir, "src/entry-server.tsx");
118+
let originalServer: string;
119+
120+
beforeAll(async () => {
121+
originalServer = readFileSync(serverFile, "utf-8");
122+
123+
process.chdir(rootDir);
124+
server = await createServer({
125+
root: rootDir,
126+
configFile: join(rootDir, "vite.config.server-reload.mjs"),
127+
});
128+
await server.listen("0" as unknown as number);
129+
130+
// Fetch the page so the SSR environment loads entry-server.tsx
131+
await fetch(getBaseURL(server) + "/");
132+
}, 30_000);
133+
134+
afterAll(async () => {
135+
writeFileSync(serverFile, originalServer);
136+
await server?.close();
137+
});
138+
139+
test("editing server-only module triggers browser full-reload when serverReload is enabled", async () => {
140+
const collecting = collectWsMessages(server, 2000);
141+
142+
const modified = originalServer.replace(
143+
'<meta name="viewport"',
144+
'<meta name="description" content="reload-test" />\n <meta name="viewport"'
145+
);
146+
writeFileSync(serverFile, modified);
147+
148+
const messages = await collecting;
149+
150+
const fullReloads = messages.filter((m) => typeof m === "object" && m?.type === "full-reload");
151+
expect(
152+
fullReloads,
153+
"browser should receive full-reload when serverReload is enabled and all modules are server-only"
154+
).not.toHaveLength(0);
155+
156+
// Server should render updated content on next request
157+
const html = await fetch(getBaseURL(server) + "/").then((r) => r.text());
158+
expect(html).toContain('content="reload-test"');
159+
160+
// Restore
161+
writeFileSync(serverFile, originalServer);
162+
});
163+
});

0 commit comments

Comments
 (0)