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 {