Skip to content
Open
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/server-functions-real-root.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 24 additions & 3 deletions packages/start/src/config/lazy.spec.ts
Original file line number Diff line number Diff line change
@@ -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; }`;
Expand All @@ -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();
});
Expand Down
14 changes: 8 additions & 6 deletions packages/start/src/config/lazy.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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`;
}

Expand Down
64 changes: 64 additions & 0 deletions packages/start/src/directives/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
14 changes: 10 additions & 4 deletions packages/start/src/directives/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createFilter>;

let env: CompileOptions["env"];

Expand Down Expand Up @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions packages/start/src/server/collect-styles.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, object> = {
[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" });
});
});
3 changes: 2 additions & 1 deletion packages/start/src/server/collect-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EnvironmentModuleNode>();

try {
Expand Down
Loading