From 1ec6644bf0ab4a71b51d2d858eb2b0ee17ac5d21 Mon Sep 17 00:00:00 2001 From: dennev Date: Sat, 17 Oct 2026 15:22:10 +0900 Subject: [PATCH] fix(start): resolve the server function filter and lazy ids against Vite's root The server function filter's default patterns are relative, and createFilter resolved them against process.cwd(); the lazy plugin also named each module relative to process.cwd(). On Windows a working directory entered through a junction or symlink keeps that path, while Vite (8.2 and later) resolves its root and every module id to the real path. The filter then matched no module, so no "use server" function was compiled: its body shipped in the client bundle, and a build whose server functions import server-only code such as @solidjs/start/http failed with "Attempt to import 'server-only' in a client module". The lazy ids matched no client manifest key, so the CSS of lazily loaded route components was missing from the server-rendered HTML. Create the filter once the config is resolved, with config.root as the base for relative patterns, and derive lazy ids from the environment's root the way Vite derives its manifest keys. The dev style lookup resolves those ids against the root as well. Co-Authored-By: Claude Opus 5.5 --- .changeset/server-functions-real-root.md | 5 ++ packages/start/src/config/lazy.spec.ts | 27 +++++++- packages/start/src/config/lazy.ts | 14 ++-- packages/start/src/directives/index.spec.ts | 64 +++++++++++++++++++ packages/start/src/directives/index.ts | 14 ++-- .../start/src/server/collect-styles.spec.ts | 37 +++++++++++ packages/start/src/server/collect-styles.ts | 3 +- 7 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 .changeset/server-functions-real-root.md create mode 100644 packages/start/src/directives/index.spec.ts create mode 100644 packages/start/src/server/collect-styles.spec.ts diff --git a/.changeset/server-functions-real-root.md b/.changeset/server-functions-real-root.md new file mode 100644 index 000000000..cefb7cfbe --- /dev/null +++ b/.changeset/server-functions-real-root.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Resolve the server function filter and the ids of lazily loaded modules against Vite's root instead of the working directory. On Windows a working directory entered through a junction or symlink keeps that path, while Vite (8.2 and later) resolves the root and every module id to the real path. The filter then matched no module, so no `"use server"` function was compiled: its body shipped in the client bundle, and the build failed with `Attempt to import 'server-only' in a client module` once one of them imported server-only code such as `@solidjs/start/http`. The ids of lazily loaded route components matched no client manifest entry either, so their CSS was missing from the server-rendered HTML. Relative patterns in the `serverFunctions.filter` option now resolve against Vite's root too. diff --git a/packages/start/src/config/lazy.spec.ts b/packages/start/src/config/lazy.spec.ts index 39adb2fad..2e7e18ef8 100644 --- a/packages/start/src/config/lazy.spec.ts +++ b/packages/start/src/config/lazy.spec.ts @@ -1,13 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import lazy from "./lazy.ts"; -async function transform(code: string, id = `${process.cwd()}/src/routes/page.tsx`) { +async function transform( + code: string, + id = `${process.cwd()}/src/routes/page.tsx`, + root = process.cwd(), +) { const plugin = lazy() as any; - const context = { environment: { name: "ssr" } }; + const context = { environment: { name: "ssr", config: { root } } }; return plugin.transform.call(context, code, id); } +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("lazy", () => { it("appends the module id without moving existing code", async () => { const code = `import { A } from "./a.ts";\nexport default function Page() { return A; }`; @@ -29,6 +37,19 @@ describe("lazy", () => { expect(result.map).toBeTruthy(); }); + it("names the module relative to the root when the working directory is a link to it", async () => { + // Vite (8.2 and later) resolves its root and module ids to the real path. + // On Windows the working directory keeps the junction or symlink path. + vi.spyOn(process, "cwd").mockReturnValue("/link/app"); + const result = await transform( + `import { A } from "./a.ts";\nexport default A;`, + "/real/app/src/routes/page.tsx", + "/real/app", + ); + + expect(result.code).toContain(`export const id$$ = "src/routes/page.tsx";`); + }); + it("skips a module without a default export or lazy", async () => { expect(await transform(`import { A } from "./a.ts";\nexport const B = A;`)).toBeUndefined(); }); diff --git a/packages/start/src/config/lazy.ts b/packages/start/src/config/lazy.ts index 72fbfc3d2..7518817e1 100644 --- a/packages/start/src/config/lazy.ts +++ b/packages/start/src/config/lazy.ts @@ -1,9 +1,9 @@ import type { PluginItem } from "@babel/core"; import babel from "@babel/core"; import * as t from "@babel/types"; -import { sep as osSep } from "node:path"; -import { basename, relative, sep } from "node:path/posix"; -import type { PluginOption } from "vite"; +import { relative } from "node:path"; +import { basename } from "node:path/posix"; +import { normalizePath, type PluginOption } from "vite"; import { VITE_ENVIRONMENTS } from "./constants.ts"; const importTransform = (): PluginItem => { @@ -31,8 +31,6 @@ const importTransform = (): PluginItem => { const fileEndingRegex = /(ts|js)x(\?.*)?$/; const lazy = (): PluginOption => { - const cwd = process.cwd().replaceAll(osSep, sep); - /** * Maps module ids to their client-specific shared chunk names. * Modules in shared chunks need to find their assets via the chunk name, instead of their module id. @@ -84,7 +82,11 @@ const lazy = (): PluginOption => { // source map of the input still applies to the output. let idExport = ""; if (src.indexOf("export default") !== -1) { - const chunkName = sharedChunkNames[id] ?? relative(cwd, id); + // Derived the way Vite derives the client manifest keys it is looked + // up by. Relative to the working directory, it would match no key when + // that directory reaches the project through a junction or symlink. + const chunkName = + sharedChunkNames[id] ?? normalizePath(relative(this.environment.config.root, id)); idExport = `\nexport const id$$ = ${JSON.stringify(chunkName)};\n`; } diff --git a/packages/start/src/directives/index.spec.ts b/packages/start/src/directives/index.spec.ts new file mode 100644 index 000000000..a84158b78 --- /dev/null +++ b/packages/start/src/directives/index.spec.ts @@ -0,0 +1,64 @@ +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { normalizePath } from "vite"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { type ServerFunctionsFilter, serverFunctionsPlugin } from "./index.ts"; + +// Vite (8.2 and later) resolves its root and every module id to the real path. +// On Windows the working directory keeps the path it was entered through, so it +// can name the same project through a junction or symlink. +const root = normalizePath(join(tmpdir(), "solid-start-app")); +const linkedRoot = normalizePath(join(tmpdir(), "solid-start-link")); + +const code = ` +export async function getMessage() { + "use server"; + return "hello"; +} +`; + +function clientTransform(filter?: ServerFunctionsFilter) { + const plugins = serverFunctionsPlugin({ + manifest: "virtual:server-fn-manifest", + runtime: { server: "virtual:server-runtime", client: "virtual:client-runtime" }, + filter, + }) as any[]; + for (const plugin of plugins) plugin.configResolved?.({ root, mode: "production" }); + + const compiler = plugins.find(p => p.name === "solid-start:server-functions/compiler"); + const context = { environment: { config: { consumer: "client" } }, warn() {} }; + return (id: string) => compiler.transform.call(context, code, id); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("serverFunctionsPlugin", () => { + it("compiles server functions when the working directory is a link to the root", async () => { + vi.spyOn(process, "cwd").mockReturnValue(linkedRoot); + const transform = clientTransform(); + + const result = await transform(`${root}/src/message.ts`); + + expect(result).not.toBeNull(); + expect(result.code).toContain("virtual:client-runtime"); + expect(result.code).not.toContain("hello"); + }); + + it("leaves modules outside the root's src folder alone", async () => { + vi.spyOn(process, "cwd").mockReturnValue(linkedRoot); + const transform = clientTransform(); + + expect(await transform(`${root}/scripts/message.ts`)).toBeNull(); + }); + + it("resolves relative filter patterns against the root", async () => { + vi.spyOn(process, "cwd").mockReturnValue(linkedRoot); + const transform = clientTransform({ include: "app/**/*.ts" }); + + expect(await transform(`${root}/app/message.ts`)).not.toBeNull(); + expect(await transform(`${root}/src/message.ts`)).toBeNull(); + }); +}); diff --git a/packages/start/src/directives/index.ts b/packages/start/src/directives/index.ts index 62137e271..2926b938a 100644 --- a/packages/start/src/directives/index.ts +++ b/packages/start/src/directives/index.ts @@ -125,10 +125,7 @@ function invalidateModules( } export function serverFunctionsPlugin(options: ServerFunctionsOptions): Plugin[] { - const filter = createFilter( - options.filter?.include || DEFAULT_INCLUDE, - options.filter?.exclude || DEFAULT_EXCLUDE, - ); + let filter: ReturnType; let env: CompileOptions["env"]; @@ -177,6 +174,15 @@ export function serverFunctionsPlugin(options: ServerFunctionsOptions): Plugin[] enforce: "pre", configResolved(config) { env = config.mode !== "production" ? "development" : "production"; + // Vite resolves its root like every module id: to the real path since + // Vite 8.2, unless `resolve.preserveSymlinks`. Patterns resolved against + // a working directory that reaches the project through a junction or + // symlink would match no module. + filter = createFilter( + options.filter?.include || DEFAULT_INCLUDE, + options.filter?.exclude || DEFAULT_EXCLUDE, + { resolve: config.root }, + ); }, configureServer(server) { currentServer = server; diff --git a/packages/start/src/server/collect-styles.spec.ts b/packages/start/src/server/collect-styles.spec.ts new file mode 100644 index 000000000..08f449c13 --- /dev/null +++ b/packages/start/src/server/collect-styles.spec.ts @@ -0,0 +1,37 @@ +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { normalizePath } from "vite"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { findStylesInModuleGraph } from "./collect-styles.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("findStylesInModuleGraph", () => { + it("resolves the id against the root when the working directory is a link to it", async () => { + const root = join(tmpdir(), "solid-start-app"); + vi.spyOn(process, "cwd").mockReturnValue(join(tmpdir(), "solid-start-link")); + const page = resolve(root, "src/routes/page.tsx"); + const style = resolve(root, "src/routes/page.css"); + const modules: Record = { + [page]: { + id: page, + url: "/src/routes/page.tsx", + file: page, + transformResult: { deps: [style] }, + }, + [style]: { id: style, url: "/src/routes/page.css", file: style }, + }; + const environment = { + config: { root: normalizePath(root) }, + fetchModule: async (file: string) => (file in modules ? { id: file } : {}), + moduleGraph: { getModuleById: (id: string) => modules[id] }, + } as any; + + const styles = await findStylesInModuleGraph(environment, "src/routes/page.tsx", () => true); + + expect(styles).toEqual({ [style]: "/src/routes/page.css" }); + }); +}); diff --git a/packages/start/src/server/collect-styles.ts b/packages/start/src/server/collect-styles.ts index bb9510bde..affc954f4 100644 --- a/packages/start/src/server/collect-styles.ts +++ b/packages/start/src/server/collect-styles.ts @@ -60,7 +60,8 @@ export async function findStylesInModuleGraph( id: string, filter: StyleFilter, ) { - const absolute = path.resolve(process.cwd(), id); + // Ids are relative to Vite's root, as `lazy` derives `id$$`. + const absolute = path.resolve(vite.config.root, id); const dependencies = new Set(); try {