diff --git a/.changeset/eighty-jars-invite.md b/.changeset/eighty-jars-invite.md new file mode 100644 index 000000000..0a48d4066 --- /dev/null +++ b/.changeset/eighty-jars-invite.md @@ -0,0 +1,7 @@ +--- +"@solidjs/start": patch +--- + +Make route module ids end in the source extension so plugins like `unplugin-auto-import` and `unplugin-icons` apply inside `src/routes`. + +Route files are requested with a `?pick=...` query so only the needed exports are bundled. Plugins that filter ids with an end-anchored extension regex (`/\.[jt]sx?$/`, the default for several `unplugin-*` packages) never matched those ids and silently skipped every route file. The query now ends with `&lang.`, following the same Vite sub-request convention `@vitejs/plugin-vue` uses. diff --git a/packages/start/src/config/fs-routes/index.ts b/packages/start/src/config/fs-routes/index.ts index 80f9ecb1a..8257a846e 100644 --- a/packages/start/src/config/fs-routes/index.ts +++ b/packages/start/src/config/fs-routes/index.ts @@ -3,7 +3,7 @@ import { type PluginOption } from "vite"; import { fileSystemWatcher } from "./fs-watcher.ts"; import type { BaseFileSystemRouter } from "./router.ts"; -import { treeShake } from "./tree-shake.ts"; +import { toRouteModuleId, treeShake } from "./tree-shake.ts"; export const moduleId = "solid-start:routes"; @@ -36,7 +36,7 @@ export function fsRoutes({ routers }: FsRoutesArgs): Array { if (v === undefined) return undefined; if (k.startsWith("$$")) { - const buildId = `${v.src}?${v.pick.map((p: any) => `pick=${p}`).join("&")}`; + const buildId = toRouteModuleId(v); /** * @type {{ [key: string]: string }} @@ -52,7 +52,7 @@ export function fsRoutes({ routers }: FsRoutesArgs): Array { // src: isBuild ? relative(root, buildId) : buildId }; } else if (k.startsWith("$")) { - const buildId = `${v.src}?${v.pick.map((p: any) => `pick=${p}`).join("&")}`; + const buildId = toRouteModuleId(v); return { src: relative(root, buildId), build: isBuild ? `_$() => import('${buildId}')$_` : undefined, diff --git a/packages/start/src/config/fs-routes/tree-shake.spec.ts b/packages/start/src/config/fs-routes/tree-shake.spec.ts index 47e94c0bd..2b5cbed59 100644 --- a/packages/start/src/config/fs-routes/tree-shake.spec.ts +++ b/packages/start/src/config/fs-routes/tree-shake.spec.ts @@ -1,14 +1,67 @@ import { describe, expect, it } from "vitest"; -import { treeShake } from "./tree-shake.ts"; +import { sanitizeRouteChunkName, toRouteModuleId, treeShake } from "./tree-shake.ts"; async function shake(code: string, pick: string[]) { const plugin = treeShake() as any; - const id = `/routes/route.ts?${pick.map(p => `pick=${p}`).join("&")}`; + const id = toRouteModuleId({ src: "/routes/route.ts", pick }); const result = await plugin.transform(code, id); return result?.code as string | undefined; } +// https://github.com/solidjs/solid-start/issues/1374 +describe("toRouteModuleId", () => { + it("ends in the source extension so extension filters still match", () => { + const id = toRouteModuleId({ src: "/routes/index.tsx", pick: ["default", "$css"] }); + + expect(id).toBe("/routes/index.tsx?pick=default&pick=$css&lang.tsx"); + // The default include filter of unplugin-auto-import, unplugin-vue-components, etc. + expect(id).toMatch(/\.[jt]sx?$/); + }); + + it("keeps the picks parseable as query params", () => { + const id = toRouteModuleId({ src: "/routes/api.ts", pick: ["GET", "POST"] }); + const query = new URLSearchParams(id.split("?")[1]); + + expect(query.getAll("pick")).toEqual(["GET", "POST"]); + }); + + it("uses the real extension for non-js route files", () => { + expect(toRouteModuleId({ src: "/routes/post.md", pick: ["$css"] })).toBe( + "/routes/post.md?pick=$css&lang.md", + ); + }); + + it("omits the suffix when the source has no extension", () => { + expect(toRouteModuleId({ src: "/routes/route", pick: ["default"] })).toBe( + "/routes/route?pick=default", + ); + }); +}); + +describe("sanitizeRouteChunkName", () => { + it("names a route chunk after its route file, not the pick query", () => { + const name = toRouteModuleId({ src: "index.tsx", pick: ["default", "$css"] }); + + expect(sanitizeRouteChunkName(name)).toBe("index"); + }); + + it("sanitizes dynamic segments the way vite does", () => { + expect(sanitizeRouteChunkName(toRouteModuleId({ src: "[id].tsx", pick: ["default"] }))).toBe( + "_id_", + ); + expect( + sanitizeRouteChunkName(toRouteModuleId({ src: "[...stories].tsx", pick: ["default"] })), + ).toBe("_...stories_"); + }); + + it("leaves non-route chunk names alone", () => { + expect(sanitizeRouteChunkName("entry-client")).toBe("entry-client"); + expect(sanitizeRouteChunkName("some.vendor.chunk")).toBe("some.vendor.chunk"); + expect(sanitizeRouteChunkName("weird?name")).toBe("weird_name"); + }); +}); + describe("treeShake", () => { it("keeps only the picked export", async () => { const code = ` diff --git a/packages/start/src/config/fs-routes/tree-shake.ts b/packages/start/src/config/fs-routes/tree-shake.ts index 61824e24f..6d330fefe 100644 --- a/packages/start/src/config/fs-routes/tree-shake.ts +++ b/packages/start/src/config/fs-routes/tree-shake.ts @@ -6,7 +6,7 @@ import type * as Babel from "@babel/core"; import type { NodePath, PluginObj, PluginPass } from "@babel/core"; import type { Binding } from "@babel/traverse"; import type { Identifier } from "@babel/types"; -import { basename } from "pathe"; +import { basename, extname } from "pathe"; import type { Plugin } from "vite"; type State = Omit & { @@ -435,6 +435,44 @@ function treeShakeTransform({ types: t }: typeof Babel): PluginObj { }; } +/** + * Builds the module id used to request a subset of a route file's exports. + * + * The trailing `&lang.` follows the Vite sub-request convention (the same + * one `@vitejs/plugin-vue` uses for `?vue&type=script&lang.ts`) so the id still + * ends in the source extension. Plugins that filter on ids with an end-anchored + * extension regex (`/\.[jt]sx?$/`, the default in `unplugin-auto-import` and + * friends) would otherwise skip every route file. + * + * https://github.com/solidjs/solid-start/issues/1374 + */ +export function toRouteModuleId(route: { src: string; pick: string[] }): string { + const query = route.pick.map(p => `pick=${p}`).join("&"); + const ext = extname(route.src).slice(1); + return `${route.src}?${query}${ext ? `&lang.${ext}` : ""}`; +} + +// Mirrors Vite's default `build.rollupOptions.output.sanitizeFileName`, which +// overriding the option replaces rather than composes with. +const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$&*+,:;<=>?[\]^`{|}\u007F]/g; +const DRIVE_LETTER_REGEX = /^[a-z]:/i; + +/** + * Rollup derives a chunk's name from the module id's basename minus its + * extension. Because {@link toRouteModuleId} ends ids with the extension rather + * than the query, that basename now includes the query, which would surface as + * `index.tsx_pick_default_pick__css_lang-.js` in the build output. Strip + * the query back off so route chunks stay named after their route file. + */ +export function sanitizeRouteChunkName(name: string): string { + if (name.includes("?pick=")) { + const file = name.slice(0, name.indexOf("?")); + name = basename(file, extname(file)); + } + const driveLetter = DRIVE_LETTER_REGEX.exec(name)?.[0] ?? ""; + return driveLetter + name.slice(driveLetter.length).replace(INVALID_CHAR_REGEX, "_"); +} + export function treeShake(): Plugin { async function transform(id: string, code: string) { const [path, queryString] = id.split("?"); diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 4ec6ab107..75d95886a 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -11,6 +11,7 @@ import { envPlugin, type EnvPluginOptions } from "./env.ts"; import { SolidStartClientFileRouter, SolidStartServerFileRouter } from "./fs-router.ts"; import { fsRoutes } from "./fs-routes/index.ts"; import type { BaseFileSystemRouter } from "./fs-routes/router.ts"; +import { sanitizeRouteChunkName, toRouteModuleId } from "./fs-routes/tree-shake.ts"; import lazy from "./lazy.ts"; import { manifest } from "./manifest.ts"; import { parseIdQuery } from "./utils.ts"; @@ -183,10 +184,7 @@ export function solidStart(options?: SolidStartOptions): Array { for (const route of await clientRouter.getRoutes()) { for (const [key, value] of Object.entries(route)) { if (value && key.startsWith("$") && !key.startsWith("$$")) { - function toRouteId(route: any) { - return `${route.src}?${route.pick.map((p: string) => `pick=${p}`).join("&")}`; - } - clientInput.push(toRouteId(value)); + clientInput.push(toRouteModuleId(value as any)); } } } @@ -215,6 +213,7 @@ export function solidStart(options?: SolidStartOptions): Array { input: clientInput, treeshake: true, preserveEntrySignatures: "exports-only", + output: { sanitizeFileName: sanitizeRouteChunkName }, }, }, }, @@ -227,6 +226,7 @@ export function solidStart(options?: SolidStartOptions): Array { copyPublicDir: false, rollupOptions: { input: handlers.server, + output: { sanitizeFileName: sanitizeRouteChunkName }, }, outDir: "dist/server", commonjsOptions: {