Skip to content

Commit 1daa177

Browse files
schiller-manuelpi0
andauthored
fix(vite): don't send browser full-reload for ssr-only changes (#4034)
Co-authored-by: Pooya Parsa <pooya@pi0.io>
1 parent 4df7aab commit 1daa177

9 files changed

Lines changed: 216 additions & 11 deletions

File tree

src/build/vite/plugin.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -238,31 +238,36 @@ 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 }) {
244+
if (ctx.pluginConfig.experimental?.vite?.serverReload === false) {
245+
return;
246+
}
244247
const env = this.environment;
245-
if (
246-
ctx.pluginConfig.experimental?.vite.serverReload === false ||
247-
env.config.consumer === "client"
248-
) {
248+
if (env.config.consumer === "client") {
249249
return;
250250
}
251251
const clientEnvs = Object.values(server.environments).filter(
252252
(env) => env.config.consumer === "client"
253253
);
254-
let hasServerOnlyModule = false;
254+
const serverOnlyModules: EnvironmentModuleNode[] = [];
255+
const sharedModules: EnvironmentModuleNode[] = [];
255256
const invalidated = new Set<EnvironmentModuleNode>();
256257
for (const mod of modules) {
257258
if (mod.id && !clientEnvs.some((env) => env.moduleGraph.getModuleById(mod.id!))) {
258-
hasServerOnlyModule = true;
259+
serverOnlyModules.push(mod);
259260
env.moduleGraph.invalidateModule(mod, invalidated, timestamp, false);
261+
} else {
262+
sharedModules.push(mod);
260263
}
261264
}
262-
if (hasServerOnlyModule) {
265+
if (serverOnlyModules.length > 0) {
263266
env.hot.send({ type: "full-reload" });
264-
server.ws.send({ type: "full-reload" });
265-
return [];
267+
if (sharedModules.length === 0 && serverOnlyModules.some((m) => m.environment !== "ssr")) {
268+
server.ws.send({ type: "full-reload" });
269+
}
270+
return sharedModules;
266271
}
267272
},
268273
};

src/build/vite/types.ts

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

3232
/**
33-
* Reload the page when a server module is updated.
33+
*
34+
* Invalidate server-only modules and optionally reload the browser when a server-only module is updated.
3435
*
3536
* @default true
3637
*/

test/vite/hmr-fixture/api/state.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { state } from "../shared.ts";
2+
3+
export default () => ({ state });
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { state } from "../shared.ts";
2+
3+
document.getElementById("client-state-value")!.textContent = state + " (modified)";
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { html } from "nitro/h3";
2+
import { serverFetch } from "nitro";
3+
import { state } from "../shared.ts";
4+
5+
export default {
6+
fetch: async () => {
7+
const apiData = (await serverFetch("/api/state").then((res) => res.json())) as {
8+
state: number;
9+
};
10+
const viteClientScript = "<script type='module' src='/@vite/client'></script>";
11+
const clientScript = "<script type='module' src='/app/entry-client.ts'></script>";
12+
return html`
13+
<!doctype html>
14+
<html lang="en">
15+
<head>${viteClientScript}</head>
16+
<body>
17+
<h1>SSR Page</h1>
18+
<p>[SSR] state: ${state}</p>
19+
<p>[API] state: ${apiData.state}</p>
20+
<p id="client-state">[Client] state: <span id="client-state-value">?</span></p>
21+
${clientScript}
22+
</body>
23+
</html>
24+
`;
25+
},
26+
};

test/vite/hmr-fixture/shared.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const state = 1;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "nitro/tsconfig"
3+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defineConfig } from "vite";
2+
import { nitro } from "nitro/vite";
3+
4+
export default defineConfig({
5+
plugins: [nitro({ serverDir: "./" })],
6+
});

