Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions src/build/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,31 +238,36 @@ function nitroMain(ctx: NitroPluginContext): VitePlugin {
return configureViteDevServer(ctx, server);
},

// Automatically reload the client when a server module is updated
// Invalidate server-only modules and optionally reload the browser
// see: https://github.com/vitejs/vite/issues/19114
async hotUpdate({ server, modules, timestamp }) {
if (ctx.pluginConfig.experimental?.vite?.serverReload === false) {
return;
}
const env = this.environment;
if (
ctx.pluginConfig.experimental?.vite.serverReload === false ||
env.config.consumer === "client"
) {
if (env.config.consumer === "client") {
return;
}
const clientEnvs = Object.values(server.environments).filter(
(env) => env.config.consumer === "client"
);
let hasServerOnlyModule = false;
const serverOnlyModules: EnvironmentModuleNode[] = [];
const sharedModules: EnvironmentModuleNode[] = [];
const invalidated = new Set<EnvironmentModuleNode>();
for (const mod of modules) {
if (mod.id && !clientEnvs.some((env) => env.moduleGraph.getModuleById(mod.id!))) {
hasServerOnlyModule = true;
serverOnlyModules.push(mod);
env.moduleGraph.invalidateModule(mod, invalidated, timestamp, false);
} else {
sharedModules.push(mod);
}
}
if (hasServerOnlyModule) {
if (serverOnlyModules.length > 0) {
env.hot.send({ type: "full-reload" });
server.ws.send({ type: "full-reload" });
return [];
if (sharedModules.length === 0 && serverOnlyModules.some((m) => m.environment !== "ssr")) {
server.ws.send({ type: "full-reload" });
}
return sharedModules;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
};
Expand Down
3 changes: 2 additions & 1 deletion src/build/vite/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ export interface NitroPluginConfig extends NitroConfig {
assetsImport?: boolean;

/**
* Reload the page when a server module is updated.
*
* Invalidate server-only modules and optionally reload the browser when a server-only module is updated.
*
* @default true
*/
Expand Down
3 changes: 3 additions & 0 deletions test/vite/hmr-fixture/api/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { state } from "../shared.ts";

export default () => ({ state });
3 changes: 3 additions & 0 deletions test/vite/hmr-fixture/app/entry-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { state } from "../shared.ts";

document.getElementById("client-state-value")!.textContent = state + " (modified)";
26 changes: 26 additions & 0 deletions test/vite/hmr-fixture/app/entry-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { html } from "nitro/h3";
import { serverFetch } from "nitro";
import { state } from "../shared.ts";

export default {
fetch: async () => {
const apiData = (await serverFetch("/api/state").then((res) => res.json())) as {
state: number;
};
const viteClientScript = "<script type='module' src='/@vite/client'></script>";
const clientScript = "<script type='module' src='/app/entry-client.ts'></script>";
return html`
<!doctype html>
<html lang="en">
<head>${viteClientScript}</head>
<body>
<h1>SSR Page</h1>
<p>[SSR] state: ${state}</p>
<p>[API] state: ${apiData.state}</p>
<p id="client-state">[Client] state: <span id="client-state-value">?</span></p>
${clientScript}
</body>
</html>
`;
},
};
1 change: 1 addition & 0 deletions test/vite/hmr-fixture/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const state = 1;
3 changes: 3 additions & 0 deletions test/vite/hmr-fixture/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "nitro/tsconfig"
}
6 changes: 6 additions & 0 deletions test/vite/hmr-fixture/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import { nitro } from "nitro/vite";

export default defineConfig({
plugins: [nitro({ serverDir: "./" })],
});
157 changes: 157 additions & 0 deletions test/vite/hmr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { join } from "pathe";
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { ViteDevServer } from "vite";
import { describe, test, expect, beforeAll, afterEach, afterAll } from "vitest";

const { createServer } = (await import(
process.env.NITRO_VITE_PKG || "vite"
)) as typeof import("vite");

describe("vite:hmr", { sequential: true }, () => {
let server: ViteDevServer;
let serverURL: string;
const wsMessages: any[] = [];

const rootDir = fileURLToPath(new URL("./hmr-fixture", import.meta.url));

const files = {
client: openFileForEditing(join(rootDir, "app/entry-client.ts")),
api: openFileForEditing(join(rootDir, "api/state.ts")),
shared: openFileForEditing(join(rootDir, "shared.ts")),
ssr: openFileForEditing(join(rootDir, "app/entry-server.ts")),
};

beforeAll(async () => {
process.chdir(rootDir);
server = await createServer({ root: rootDir });

const originalSend = server.ws.send.bind(server.ws);
server.ws.send = function (payload: any) {
wsMessages.push(payload);
return originalSend(payload);
};

await server.listen("0" as unknown as number);
const addr = server.httpServer?.address() as { port: number; address: string; family: string };
serverURL = `http://${addr.family === "IPv6" ? `[${addr.address}]` : addr.address}:${addr.port}`;

const html = await fetch(serverURL).then((r) => r.text());
expect(html).toContain("<h1>SSR Page</h1>");
expect(html).toContain("[SSR] state: 1");
expect(html).toContain("[API] state: 1");
}, 30_000);

afterAll(async () => {
await server?.close();
});

afterEach(async () => {
wsMessages.length = 0;
let restored = false;
for (const file of Object.values(files)) {
if (file.restore()) {
restored = true;
}
}
if (restored) {
await waitFor(() => wsMessages.length > 0, 500);
}
wsMessages.length = 0;
});

test("editing API entry", async () => {
files.api.update((content) =>
content.replace("({ state })", '({ state: state + " (modified)" })')
);
await pollResponse(`${serverURL}/api/state`, /modified/);
expect(wsMessages).toMatchObject([{ type: "full-reload" }]);
});

test("Editing client entry (no full-reload)", async () => {
files.client.update((content) => content.replace(`+ ""`, `+ " (modified)"`));
await pollResponse(`${serverURL}/app/entry-client.ts`, /modified/);
expect(wsMessages.length).toBe(0);
});
Comment thread
pi0 marked this conversation as resolved.

test("editing SSR entry (no full-reload)", async () => {
files.ssr.update((content) =>
content.replace("<h1>SSR Page</h1>", "<h1>Modified SSR Page</h1>")
);
await pollResponse(serverURL, /Modified SSR Page/);
expect(wsMessages.length).toBe(0);
});

test("Editing shared entry", async () => {
files.shared.update((content) => content.replace(`state = 1`, `state = 2`));
await pollResponse(
`${serverURL}`,
(txt) => txt.includes("state: 2") && !txt.includes("state: 1")
);
expect(wsMessages).toMatchObject([{ type: "full-reload" }]);
});
});

function openFileForEditing(path: string) {
const originalContent = readFileSync(path, "utf-8");
return {
path,
update(cb: (content: string) => string) {
const currentContent = readFileSync(path, "utf-8");
const newContent = cb(currentContent);
writeFileSync(path, newContent);
},
restore() {
if (readFileSync(path, "utf-8") !== originalContent) {
writeFileSync(path, originalContent);
return true;
}
return false;
},
};
}

function waitFor(check: () => boolean, duration: number): Promise<void> {
const start = Date.now();
return new Promise((resolve) => {
const poll = () => {
if (check() || Date.now() - start > duration) {
resolve();
} else {
setTimeout(poll, 10);
}
};
poll();
});
}

function pollResponse(
url: string,
match: RegExp | ((txt: string) => boolean),
timeout = 5000
): Promise<string> {
const start = Date.now();
let lastResponse = "";
return new Promise((resolve, reject) => {
const check = async () => {
try {
const response = await fetch(url);
lastResponse = await response.text();
if (typeof match === "function" ? match(lastResponse) : match.test(lastResponse)) {
resolve(lastResponse);
} else if (Date.now() - start > timeout) {
reject(
new Error(
`Timeout waiting for response to match ${match} at ${url}. Last response: ${lastResponse}`
)
);
} else {
setTimeout(check, 100);
}
} catch (err) {
reject(err);
}
};
check();
});
}
Loading