diff --git a/packages/start/src/config/fs-routes/index.ts b/packages/start/src/config/fs-routes/index.ts index 80f9ecb1a..77faf89e4 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 { toPickId, 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 = toPickId(v.src, v.pick); /** * @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 = toPickId(v.src, v.pick); 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..485c40276 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,48 @@ import { describe, expect, it } from "vitest"; -import { treeShake } from "./tree-shake.ts"; +import { sanitizeChunkFileName, toPickId, 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 = toPickId("/routes/route.ts", pick); const result = await plugin.transform(code, id); return result?.code as string | undefined; } +// https://github.com/solidjs/solid-start/issues/1918 +describe("toPickId", () => { + it("ends the id with the source extension so extension filters still match", () => { + const id = toPickId("/routes/route.ts", ["GET"]); + + expect(id).toBe("/routes/route.ts?pick=GET&lang.ts"); + // the default filter used by unplugin-macros and many other plugins + expect(id).toMatch(/\.[cm]?[jt]sx?$/); + }); + + it("keeps every picked export in the query", () => { + expect(toPickId("/routes/route.tsx", ["default", "$css"])).toBe( + "/routes/route.tsx?pick=default&pick=$css&lang.tsx", + ); + }); + + it("leaves non-script sources alone", () => { + expect(toPickId("/routes/route.mdx", ["default"])).toBe("/routes/route.mdx?pick=default"); + }); +}); + +describe("sanitizeChunkFileName", () => { + it("drops the pick query so route chunks keep their file-based name", () => { + expect(sanitizeChunkFileName("index.tsx?pick=default&pick=$css&lang")).toBe("index"); + expect(sanitizeChunkFileName("macro.ts?pick=GET&lang")).toBe("macro"); + }); + + it("still sanitizes the rest of the name like rolldown does", () => { + expect(sanitizeChunkFileName("[...404].tsx?pick=default&pick=$css&lang")).toBe("_...404_"); + expect(sanitizeChunkFileName("entry-client")).toBe("entry-client"); + expect(sanitizeChunkFileName("_libs/solid-js")).toBe("_libs/solid-js"); + }); +}); + 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..1e241e8e5 100644 --- a/packages/start/src/config/fs-routes/tree-shake.ts +++ b/packages/start/src/config/fs-routes/tree-shake.ts @@ -6,9 +6,59 @@ 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"; +const PICKABLE_EXTENSIONS = ["js", "jsx", "ts", "tsx"]; + +/** + * Builds the module id used to import a subset of a route file's exports. + * + * The `pick` list has to live in the query so that the same file can be + * instantiated once per export subset (the client picks `default`/`$css`, + * the server picks its HTTP handlers). That query would otherwise leave the + * id ending in `?pick=GET`, which silently excludes route files from any + * plugin whose filter is anchored on the file extension -- a very common + * default, e.g. unplugin-macros' `/\.[cm]?[jt]sx?$/`. + * + * The trailing `lang.` marker is the same convention Vue SFCs use + * (`?vue&type=script&lang.ts`) and puts a real extension back at the end of + * the id, so those filters match again. + * + * @see https://github.com/solidjs/solid-start/issues/1918 + */ +export function toPickId(src: string, pick: string[]): string { + const query = pick.map(p => `pick=${p}`).join("&"); + const ext = extname(src).slice(1); + return PICKABLE_EXTENSIONS.includes(ext) ? `${src}?${query}&lang.${ext}` : `${src}?${query}`; +} + +/** + * Matches the tail that {@link toPickId} appends, as it appears in a chunk + * name. Rolldown derives a chunk name from the module id by taking the + * basename minus its extension, so `index.tsx?pick=default&pick=$css&lang.tsx` + * arrives here as `index.tsx?pick=default&pick=$css&lang`. + */ +const PICK_CHUNK_NAME_RE = /\.[cm]?[jt]sx?\?pick=[^?]*&lang$/; + +const INVALID_CHAR_RE = /[\u0000-\u001F"#$&*+,:;<=>?[\]^`{|}\u007F]/g; +const DRIVE_LETTER_RE = /^[a-z]:/i; + +/** + * Rolldown's default chunk name sanitizer, plus a pass that drops the + * `?pick=...&lang` tail so route chunks keep the short, stable filenames they + * had before the `lang` marker was introduced (`index-.js`, not + * `index.tsx_pick_default_pick__css_lang-.js`). Client chunk filenames + * are public URLs, so this is not purely cosmetic. + * + * @see https://github.com/rollup/rollup/blob/master/src/utils/sanitizeFileName.ts + */ +export function sanitizeChunkFileName(name: string): string { + const stripped = name.replace(PICK_CHUNK_NAME_RE, ""); + const driveLetter = DRIVE_LETTER_RE.exec(stripped)?.[0] ?? ""; + return driveLetter + stripped.slice(driveLetter.length).replace(INVALID_CHAR_RE, "_"); +} + type State = Omit & { opts: { pick: string[] }; refs: Set; diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index cf22a13d2..73527bf8d 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -12,6 +12,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 { sanitizeChunkFileName, toPickId } from "./fs-routes/tree-shake.ts"; import lazy from "./lazy.ts"; import { manifest } from "./manifest.ts"; import { parseIdQuery } from "./utils.ts"; @@ -208,17 +209,23 @@ 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(toPickId((value as any).src, (value as any).pick)); } } } } return { appType: "custom", - build: { assetsDir: "_build/assets" }, + build: { + assetsDir: "_build/assets", + rollupOptions: { + output: { + // Keeps route chunks named after their file rather than after + // the `?pick=...` id that addresses them. See toPickId. + sanitizeFileName: sanitizeChunkFileName, + }, + }, + }, optimizeDeps: { // Suppress TS errors from Vite 7 types when configuring Vite 8's Rolldown ...({