diff --git a/.changeset/exact-optional-route-definition.md b/.changeset/exact-optional-route-definition.md new file mode 100644 index 00000000..082025b1 --- /dev/null +++ b/.changeset/exact-optional-route-definition.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +Accept an explicit `undefined` on every optional `RouteDefinition` property (and on the optional properties of `defineRoute`, `defineFileRoute`, and their returned config types), so route trees produced under `exactOptionalPropertyTypes` — notably `fileRoutes()` applied to `filesystem-routing`'s generated manifest types, whose leaves carry `children: undefined` — type-check. The runtime already treated absent and `undefined` alike; the one presence check (`hasOwnProperty("path")`) now treats an explicit `path: undefined` as a pathless route too (#598). diff --git a/src/fs.ts b/src/fs.ts index c4b78bb4..0a89ce69 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -51,8 +51,8 @@ export type FileRouteConfig< > = TypedRouteConfig & ([F] extends [undefined] ? {} : DefinedRouteFilters extends F ? {} : { matchFilters: F }) & ([Sch] extends [undefined] ? {} : { search: Sch }) & { - preload?: RoutePreloadFunc; - info?: RouteInfo; + preload?: RoutePreloadFunc | undefined; + info?: RouteInfo | undefined; }; /** @@ -83,11 +83,11 @@ export function defineFileRoute< >( path: S, config: { - matchFilters?: F & ValidFilters; - preload?: (args: RoutePreloadFuncArgs>) => T; + matchFilters?: (F & ValidFilters) | undefined; + preload?: ((args: RoutePreloadFuncArgs>) => T) | undefined; /** Standard Schema validator for this route's search params; its input type flows into the typed path proxy. */ search?: Sch; - info?: RouteInfo; + info?: RouteInfo | undefined; } ): FileRouteConfig { return config as FileRouteConfig; @@ -101,7 +101,7 @@ export interface FileRouteLazyRef> { /** An eager module ref: its picked exports are imported statically. */ export interface FileRouteEagerRef> { - src?: string; + src?: string | undefined; require(): M; } diff --git a/src/routers/factory.tsx b/src/routers/factory.tsx index c341f1ca..dc8e64c6 100644 --- a/src/routers/factory.tsx +++ b/src/routers/factory.tsx @@ -95,9 +95,9 @@ export type DefinedRoute< ([F] extends [undefined] ? {} : DefinedRouteFilters extends F ? {} : { matchFilters: F }) & ([C] extends [undefined] ? {} : [RouteChildren | undefined] extends [C] ? {} : { children: C }) & ([Sch] extends [undefined] ? {} : { search: Sch }) & { - component?: RouteSectionComponent; - preload?: RoutePreloadFunc; - info?: RouteInfo; + component?: RouteSectionComponent | undefined; + preload?: RoutePreloadFunc | undefined; + info?: RouteInfo | undefined; }; /** @@ -125,13 +125,13 @@ export function defineRoute< Sch extends StandardSchemaV1 | undefined = undefined >(route: { path: S; - matchFilters?: F & ValidFilters; - preload?: (args: RoutePreloadFuncArgs>) => T; - component?: DefinedRouteComponent>; + matchFilters?: (F & ValidFilters) | undefined; + preload?: ((args: RoutePreloadFuncArgs>) => T) | undefined; + component?: DefinedRouteComponent> | undefined; children?: C; /** Standard Schema validator for this route's search params; its input type flows into the typed path proxy. */ search?: Sch; - info?: RouteInfo; + info?: RouteInfo | undefined; }): DefinedRoute; // pathless (layout) route — params stay the open `Params` record export function defineRoute< @@ -139,11 +139,11 @@ export function defineRoute< const C extends RouteChildren | undefined = RouteChildren | undefined, Sch extends StandardSchemaV1 | undefined = undefined >(route: { - preload?: (args: RoutePreloadFuncArgs) => T; - component?: DefinedRouteComponent; + preload?: ((args: RoutePreloadFuncArgs) => T) | undefined; + component?: DefinedRouteComponent | undefined; children?: C; search?: Sch; - info?: RouteInfo; + info?: RouteInfo | undefined; }): DefinedRoute; export function defineRoute(route: RouteDefinition): RouteDefinition { return route; diff --git a/src/routing.ts b/src/routing.ts index 180e1a37..32d3e851 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -591,7 +591,8 @@ export function createBranches( for (let i = 0, len = routeDefs.length; i < len; i++) { const def = routeDefs[i]; if (def && typeof def === "object") { - if (!def.hasOwnProperty("path")) def.path = ""; + // absent and explicitly `undefined` paths are the same pathless route + if (def.path === undefined) def.path = ""; const routes = createRoutes(def, base); for (const route of routes) { stack.push(route); diff --git a/src/types.ts b/src/types.ts index 79ac3b55..546f4fb5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -221,16 +221,21 @@ export type LazyRouteChildren = () => // `T` defaults to `any` (not `unknown`) so typed components/preloads are assignable // in annotated configs like `const routes: RouteDefinition[]`, where no inference -// site for `T` exists (#454) +// site for `T` exists (#454). +// +// Every optional property also admits an explicit `undefined`: the runtime +// treats an absent key and an `undefined` value identically, and trees built +// by adapters (`fileRoutes` sets `component`/`children` to `undefined` for +// leaves) must stay assignable under `exactOptionalPropertyTypes` (#598). export type RouteDefinition = { - path?: S; - matchFilters?: MatchFilters; - preload?: RoutePreloadFunc; - children?: RouteDefinition | readonly RouteDefinition[] | LazyRouteChildren; - component?: RouteSectionComponent; + path?: S | undefined; + matchFilters?: MatchFilters | undefined; + preload?: RoutePreloadFunc | undefined; + children?: RouteDefinition | readonly RouteDefinition[] | LazyRouteChildren | undefined; + component?: RouteSectionComponent | undefined; /** Standard Schema validator for this route's search params; its input type flows into the typed path proxy. */ - search?: StandardSchemaV1; - info?: RouteInfo; + search?: StandardSchemaV1 | undefined; + info?: RouteInfo | undefined; }; // Type-only circular import: `RouteInfo` must be *declared* in the package diff --git a/test/exact-optional-types.spec.ts b/test/exact-optional-types.spec.ts new file mode 100644 index 00000000..70ae6a56 --- /dev/null +++ b/test/exact-optional-types.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { resolve } from "node:path"; +import ts from "typescript"; + +/* + * The project's own type tests compile without `exactOptionalPropertyTypes` + * (the source doesn't opt in). Consumers may, so this compiles the fixture + * under the flag through the compiler API and reports only the fixture's + * diagnostics — the rest of the program is type-checked by `test:types`. + */ +describe("route definitions under exactOptionalPropertyTypes", () => { + it("accepts trees with present-but-undefined optional properties", () => { + const root = resolve(__dirname, ".."); + const fixture = resolve(root, "test/fixtures/exact-optional-routes.ts"); + const { config } = ts.readConfigFile(resolve(root, "tsconfig.json"), ts.sys.readFile); + const { options } = ts.parseJsonConfigFileContent(config, ts.sys, root); + + const program = ts.createProgram([fixture], { + ...options, + rootDir: undefined, + noEmit: true, + exactOptionalPropertyTypes: true + }); + const source = program.getSourceFile(fixture)!; + const diagnostics = [ + ...program.getSyntacticDiagnostics(source), + ...program.getSemanticDiagnostics(source) + ].map(d => { + const { line } = source.getLineAndCharacterOfPosition(d.start ?? 0); + return `${line + 1}: ${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`; + }); + + expect(diagnostics).toEqual([]); + }); +}); diff --git a/test/fixtures/exact-optional-routes.ts b/test/fixtures/exact-optional-routes.ts new file mode 100644 index 00000000..1bcd5da5 --- /dev/null +++ b/test/fixtures/exact-optional-routes.ts @@ -0,0 +1,108 @@ +/* + * Type-only fixture, compiled by `test/exact-optional-types.spec.ts` with + * `exactOptionalPropertyTypes: true` (the regular type test compiles it too, + * without the flag). Under that flag an optional property only admits an + * explicit `undefined` when its type says so — so route trees that carry + * present-but-`undefined` keys, like the ones `filesystem-routing`'s + * generated declaration describes and `fileRoutes` produces, must stay + * assignable to `RouteDefinition` (#598). + */ +import { createRouter, defineRoute } from "../../src/index.js"; +import { defineFileRoute, fileRoutes } from "../../src/fs.js"; +import type { FileRouteEagerRef, FileRouteLazyRef } from "../../src/fs.js"; +import type { RouteDefinition } from "../../src/types.js"; + +type Page = { default: () => null }; + +const postRoute = defineFileRoute("/users/:id", { + preload: ({ params }) => params.id, + info: { section: "users" } +}); + +// The nested `pageRoutes` view exactly as the plugin's `types` option +// declares it: absent refs and children are present-but-`undefined` keys. +declare const pageRoutes: readonly [ + { + path: "/"; + id: "/"; + page: true; + $component: FileRouteLazyRef; + $$route?: undefined; + children?: undefined; + }, + { + path: "/*404"; + id: "/*404"; + page: true; + $component: FileRouteLazyRef; + $$route?: undefined; + children?: undefined; + }, + { + path: "/users"; + id: "/users"; + page: true; + $component: FileRouteLazyRef; + $$route?: undefined; + children: readonly [ + { + path: "/:id"; + id: "/:id"; + page: true; + $component: FileRouteLazyRef; + $$route: FileRouteEagerRef<{ route: typeof postRoute }>; + children?: undefined; + } + ]; + } +]; + +// the adapter's output drops into a route tree... +const routes = fileRoutes(pageRoutes); +const _definitions: readonly RouteDefinition[] = routes; + +// ...and into a router, with typed paths intact +const Router = createRouter({ routes: fileRoutes(pageRoutes) }); +const _users: string = Router.paths.users(1)(); +// @ts-expect-error not a route +Router.paths.nope; + +// Hand-written definitions may spell absent properties out as `undefined` +const _explicit: RouteDefinition[] = [ + { + path: "/", + component: undefined, + preload: undefined, + children: undefined, + matchFilters: undefined, + search: undefined, + info: undefined + }, + { path: undefined, children: [{ path: "/nested" }] } +]; + +const _defined: RouteDefinition = defineRoute({ + path: "/defined/:id", + component: undefined, + preload: undefined, + children: undefined, + matchFilters: undefined, + search: undefined, + info: undefined +}); + +const _layout: RouteDefinition = defineRoute({ + component: undefined, + preload: undefined, + children: undefined, + info: undefined +}); + +const _fileConfig: RouteDefinition = defineFileRoute("/file/:id", { + preload: undefined, + matchFilters: undefined, + search: undefined, + info: undefined +}); + +export {};