diff --git a/.changeset/route-types-pick.md b/.changeset/route-types-pick.md new file mode 100644 index 0000000..76c34e0 --- /dev/null +++ b/.changeset/route-types-pick.md @@ -0,0 +1,5 @@ +--- +"filesystem-routing": patch +--- + +Declarations generated by the `types` option now type each ref by its picked exports instead of the whole module. A `$component` ref no longer includes the `route` config or separately picked handlers, and `$$route` no longer exposes `default`. Picked exports keep their types, including `any` for untyped JavaScript routes. diff --git a/src/vite/types.ts b/src/vite/types.ts index 923fdcc..d4af383 100644 --- a/src/vite/types.ts +++ b/src/vite/types.ts @@ -55,18 +55,21 @@ const REF_TYPES = ` /** A code-split ref: the delivery adapter emits it as a dy const TYPED_MODULE = /\.([mc]?[jt]sx?)$/; /** - * Types a ref by the module it points at, so a page's `default` really is the - * component and a `route` export really is the config the module declares — - * which is what saves an emission adapter from casting either one. Modules - * TypeScript cannot resolve (`.md`, `.mdx`) fall back to the generic ref. + * Types a ref by its picked exports, keeping the module's `default` and + * `route` types so an emission adapter can read either without a cast. + * Modules TypeScript cannot resolve (`.md`, `.mdx`) fall back to the generic ref. */ function refType(ref: ModuleRef, kind: "lazy" | "eager", from: string): string { const name = kind === "eager" ? "FileRouteEagerRef" : "FileRouteLazyRef"; if (!TYPED_MODULE.test(ref.src)) return name; + const picks = ref.pick.filter(pick => pick !== "$css").map(pick => JSON.stringify(pick)); + if (!picks.length) return name; + let specifier = normalizePath(relative(from, ref.src)).replace(TYPED_MODULE, ""); if (!specifier.startsWith(".")) specifier = `./${specifier}`; - return `${name}`; + const moduleType = `typeof import(${JSON.stringify(specifier)})`; + return `${name}>>`; } const isLazyRefKey = (key: string) => key.startsWith("$") && !key.startsWith("$$"); diff --git a/test/vite.spec.ts b/test/vite.spec.ts index e7d05af..59bf111 100644 --- a/test/vite.spec.ts +++ b/test/vite.spec.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import ts from "typescript"; import { afterEach, describe, expect, it } from "vitest"; import { PageFileSystemRouter } from "../src/convention.ts"; @@ -42,6 +43,23 @@ function loadVirtualModule(root: string, environment = "client") { return loadWith(createPlugin(root), root, environment); } +function checkTypes(root: string, source: string, options: ts.CompilerOptions = {}) { + const file = path.join(root, "consumer.ts"); + fs.writeFileSync(file, source); + const program = ts.createProgram([path.join(root, "generated.d.ts"), file], { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + noEmit: true, + types: [], + ...options + }); + return ts + .getPreEmitDiagnostics(program) + .map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); +} + describe("fileRoutes vite plugin", () => { it("serializes the manifest into a virtual module", async () => { const root = createRouteTree({ @@ -130,6 +148,174 @@ describe("fileRoutes vite plugin", () => { }); describe("types", () => { + it.each([ + { codeSplitting: true, serverComponents: false }, + { codeSplitting: false, serverComponents: false }, + { codeSplitting: true, serverComponents: true } + ])("types only picked exports with %o", async options => { + const root = createRouteTree({ + "index.ts": ` + export const route = { info: { title: "Home" } }; + export const GET = () => new Response("ok"); + export const POST = () => new Response("created"); + export default function Home() { + ${options.serverComponents ? '"use server";' : ""} + return null; + } + ` + }); + const plugin = createPlugin(root, { ...options, types: "generated.d.ts", httpMethods: true }); + await plugin.buildStart.call({}); + const componentMethod = + options.codeSplitting && !options.serverComponents ? "import" : "require"; + const componentResult = + componentMethod === "import" + ? "Promise.resolve({ default: render })" + : "({ default: render })"; + const handlerMethod = options.codeSplitting ? "import" : "require"; + + expect( + checkTypes( + root, + ` + import routes, { pageRoutes } from "virtual:file-routes"; + const entry = routes[0]; + const render = () => null; + entry.$component.${componentMethod} = () => ${componentResult}; + pageRoutes[0].$component.${componentMethod} = () => ${componentResult}; + entry.$$route.require = () => ({ route: { info: { title: "Home" } } }); + const component = await entry.$component.${componentMethod}(); + const config = entry.$$route.require(); + const get = await entry.$GET.${handlerMethod}(); + const head = await entry.$HEAD.${handlerMethod}(); + const result: null = component.default(); + const title: string = config.route.info.title; + const response: Response = head.GET(); + // @ts-expect-error route config is delivered through its own ref + component.route; + // @ts-expect-error component refs omit HTTP handlers + component.GET; + // @ts-expect-error route config refs omit the page component + config.default; + // @ts-expect-error route config refs omit HTTP handlers + config.GET; + // @ts-expect-error GET refs omit other handlers + get.POST; + // @ts-expect-error the HEAD alias picks GET, not HEAD + head.HEAD; + // @ts-expect-error handler refs omit the page component + head.default; + // @ts-expect-error the tree view also omits unpicked exports + pageRoutes[0].$$route.require().default; + ` + ) + ).toEqual([]); + }); + + it.each([ + { extension: "js", codeSplitting: true }, + { extension: "jsx", codeSplitting: true }, + { extension: "js", codeSplitting: false }, + { extension: "jsx", codeSplitting: false } + ])( + "keeps picked exports from untyped JavaScript with %o", + async ({ extension, codeSplitting }) => { + const root = createRouteTree({ + [`index.${extension}`]: ` + export const route = { info: { title: "Home" } }; + export default () => null; + ` + }); + const plugin = createPlugin(root, { types: "generated.d.ts", codeSplitting }); + await plugin.buildStart.call({}); + const method = codeSplitting ? "import" : "require"; + + expect( + checkTypes( + root, + ` + import routes, { pageRoutes } from "virtual:file-routes"; + const component = await routes[0].$component.${method}(); + const config = routes[0].$$route.require(); + component.default(); + config.route.info.title; + (await pageRoutes[0].$component.${method}()).default(); + pageRoutes[0].$$route.require().route.info.title; + type IsAny = 0 extends (1 & T) ? true : false; + const defaultIsAny: IsAny = true; + const routeIsAny: IsAny = true; + // @ts-expect-error component refs exclude the route config even without source types + component.route; + // @ts-expect-error route config refs exclude the component even without source types + config.default; + `, + { allowJs: false, skipLibCheck: true } + ) + ).toEqual([]); + } + ); + + it("keeps multiple picked exports without requiring synthetic or missing names", async () => { + const root = createRouteTree({ + "index.ts": ` + export const helper = 42; + export const route = {}; + export default () => null; + ` + }); + const plugin = createPlugin(root, { + types: "generated.d.ts", + toRoute: src => ({ + path: "/", + page: true, + $component: { src, pick: ["default", "helper", "missing", "$css"] } + }) + }); + await plugin.buildStart.call({}); + + expect( + checkTypes( + root, + ` + import routes from "virtual:file-routes"; + const component = await routes[0].$component.import(); + const helper: 42 = component.helper; + const result: null = component.default(); + // @ts-expect-error the module does not export the synthetic CSS pick + component.$css; + // @ts-expect-error absent picked names do not become exports + component.missing; + // @ts-expect-error unpicked exports are excluded + component.route; + ` + ) + ).toEqual([]); + }); + + it.each([true, false])( + "uses generic refs for empty or CSS-only picks (codeSplitting: %s)", + async codeSplitting => { + const root = createRouteTree({ "index.ts": "export default () => null;" }); + const plugin = createPlugin(root, { + types: "generated.d.ts", + codeSplitting, + toRoute: src => ({ + path: "/", + page: true, + $component: { src, pick: ["$css"] }, + $$route: { src, pick: [] } + }) + }); + await plugin.buildStart.call({}); + const declaration = fs.readFileSync(path.join(root, "generated.d.ts"), "utf-8"); + + expect(declaration).toContain( + `$component: ${codeSplitting ? "FileRouteLazyRef" : "FileRouteEagerRef"};` + ); + expect(declaration).toContain("$$route: FileRouteEagerRef;"); + } + ); + it("declares the manifest as a literal tuple", async () => { const root = createRouteTree({ "(app).tsx": "export default props =>
{props.children}
;", @@ -154,14 +340,13 @@ describe("fileRoutes vite plugin", () => { expect(declaration).toMatch(/children: readonly \[[\s\S]*path: "\/blog\/:id"/); // absent refs stay readable rather than vanishing from the type expect(declaration).toContain("$$route?: undefined"); - // refs are typed by the module they point at, so an adapter reads a - // page's real default export and a route module's real config + // refs retain the original types of their picked exports expect(declaration).toMatch( - /\$component: FileRouteLazyRef/ + /\$component: FileRouteLazyRef { expect(declaration).toMatch(/path: "\/post";[\s\S]*?\$component: FileRouteLazyRef;/); expect(declaration).toMatch( - /\$component: FileRouteLazyRef/ + /\$component: FileRouteLazyRef { const declaration = fs.readFileSync(path.join(directory, "generated.d.ts"), "utf-8"); expect(declaration).toMatch( - /\$component: FileRouteEagerRef/ + /\$component: FileRouteEagerRef { await plugin.buildStart.call({}); const declaration = fs.readFileSync(path.join(directory, "generated.d.ts"), "utf-8"); expect(declaration).toMatch( - /server: true;\s*\n\s*\$component: FileRouteEagerRef/ + /server: true;\s*\n\s*\$component: FileRouteEagerRef/ + /\$component: FileRouteLazyRef