From 5dba43c8bdb537c0a11d04a6b61aeb1f782bdbea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Mon, 12 Oct 2026 22:04:23 -0300 Subject: [PATCH] fix(vite): type generated refs by their picked exports Type lazy and eager refs with Pick>, using the names in ref.pick without the synthetic $css. Refs with no names left use a generic type. Picked names stay accessible when the module is any. Compile generated declarations with small consumer files to check that mocks returning only default typecheck and reads outside the pick fail. Cover untyped JavaScript routes with code splitting on and off. --- .changeset/route-types-pick.md | 5 + src/vite/types.ts | 13 ++- test/vite.spec.ts | 203 +++++++++++++++++++++++++++++++-- 3 files changed, 207 insertions(+), 14 deletions(-) create mode 100644 .changeset/route-types-pick.md 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