Skip to content
Merged
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
6 changes: 3 additions & 3 deletions packages/start/src/config/fs-routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -36,7 +36,7 @@ export function fsRoutes({ routers }: FsRoutesArgs): Array<PluginOption> {
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 }}
Expand All @@ -52,7 +52,7 @@ export function fsRoutes({ routers }: FsRoutesArgs): Array<PluginOption> {
// 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,
Expand Down
38 changes: 36 additions & 2 deletions packages/start/src/config/fs-routes/tree-shake.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = `
Expand Down
52 changes: 51 additions & 1 deletion packages/start/src/config/fs-routes/tree-shake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<ext>` 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-<hash>.js`, not
* `index.tsx_pick_default_pick__css_lang-<hash>.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<PluginPass, "opts"> & {
opts: { pick: string[] };
refs: Set<any>;
Expand Down
17 changes: 12 additions & 5 deletions packages/start/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -208,17 +209,23 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
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
...({
Expand Down
Loading