test/vite/hmr.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { join } from "pathe";
2+
import { readFileSync, writeFileSync } from "node:fs";
3+
import { fileURLToPath } from "node:url";
4+
import type { ViteDevServer } from "vite";
5+
import { describe, test, expect, beforeAll, afterEach, afterAll } from "vitest";
6+
7+
const { createServer } = (await import(
8+
process.env.NITRO_VITE_PKG || "vite"
9+
)) as typeof import("vite");
10+
11+
describe("vite:hmr", { sequential: true }, () => {
12+
let server: ViteDevServer;
13+
let serverURL: string;
14+
const wsMessages: any[] = [];
15+
16+
const rootDir = fileURLToPath(new URL("./hmr-fixture", import.meta.url));
17+
18+
const files = {
19+
client: openFileForEditing(join(rootDir, "app/entry-client.ts")),
20+
api: openFileForEditing(join(rootDir, "api/state.ts")),
21+
shared: openFileForEditing(join(rootDir, "shared.ts")),
22+
ssr: openFileForEditing(join(rootDir, "app/entry-server.ts")),
23+
};
24+
25+
beforeAll(async () => {
26+
process.chdir(rootDir);
27+
server = await createServer({ root: rootDir });
28+
29+
const originalSend = server.ws.send.bind(server.ws);
30+
server.ws.send = function (payload: any) {
31+
wsMessages.push(payload);
32+
return originalSend(payload);
33+
};
34+
35+
await server.listen("0" as unknown as number);
36+
const addr = server.httpServer?.address() as { port: number; address: string; family: string };
37+
serverURL = `http://${addr.family === "IPv6" ? `[${addr.address}]` : addr.address}:${addr.port}`;
38+
39+
const html = await fetch(serverURL).then((r) => r.text());
40+
expect(html).toContain("<h1>SSR Page</h1>");
41+
expect(html).toContain("[SSR] state: 1");
42+
expect(html).toContain("[API] state: 1");
43+
}, 30_000);
44+
45+
afterAll(async () => {
46+
await server?.close();
47+
});
48+
49+
afterEach(async () => {
50+
wsMessages.length = 0;
51+
let restored = false;
52+
for (const file of Object.values(files)) {
53+
if (file.restore()) {
54+
restored = true;
55+
}
56+
}
57+
if (restored) {
58+
await waitFor(() => wsMessages.length > 0, 500);
59+
}
60+
wsMessages.length = 0;
61+
});
62+
63+
test("editing API entry", async () => {
64+
files.api.update((content) =>
65+
content.replace("({ state })", '({ state: state + " (modified)" })')
66+
);
67+
await pollResponse(`${serverURL}/api/state`, /modified/);
68+
expect(wsMessages).toMatchObject([{ type: "full-reload" }]);
69+
});
70+
71+
test("Editing client entry (no full-reload)", async () => {
72+
files.client.update((content) => content.replace(`+ ""`, `+ " (modified)"`));
73+
await pollResponse(`${serverURL}/app/entry-client.ts`, /modified/);
74+
expect(wsMessages.length).toBe(0);
75+
});
76+
77+
test("editing SSR entry (no full-reload)", async () => {
78+
files.ssr.update((content) =>
79+
content.replace("<h1>SSR Page</h1>", "<h1>Modified SSR Page</h1>")
80+
);
81+
await pollResponse(serverURL, /Modified SSR Page/);
82+
expect(wsMessages.length).toBe(0);
83+
});
84+
85+
test("Editing shared entry", async () => {
86+
files.shared.update((content) => content.replace(`state = 1`, `state = 2`));
87+
await pollResponse(
88+
`${serverURL}`,
89+
(txt) => txt.includes("state: 2") && !txt.includes("state: 1")
90+
);
91+
expect(wsMessages).toMatchObject([{ type: "full-reload" }]);
92+
});
93+
});
94+
95+
function openFileForEditing(path: string) {
96+
const originalContent = readFileSync(path, "utf-8");
97+
return {
98+
path,
99+
update(cb: (content: string) => string) {
100+
const currentContent = readFileSync(path, "utf-8");
101+
const newContent = cb(currentContent);
102+
writeFileSync(path, newContent);
103+
},
104+
restore() {
105+
if (readFileSync(path, "utf-8") !== originalContent) {
106+
writeFileSync(path, originalContent);
107+
return true;
108+
}
109+
return false;
110+
},
111+
};
112+
}
113+
114+
function waitFor(check: () => boolean, duration: number): Promise<void> {
115+
const start = Date.now();
116+
return new Promise((resolve) => {
117+
const poll = () => {
118+
if (check() || Date.now() - start > duration) {
119+
resolve();
120+
} else {
121+
setTimeout(poll, 10);
122+
}
123+
};
124+
poll();
125+
});
126+
}
127+
128+
function pollResponse(
129+
url: string,
130+
match: RegExp | ((txt: string) => boolean),
131+
timeout = 5000
132+
): Promise<string> {
133+
const start = Date.now();
134+
let lastResponse = "";
135+
return new Promise((resolve, reject) => {
136+
const check = async () => {
137+
try {
138+
const response = await fetch(url);
139+
lastResponse = await response.text();
140+
if (typeof match === "function" ? match(lastResponse) : match.test(lastResponse)) {
141+
resolve(lastResponse);
142+
} else if (Date.now() - start > timeout) {
143+
reject(
144+
new Error(
145+
`Timeout waiting for response to match ${match} at ${url}. Last response: ${lastResponse}`
146+
)
147+
);
148+
} else {
149+
setTimeout(check, 100);
150+
}
151+
} catch (err) {
152+
reject(err);
153+
}
154+
};
155+
check();
156+
});
157+
}

0 commit comments

Comments
 (0)