Skip to content
Closed
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
7 changes: 7 additions & 0 deletions .changeset/eighty-jars-invite.md
Original file line number Diff line number Diff line change
@@ -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.<ext>`, following the same Vite sub-request convention `@vitejs/plugin-vue` uses.
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 { toRouteModuleId, 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 = toRouteModuleId(v);

/**
* @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 = toRouteModuleId(v);
return {
src: relative(root, buildId),
build: isBuild ? `_$() => import('${buildId}')$_` : undefined,
Expand Down
57 changes: 55 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,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 = `
Expand Down
40 changes: 39 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,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<PluginPass, "opts"> & {
Expand Down Expand Up @@ -435,6 +435,44 @@ function treeShakeTransform({ types: t }: typeof Babel): PluginObj<State> {
};
}

/**
* Builds the module id used to request a subset of a route file's exports.
*
* The trailing `&lang.<ext>` 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-<hash>.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("?");
Expand Down
8 changes: 4 additions & 4 deletions packages/start/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -183,10 +184,7 @@ 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(toRouteModuleId(value as any));
}
}
}
Expand Down Expand Up @@ -215,6 +213,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
input: clientInput,
treeshake: true,
preserveEntrySignatures: "exports-only",
output: { sanitizeFileName: sanitizeRouteChunkName },
},
},
},
Expand All @@ -227,6 +226,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
copyPublicDir: false,
rollupOptions: {
input: handlers.server,
output: { sanitizeFileName: sanitizeRouteChunkName },
},
outDir: "dist/server",
commonjsOptions: {
Expand Down
Loading