Skip to content

Commit e0e6f6d

Browse files
birkskyumbrenelz
andauthored
fix: reload the browser when the server entry or middleware changes in dev (#2255)
Co-authored-by: Brenley Dueck <brenleydueck@gmail.com>
1 parent c30a825 commit e0e6f6d

4 files changed

Lines changed: 171 additions & 7 deletions

File tree

‎.changeset/proud-pandas-reload.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/start": patch
3+
---
4+
5+
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.

‎packages/start/src/config/dev-server.spec.ts‎

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
4-
import { afterEach, describe, expect, it } from "vitest";
4+
import {
5+
createServer,
6+
type DevEnvironment,
7+
type EnvironmentModuleNode,
8+
type HotUpdateOptions,
9+
normalizePath,
10+
type Plugin,
11+
type ViteDevServer,
12+
} from "vite";
13+
import { afterEach, describe, expect, it, vi } from "vitest";
514

6-
import { isHtmlResponse, resolvePreviewServerEntry } from "./dev-server.ts";
15+
import { VITE_ENVIRONMENTS } from "./constants.ts";
16+
import { isHtmlResponse, resolvePreviewServerEntry, serverOnlyReload } from "./dev-server.ts";
717

818
const temporaryDirectories: string[] = [];
919

@@ -19,7 +29,10 @@ function createServerEntry(extension: "js" | "mjs") {
1929
return { root, serverEntry };
2030
}
2131

22-
afterEach(() => {
32+
const servers: ViteDevServer[] = [];
33+
34+
afterEach(async () => {
35+
await Promise.all(servers.splice(0).map(server => server.close()));
2336
for (const directory of temporaryDirectories.splice(0)) {
2437
rmSync(directory, { recursive: true });
2538
}
@@ -42,6 +55,81 @@ describe("resolvePreviewServerEntry", () => {
4255
});
4356
});
4457

58+
describe("serverOnlyReload", () => {
59+
/** An app whose middleware is configured without a file extension */
60+
async function createApp() {
61+
const root = realpathSync.native(mkdtempSync(join(tmpdir(), "solid-start-server-reload-")));
62+
temporaryDirectories.push(root);
63+
64+
mkdirSync(join(root, "src"));
65+
writeFileSync(join(root, "package.json"), '{ "type": "module" }');
66+
for (const file of ["entry-server.tsx", "middleware.ts", "app.tsx"]) {
67+
writeFileSync(join(root, "src", file), "export default {};");
68+
}
69+
70+
const plugin = serverOnlyReload("./src/entry-server.tsx", "./src/middleware") as Plugin;
71+
const server = await createServer({
72+
configFile: false,
73+
logLevel: "silent",
74+
root,
75+
server: { middlewareMode: true },
76+
plugins: [plugin],
77+
});
78+
servers.push(server);
79+
80+
const send = vi.spyOn(server.environments[VITE_ENVIRONMENTS.client]!.hot, "send");
81+
const hotUpdate = plugin.hotUpdate as (
82+
this: { environment: DevEnvironment },
83+
options: HotUpdateOptions,
84+
) => Promise<void>;
85+
86+
/** Stands in for Vite calling the hook when a watched file changes */
87+
const change = (
88+
file: string,
89+
{ environment = VITE_ENVIRONMENTS.server, inGraph = true } = {},
90+
) =>
91+
hotUpdate.call(
92+
{ environment: server.environments[environment]! },
93+
{
94+
type: "update",
95+
file: normalizePath(join(root, file)),
96+
timestamp: Date.now(),
97+
modules: inGraph ? [{} as EnvironmentModuleNode] : [],
98+
read: async () => "",
99+
server,
100+
},
101+
);
102+
103+
return { change, send };
104+
}
105+
106+
it.each(["src/entry-server.tsx", "src/middleware.ts"])(
107+
"reloads the browser when %s changes",
108+
async file => {
109+
const { change, send } = await createApp();
110+
111+
await change(file);
112+
113+
await vi.waitFor(() => expect(send).toHaveBeenCalledWith({ type: "full-reload" }));
114+
},
115+
);
116+
117+
it("leaves every other change to Vite's own HMR", async () => {
118+
const { change, send } = await createApp();
119+
120+
// Also rendered on the client, so the client environment updates it
121+
await change("src/app.tsx");
122+
// Already handled by the environment the change was reported for
123+
await change("src/entry-server.tsx", { environment: VITE_ENVIRONMENTS.client });
124+
// Nothing was rendered from it, so there is nothing to see
125+
await change("src/entry-server.tsx", { inGraph: false });
126+
127+
// Longer than the debounce the reload is coalesced with
128+
await new Promise(resolve => setTimeout(resolve, 200));
129+
expect(send).not.toHaveBeenCalled();
130+
});
131+
});
132+
45133
describe("isHtmlResponse", () => {
46134
it("recognizes HTML responses with content type parameters", () => {
47135
const response = new Response(null, {

‎packages/start/src/config/dev-server.ts‎

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import { existsSync } from "node:fs";
2-
import { join } from "node:path";
2+
import { join, resolve } from "node:path";
33
import { pathToFileURL } from "node:url";
44
import { NodeRequest, sendNodeResponse } from "srvx/node";
55
import {
66
type Connect,
7+
type DevEnvironment,
78
isRunnableDevEnvironment,
9+
normalizePath,
810
type PluginOption,
911
type ViteDevServer,
1012
} from "vite";
13+
import { debounce } from "../utils/debounce.ts";
1114
import { VITE_ENVIRONMENTS } from "./constants.ts";
15+
import { parseIdQuery } from "./utils.ts";
1216

13-
export function devServer(serverEntryPath: string): Array<PluginOption> {
17+
export function devServer(serverEntryPath: string, middlewarePath?: string): Array<PluginOption> {
1418
return [
19+
serverOnlyReload(serverEntryPath, middlewarePath),
1520
{
1621
name: "solid-start-dev-server",
1722
configurePreviewServer(server) {
@@ -129,6 +134,72 @@ export function devServer(serverEntryPath: string): Array<PluginOption> {
129134
];
130135
}
131136

137+
/**
138+
* Reloads the browser when the server entry or the middleware changes.
139+
*
140+
* Both are server-only, so they have no module in the client graph and Vite has
141+
* nothing to send the browser when they change. The server picks the edit up on
142+
* the next request, but the page the developer is looking at keeps showing the
143+
* output rendered from the previous version until it is refreshed by hand.
144+
*
145+
* Only these two entries are handled: modules that also exist on the client
146+
* (routes, components, server functions) are left to Vite, since reloading on
147+
* top of a client HMR update would needlessly throw away the page's state.
148+
*
149+
* @see https://github.com/solidjs/solid-start/issues/1611
150+
*/
151+
export function serverOnlyReload(serverEntryPath: string, middlewarePath?: string): PluginOption {
152+
/**
153+
* Resolved the same way the runtime resolves them, so that an extensionless
154+
* or aliased middleware path still matches. Both are stable for the lifetime
155+
* of the server, so this is only done once.
156+
*/
157+
let serverOnlyFiles: Promise<Set<string>> | undefined;
158+
159+
/** Coalesces the reload when a save touches both entries at once */
160+
const reload = debounce((server: ViteDevServer) => {
161+
server.environments[VITE_ENVIRONMENTS.client]?.hot.send({ type: "full-reload" });
162+
}, 50);
163+
164+
return {
165+
name: "solid-start-server-only-reload",
166+
async hotUpdate({ file, modules, server }) {
167+
if (this.environment.name !== VITE_ENVIRONMENTS.server) return;
168+
// Nothing was server rendered from this file
169+
if (modules.length === 0) return;
170+
171+
serverOnlyFiles ??= resolveFiles(this.environment, server.config.root, [
172+
serverEntryPath,
173+
middlewarePath,
174+
]);
175+
176+
if ((await serverOnlyFiles).has(file)) reload(server);
177+
},
178+
};
179+
}
180+
181+
async function resolveFiles(
182+
environment: DevEnvironment,
183+
root: string,
184+
paths: Array<string | undefined>,
185+
): Promise<Set<string>> {
186+
const files = new Set<string>();
187+
188+
for (const path of paths) {
189+
if (!path) continue;
190+
191+
let resolved: string | undefined;
192+
try {
193+
resolved = (await environment.pluginContainer.resolveId(path))?.id;
194+
} catch {
195+
// Fall back to the path as written, e.g. when a plugin throws on resolve
196+
}
197+
files.add(normalizePath(parseIdQuery(resolved ?? resolve(root, path)).filename));
198+
}
199+
200+
return files;
201+
}
202+
132203
export function resolvePreviewServerEntry(root: string): string {
133204
const serverDirectory = join(root, "dist/server");
134205
const serverEntry = ["js", "mjs"]

‎packages/start/src/config/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
455455
(globalThis as any).START_CLIENT_OUT_DIR = options.dir;
456456
},
457457
},
458-
devServer(handlers.server),
458+
devServer(handlers.server, start.middleware),
459459
solid({
460460
...start.solid,
461461
ssr: true,

0 commit comments

Comments
 (0)