diff --git a/packages/esroute/README.md b/packages/esroute/README.md index 32110ab..54e5d97 100644 --- a/packages/esroute/README.md +++ b/packages/esroute/README.md @@ -125,24 +125,36 @@ router.go("/foo"); // ✓ router.go("/baz"); // TS Error: Argument of type '"/baz"' is not assignable ``` -#### Typed state +#### Typed state and search -Route handlers can declare the history state type they require by typing the `NavOpts` parameter. When navigating to such a route, TypeScript enforces that you provide the correct state — and inside the handler, `state` is typed directly without null checks: +Route handlers can declare the history state and search params they require with `route()`. When navigating to such a route, TypeScript enforces that you provide the correct metadata: ```ts const routes = { - profile: (navOpts: NavOpts<{ userId: string }>) => - loadProfile(navOpts.state.userId), + profile: route<{ + state: { userId: string }; + search: { tab: string }; + }>(({ state, search }) => loadProfile(state.userId, search.tab)), } satisfies Routes; const router = createRouter({ routes }); -router.go("/profile"); // TS Error: state required -router.go("/profile", { state: { userId: "123" } }); // ✓ -router.go("/profile", { state: { userId: 42 } }); // TS Error: number is not string +router.go("/profile"); // TS Error: state and search required +router.go("/profile", { + state: { userId: "123" }, + search: { tab: "settings" }, +}); // ✓ +router.go("/profile", { state: { userId: 42 } }); // TS Error ``` -State defaults to `null` when not provided, consistent with the History API. Routes without an explicit state type leave it as `null`. +The equivalent explicit handler type is: + +```ts +profile: ({ state, search }: NavOpts<{ userId: string }, { tab: string }>) => + loadProfile(state.userId, search.tab), +``` + +At runtime, state defaults to `null` when not provided, consistent with the History API. ### 🏎 Fast startup and runtime diff --git a/packages/esroute/src/nav-opts.ts b/packages/esroute/src/nav-opts.ts index f6f2f51..6740e41 100644 --- a/packages/esroute/src/nav-opts.ts +++ b/packages/esroute/src/nav-opts.ts @@ -1,8 +1,11 @@ export type PathOrHref = string | string[]; -export interface NavMeta { +export interface NavMeta< + S = never, + Search extends Record = Record +> { /** The search query object. */ - search?: Record; + search?: Search; /** The state to push. */ state?: S | null; /** The location hash. */ @@ -19,7 +22,10 @@ export interface NavMeta { skipRender?: boolean; } -export type StrictNavMeta = NavMeta & +export type StrictNavMeta< + S = never, + Search extends Record = Record +> = NavMeta & ( | { path: string[]; @@ -29,20 +35,26 @@ export type StrictNavMeta = NavMeta & } ); -export class NavOpts implements NavMeta { +export class NavOpts< + S = never, + Search extends Record = Record +> implements NavMeta { readonly state: S; readonly params: string[] = []; readonly hash?: string; readonly replace?: boolean; readonly skipRender?: boolean; readonly path: string[]; - readonly search: Record; + readonly search: Search; readonly pop?: boolean; private _h?: string; - constructor(target: StrictNavMeta); - constructor(target: PathOrHref, opts?: NavMeta); - constructor(target: PathOrHref | StrictNavMeta, opts: NavMeta = {}) { + constructor(target: StrictNavMeta); + constructor(target: PathOrHref, opts?: NavMeta); + constructor( + target: PathOrHref | StrictNavMeta, + opts: NavMeta = {} + ) { let { path, href, hash, pop, replace, search, state, skipRender } = typeof target === "string" || Array.isArray(target) ? opts : target; if (path) this.path = path; @@ -57,7 +69,7 @@ export class NavOpts implements NavMeta { if (searchString) this.search = Object.fromEntries( new URLSearchParams(searchString).entries() - ); + ) as Search; if (parsedHash) this.hash = parsedHash; } else this.path = target as string[]; if (hash != null) this.hash = hash; @@ -66,7 +78,7 @@ export class NavOpts implements NavMeta { this.state = (state ?? null) as S; if (replace != null) this.replace = replace; if (skipRender != null) this.skipRender = skipRender; - this.search ??= {}; + this.search ??= {} as Search; } get href() { @@ -79,8 +91,8 @@ export class NavOpts implements NavMeta { } get go() { - return (path: PathOrHref, opts: NavMeta = {}) => - new NavOpts(path, { + return (path: PathOrHref, opts: NavMeta = {}) => + new NavOpts(path, { search: opts.search, state: opts.state, replace: opts.replace ?? this.replace, diff --git a/packages/esroute/src/route-resolver.ts b/packages/esroute/src/route-resolver.ts index 95e7b08..1ac7fe8 100644 --- a/packages/esroute/src/route-resolver.ts +++ b/packages/esroute/src/route-resolver.ts @@ -1,28 +1,40 @@ import { NavOpts } from "./nav-opts.js"; import { Resolve, Routes } from "./routes.js"; -export interface Resolved { +export interface Resolved< + T, + S = never, + Search extends Record = Record +> { /** The resolved value of the route. */ value: T; /** The final navigation options after all redirecting. */ - opts: NavOpts; + opts: NavOpts; } -export type RouteResolver = ( - routes: Routes, - opts: NavOpts, - notFound: Resolve -) => Promise>; +export type RouteResolver< + T, + S = never, + Search extends Record = Record +> = ( + routes: Routes, + opts: NavOpts, + notFound: Resolve +) => Promise>; const MAX_REDIRECTS = 10; -export const resolve = async ( - routes: Routes, - opts: NavOpts, - notFound: Resolve -): Promise> => { - let value: NavOpts | T = opts; - const navPath: NavOpts[] = []; +export const resolve = async < + T, + S = never, + Search extends Record = Record +>( + routes: Routes, + opts: NavOpts, + notFound: Resolve +): Promise> => { + let value: NavOpts | T = opts; + const navPath: NavOpts[] = []; while (value instanceof NavOpts && navPath.length <= MAX_REDIRECTS) { opts = value; navPath.push(opts); @@ -44,18 +56,25 @@ export const resolve = async ( }; const getResolves = async ( - root: Routes, - opts: NavOpts -): Promise => { + root: Routes, + opts: NavOpts +): Promise[] | null> => { const { path, params } = opts; - const resolves: Resolve[] = []; - let routes: Routes | Resolve | null | undefined = root; + const resolves: Resolve[] = []; + let routes: + | Routes + | Resolve + | null + | undefined = root; for (let i = 0; i < path.length; i++) { const part = path[i]; if (!routes || typeof routes === "function") return null; const redirect = await checkGuard(routes, opts); if (redirect) return [() => redirect]; - const virtual: Routes | Resolve | undefined = routes[""]; + const virtual: + | Routes + | Resolve + | undefined = routes[""]; if (typeof virtual === "function") resolves.unshift(virtual); if (part in routes) routes = routes[part]; else if ("*" in routes) { @@ -80,7 +99,10 @@ const getResolves = async ( return null; }; -const checkGuard = async (routes: Routes, opts: NavOpts) => { +const checkGuard = async ( + routes: Routes, + opts: NavOpts +) => { if (typeof routes["?"] === "function") { const guardResult = await routes["?"](opts); if (guardResult instanceof NavOpts) return guardResult; diff --git a/packages/esroute/src/router.spec.ts b/packages/esroute/src/router.spec.ts index 9dee536..bd4a922 100644 --- a/packages/esroute/src/router.spec.ts +++ b/packages/esroute/src/router.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { NavOpts } from "./nav-opts.js"; -import { Routes } from "./routes.js"; +import { route, Routes } from "./routes.js"; import { createRouter } from "./router.js"; // Define routes with proper typing to verify RoutePaths inference @@ -10,9 +10,15 @@ const routes = { foo: () => "foo", fail: () => Promise.reject(new Error("test")), bar: () => "bar", + "some-route": route<{ + state: { foo: number }; + search: { bar: string }; + }>(({ state: { foo }, search: { bar } }) => `${foo}:${bar}`), + baz: ({ go }) => go("/non-existing"), // should fail x: { "": ({ state }: NavOpts<{ a: boolean }>) => (state.a ? "b" : "c"), - y: () => "x", + y: ({ state }: NavOpts) => state.toString(), + "*": ({ state }: NavOpts) => state, }, } satisfies Routes; @@ -130,7 +136,7 @@ describe("Router", () => { }); it("should replace the state by default, if target is a mapping funciton", async () => { - await router.go("/foo", { search: { a: "b" } }); + await router.go(["foo"], { search: { a: "b" } }); await router.go(() => ({ search: { a: "c" } })); expect(history.replaceState).toHaveBeenCalledWith(null, "", "/foo?a=c"); @@ -229,12 +235,39 @@ describe("Router", () => { // /x requires state: { a: boolean } — omitting state is a type error (see go() test above) // Providing the required state is valid: await router.go("/x", { state: { a: true } }); + await router.go("/x/y", { state: 32 }); + await router.go("/x/ntesr", { state: "4" }); }); - it("should not allow state when the route handler declares no state type", async () => { + it("should type route() state and search contracts", async () => { router.init(); - // @ts-expect-error - /foo has no state type, passing state should be a type error - await router.go("/foo", { state: { a: true } }); + + await router.go("/some-route", { + state: { foo: 1 }, + search: { bar: "baz" }, + }); + + if (false) { + // @ts-expect-error - search is required by route() + await router.go("/some-route", { state: { foo: 1 } }); + // @ts-expect-error - state is required by route() + await router.go("/some-route", { search: { bar: "baz" } }); + // @ts-expect-error - search.bar must be a string + await router.go("/some-route", { + state: { foo: 1 }, + search: { bar: 1 }, + }); + } + }); + + it("should not allow state or search when the route handler declares no meta type", async () => { + router.init(); + if (false) { + // @ts-expect-error - /foo has no state type, passing state should be a type error + await router.go("/foo", { state: { a: true } }); + // @ts-expect-error - /foo has no search type, passing search should be a type error + await router.go("/foo", { search: { a: "b" } }); + } }); }); }); diff --git a/packages/esroute/src/router.ts b/packages/esroute/src/router.ts index c53bdb3..82b194d 100644 --- a/packages/esroute/src/router.ts +++ b/packages/esroute/src/router.ts @@ -6,23 +6,32 @@ import { RoutePaths, RawRoutes, HandlerFor, - StateOf, - NeedsState, + NavMetaFor, + NeedsNavMeta, } from "./routes.js"; -export type OnResolveListener = (resolved: Resolved) => void; -export interface Router { +export type OnResolveListener< + T, + S = never, + Search extends Record = Record +> = (resolved: Resolved) => void; +export interface Router< + T = any, + S = never, + R extends RawRoutes = RawRoutes, + Search extends Record = Record +> { /** * The routes configuration. * You may modify this object to change the routes. * Be sure to call `router.init()` after the current route is configured. */ - routes: Routes; + routes: Routes; /** * The current resolved route. * It is updated after each route resolution. */ - readonly current: NavOpts; + readonly current: NavOpts; /** * Triggers a navigation. * You can modify the navigation options by passing in a second argument. @@ -35,22 +44,25 @@ export interface Router { * @param opts The navigation metadata. */ go( - target: number | StrictNavMeta | ((prev: NavOpts) => NavMeta) + target: + | number + | StrictNavMeta + | ((prev: NavOpts) => NavMeta) ): Promise; - /** Navigate to a typed route path, enforcing the required state type for that route. */ + /** Navigate to a typed route path, enforcing the required meta for that route. */ go

