Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/proud-pandas-reload.md
Original file line number Diff line number Diff line change
@@ -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.
96 changes: 92 additions & 4 deletions packages/start/src/config/dev-server.spec.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];

Expand All @@ -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 });
}
Expand All @@ -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<void>;

/** 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, {
Expand Down
75 changes: 73 additions & 2 deletions packages/start/src/config/dev-server.ts
Original file line number Diff line number Diff line change
@@ -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<PluginOption> {
export function devServer(serverEntryPath: string, middlewarePath?: string): Array<PluginOption> {
return [
serverOnlyReload(serverEntryPath, middlewarePath),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably good if someone else reviews this but overall looks good

{
name: "solid-start-dev-server",
configurePreviewServer(server) {
Expand Down Expand Up @@ -125,6 +130,72 @@ export function devServer(serverEntryPath: string): Array<PluginOption> {
];
}

/**
* 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<Set<string>> | 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<string | undefined>,
): Promise<Set<string>> {
const files = new Set<string>();

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"]
Expand Down
2 changes: 1 addition & 1 deletion packages/start/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
(globalThis as any).START_CLIENT_OUT_DIR = options.dir;
},
},
devServer(handlers.server),
devServer(handlers.server, start.middleware),
solid({
...start.solid,
ssr: true,
Expand Down
Loading