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
5 changes: 5 additions & 0 deletions .changeset/exact-optional-route-definition.md
Original file line number Diff line number Diff line change
@@ -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).
12 changes: 6 additions & 6 deletions src/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ export type FileRouteConfig<
> = TypedRouteConfig<S> &
([F] extends [undefined] ? {} : DefinedRouteFilters<S> extends F ? {} : { matchFilters: F }) &
([Sch] extends [undefined] ? {} : { search: Sch }) & {
preload?: RoutePreloadFunc<T>;
info?: RouteInfo;
preload?: RoutePreloadFunc<T> | undefined;
info?: RouteInfo | undefined;
};

/**
Expand Down Expand Up @@ -83,11 +83,11 @@ export function defineFileRoute<
>(
path: S,
config: {
matchFilters?: F & ValidFilters<F, S>;
preload?: (args: RoutePreloadFuncArgs<RouteParams<S>>) => T;
matchFilters?: (F & ValidFilters<F, S>) | undefined;
preload?: ((args: RoutePreloadFuncArgs<RouteParams<S>>) => 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<S, T, F, Sch> {
return config as FileRouteConfig<S, T, F, Sch>;
Expand All @@ -101,7 +101,7 @@ export interface FileRouteLazyRef<M = Record<string, unknown>> {

/** An eager module ref: its picked exports are imported statically. */
export interface FileRouteEagerRef<M = Record<string, unknown>> {
src?: string;
src?: string | undefined;
require(): M;
}

Expand Down
20 changes: 10 additions & 10 deletions src/routers/factory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,9 @@ export type DefinedRoute<
([F] extends [undefined] ? {} : DefinedRouteFilters<S> extends F ? {} : { matchFilters: F }) &
([C] extends [undefined] ? {} : [RouteChildren | undefined] extends [C] ? {} : { children: C }) &
([Sch] extends [undefined] ? {} : { search: Sch }) & {
component?: RouteSectionComponent<T>;
preload?: RoutePreloadFunc<T>;
info?: RouteInfo;
component?: RouteSectionComponent<T> | undefined;
preload?: RoutePreloadFunc<T> | undefined;
info?: RouteInfo | undefined;
};

/**
Expand Down Expand Up @@ -125,25 +125,25 @@ export function defineRoute<
Sch extends StandardSchemaV1<any, any> | undefined = undefined
>(route: {
path: S;
matchFilters?: F & ValidFilters<F, S>;
preload?: (args: RoutePreloadFuncArgs<RouteParams<S>>) => T;
component?: DefinedRouteComponent<T, RouteParams<S>>;
matchFilters?: (F & ValidFilters<F, S>) | undefined;
preload?: ((args: RoutePreloadFuncArgs<RouteParams<S>>) => T) | undefined;
component?: DefinedRouteComponent<T, RouteParams<S>> | 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<S, T, F, C, Sch>;
// pathless (layout) route — params stay the open `Params` record
export function defineRoute<
T = unknown,
const C extends RouteChildren | undefined = RouteChildren | undefined,
Sch extends StandardSchemaV1<any, any> | undefined = undefined
>(route: {
preload?: (args: RoutePreloadFuncArgs) => T;
component?: DefinedRouteComponent<T, Params>;
preload?: ((args: RoutePreloadFuncArgs) => T) | undefined;
component?: DefinedRouteComponent<T, Params> | undefined;
children?: C;
search?: Sch;
info?: RouteInfo;
info?: RouteInfo | undefined;
}): DefinedRoute<undefined, T, undefined, C, Sch>;
export function defineRoute(route: RouteDefinition): RouteDefinition {
return route;
Expand Down
3 changes: 2 additions & 1 deletion src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 13 additions & 8 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<S extends string | string[] = any, T = any> = {
path?: S;
matchFilters?: MatchFilters<S>;
preload?: RoutePreloadFunc<T>;
children?: RouteDefinition | readonly RouteDefinition[] | LazyRouteChildren;
component?: RouteSectionComponent<T>;
path?: S | undefined;
matchFilters?: MatchFilters<S> | undefined;
preload?: RoutePreloadFunc<T> | undefined;
children?: RouteDefinition | readonly RouteDefinition[] | LazyRouteChildren | undefined;
component?: RouteSectionComponent<T> | undefined;
/** Standard Schema validator for this route's search params; its input type flows into the typed path proxy. */
search?: StandardSchemaV1<any, any>;
info?: RouteInfo;
search?: StandardSchemaV1<any, any> | undefined;
info?: RouteInfo | undefined;
};

// Type-only circular import: `RouteInfo` must be *declared* in the package
Expand Down
35 changes: 35 additions & 0 deletions test/exact-optional-types.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
108 changes: 108 additions & 0 deletions test/fixtures/exact-optional-routes.ts
Original file line number Diff line number Diff line change
@@ -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<Page>;
$$route?: undefined;
children?: undefined;
},
{
path: "/*404";
id: "/*404";
page: true;
$component: FileRouteLazyRef<Page>;
$$route?: undefined;
children?: undefined;
},
{
path: "/users";
id: "/users";
page: true;
$component: FileRouteLazyRef<Page>;
$$route?: undefined;
children: readonly [
{
path: "/:id";
id: "/:id";
page: true;
$component: FileRouteLazyRef<Page>;
$$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 {};
Loading