>( target: P, - ...opts: NeedsState> extends true - ? [opts: NavMeta>> & { state: StateOf> }] - : [opts?: Omit>>, "state">] + ...opts: NeedsNavMeta> extends true + ? [opts: NavMetaFor>] + : [opts?: NavMetaFor>] ): Promise; - go(target: string[], opts?: NavMeta): Promise; + go(target: string[], opts?: NavMeta): Promise; /** * Use this to listen for route changes. * Returns an unsubscribe function. * @param listener The listener that receives a Resolved object. */ - onResolve(listener: OnResolveListener): () => void; + onResolve(listener: OnResolveListener): () => void; /** * Initializes the router: Starts listening for events, resolves the current * route and calls the `onResolve` listeners. @@ -63,7 +75,7 @@ export interface Router { /** * Use this to wait for the current navigation to complete. */ - resolution?: Promise>; + resolution?: Promise>; /** * Use this to render the current route (history and location). * @param defer A function that defers rendering and can be used to trigger multiple successive @@ -72,17 +84,22 @@ export interface Router { render(defer?: () => Promise): Promise; } -export interface RouterConf { +export interface RouterConf< + T = any, + S = never, + R extends RawRoutes = RawRoutes, + Search extends Record = Record +> { /** * The routes configuration. You can modify this object later. * Make sure, the current route is in place before you call `router.init()`. */ - routes?: R & Routes; + routes?: R & Routes; /** * A fallback resolve funuction to use, if a route could not be found. * By default it redirects to the root path '/'. */ - notFound?: Resolve; + notFound?: Resolve; /** * Whether the click handler for anchor elements shall not be installed. * This might make sense, if you want to take more control over how anchor @@ -92,22 +109,27 @@ export interface RouterConf { /** * A callback that is invoked whenever a route is resolved. */ - onResolve?: OnResolveListener; + onResolve?: OnResolveListener; } -export const createRouter = ({ - routes = {} as R & Routes, - notFound = ({ go }: NavOpts) => go([]), +export const createRouter = < + T = any, + S = never, + R extends RawRoutes = RawRoutes, + Search extends Record = Record +>({ + routes = {} as R & Routes, + notFound = ({ go }: NavOpts) => go([]), noClick = false, onResolve, -}: RouterConf = {}): Router => { - let _current: Resolved; - const _listeners = new Set>( +}: RouterConf = {}): Router => { + let _current: Resolved; + const _listeners = new Set>( onResolve ? [onResolve] : [] ); - let resolution: Promise>; + let resolution: Promise>; let skipRender = false; - const r: Router = { + const r: Router = { routes, get current() { return _current.opts; @@ -127,11 +149,11 @@ export const createRouter = ( async go( target: | number - | StrictNavMeta - | ((prev: NavOpts) => NavMeta) + | StrictNavMeta + | ((prev: NavOpts) => NavMeta) | RoutePaths | PathOrHref, - opts?: NavMeta + opts?: NavMeta ): Promise { // Serialize all navigaton requests const prevRes = await this.resolution; @@ -163,13 +185,13 @@ export const createRouter = ( resolvedTarget instanceof NavOpts ? resolvedTarget : typeof resolvedTarget === "string" || Array.isArray(resolvedTarget) - ? new NavOpts(resolvedTarget as PathOrHref, opts) - : new NavOpts(resolvedTarget as StrictNavMeta); + ? new NavOpts(resolvedTarget as PathOrHref, opts) + : new NavOpts(resolvedTarget as StrictNavMeta); if (navOpts.skipRender || skipRender) return updateState(navOpts); const res = await applyResolution(resolve(r.routes, navOpts, notFound)); updateState(res.opts); }, - onResolve(listener: OnResolveListener) { + onResolve(listener: OnResolveListener) { _listeners.add(listener); if (_current) listener(_current); return () => _listeners.delete(listener); @@ -220,7 +242,7 @@ export const createRouter = ( const resolveCurrent = async (e?: PopStateEvent) => { const { href, origin } = window.location; - const initialOpts = new NavOpts(href.substring(origin.length), { + const initialOpts = new NavOpts(href.substring(origin.length), { state: e ? e.state : history.state, pop: !!e, }); @@ -230,7 +252,7 @@ export const createRouter = ( if (opts !== initialOpts) { updateState( - new NavOpts(opts.path, { + new NavOpts(opts.path, { replace: true, search: opts.search, state: opts.state, @@ -240,7 +262,7 @@ export const createRouter = ( } }; - const applyResolution = async (res: Promise>) => { + const applyResolution = async (res: Promise>) => { resolution = res; try { const resolved = await res; @@ -252,7 +274,7 @@ export const createRouter = ( } }; - const updateState = ({ state, replace, href }: NavOpts) => { + const updateState = ({ state, replace, href }: NavOpts) => { if (replace) history.replaceState(state, "", href); else history.pushState(state, "", href); }; diff --git a/packages/esroute/src/routes.ts b/packages/esroute/src/routes.ts index f9822a7..fff8416 100644 --- a/packages/esroute/src/routes.ts +++ b/packages/esroute/src/routes.ts @@ -1,17 +1,70 @@ -import { NavOpts } from "./nav-opts.js"; +import { NavMeta, NavOpts } from "./nav-opts.js"; export type RawRoutes = { [k: string]: RawRoutes | ((...args: any[]) => any); }; -export type Resolve = ( - navOpts: NavOpts, +export interface RouteContract { + state?: any; + search?: Record; +} + +declare const routeContract: unique symbol; + +type ContractState = C extends { state?: infer S } ? S : never; +type ContractSearch = C extends { + search?: infer Search extends Record; +} + ? Search + : Record; + +type HasContract = typeof routeContract extends keyof F ? true : false; +type ContractOf = F extends { readonly [routeContract]?: infer C } + ? C + : never; +type HasContractState = HasContract extends true + ? "state" extends keyof ContractOf + ? true + : false + : false; +type HasContractSearch = HasContract extends true + ? "search" extends keyof ContractOf + ? true + : false + : false; +type FirstArg = F extends (...args: infer Args) => any + ? Args extends [infer First, ...any[]] + ? First + : never + : never; + +export type Resolve< + T = any, + S = never, + Search extends Record = Record +> = ( + navOpts: NavOpts, next?: T -) => T | NavOpts | Promise>; +) => T | NavOpts | Promise>; + +export type Route< + T = any, + C extends RouteContract = RouteContract +> = Resolve, ContractSearch> & { + readonly [routeContract]?: C; +}; -export interface Routes { - "?"?: Resolve; - [k: string]: Routes | Resolve | undefined; +export const route = ( + resolve: Resolve, ContractSearch> +): Route => resolve as Route; + +export interface Routes< + T = any, + S = never, + Search extends Record = Record +> { + "?"?: Resolve; + [k: string]: Routes | Resolve | undefined; } // Depth counter using a string to track recursion depth (max 10 levels) @@ -22,7 +75,9 @@ export type RoutePaths< R extends RawRoutes, Prefix extends string = "", D extends string = "" -> = IsMaxDepth extends true +> = string extends keyof R + ? string + : IsMaxDepth extends true ? string : { [K in keyof R & string]: K extends "?" @@ -65,35 +120,78 @@ type _NavigateRoutes = : Rest extends [] ? R[First] : never + : "*" extends keyof R + ? R["*"] extends RawRoutes + ? _NavigateRoutes + : Rest extends [] + ? R["*"] + : never : never : never; /** Resolves to the route handler function for a given path string. */ -export type HandlerFor = _NavigateRoutes< - R, - _PathParts

->; +export type HandlerFor = string extends keyof R + ? () => any + : _NavigateRoutes>; /** - * Extracts the state type S from a route handler typed as - * `(navOpts: NavOpts, ...) => any`. + * Extracts the state type S from either a route() contract or a route handler + * typed as `(navOpts: NavOpts, ...) => any`. */ -export type StateOf = F extends ( - navOpts: NavOpts, - ...rest: any[] -) => any +export type StateOf = HasContract extends true + ? ContractState> + : [FirstArg] extends [never] + ? never + : [FirstArg] extends [NavOpts] ? S - : any; + : never; + +/** + * Extracts the search type from either a route() contract or a route handler + * typed as `(navOpts: NavOpts, ...) => any`. + */ +export type SearchOf = HasContract extends true + ? ContractSearch> + : [FirstArg] extends [never] + ? Record + : [FirstArg] extends [ + NavOpts> + ] + ? Search + : Record; /** - * True when the route handler specifies a concrete (non-`any`, non-`unknown`, - * non-`undefined`) state type, indicating callers must provide `state` when - * navigating to it. + * True when the route handler specifies a concrete state type, indicating + * callers must provide `state` when navigating to it. * - * Uses `unknown extends T` which is only true when T is `any` or `unknown`. + * route() contracts require state when the contract contains a `state` key. + * Legacy NavOpts-typed handlers use `unknown extends T` which is only true + * when T is `any` or `unknown`. */ -export type NeedsState = unknown extends StateOf +export type NeedsState = HasContractState extends true + ? true + : unknown extends StateOf ? false : StateOf extends null | undefined ? false : true; + +/** True when a route declares non-empty search metadata. */ +export type NeedsSearch = HasContractSearch extends true + ? true + : keyof SearchOf extends never + ? false + : true; + +export type NavMetaFor = Omit< + NavMeta, SearchOf>, + "state" | "search" +> & + (NeedsState extends true ? { state: StateOf } : { state?: never }) & + (NeedsSearch extends true ? { search: SearchOf } : { search?: never }); + +export type NeedsNavMeta = NeedsState extends true + ? true + : NeedsSearch extends true + ? true + : false;