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
28 changes: 20 additions & 8 deletions packages/esroute/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;

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

Expand Down
36 changes: 24 additions & 12 deletions packages/esroute/src/nav-opts.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
export type PathOrHref = string | string[];

export interface NavMeta<S = any> {
export interface NavMeta<
S = never,
Search extends Record<string, string> = Record<never, never>
> {
/** The search query object. */
search?: Record<string, string>;
search?: Search;
/** The state to push. */
state?: S | null;
/** The location hash. */
Expand All @@ -19,7 +22,10 @@ export interface NavMeta<S = any> {
skipRender?: boolean;
}

export type StrictNavMeta<S = any> = NavMeta<S> &
export type StrictNavMeta<
S = never,
Search extends Record<string, string> = Record<never, never>
> = NavMeta<S, Search> &
(
| {
path: string[];
Expand All @@ -29,20 +35,26 @@ export type StrictNavMeta<S = any> = NavMeta<S> &
}
);

export class NavOpts<S = null> implements NavMeta<S> {
export class NavOpts<
S = never,
Search extends Record<string, string> = Record<never, never>
> implements NavMeta<S, Search> {
readonly state: S;
readonly params: string[] = [];
readonly hash?: string;
readonly replace?: boolean;
readonly skipRender?: boolean;
readonly path: string[];
readonly search: Record<string, string>;
readonly search: Search;
readonly pop?: boolean;
private _h?: string;

constructor(target: StrictNavMeta<S>);
constructor(target: PathOrHref, opts?: NavMeta<S>);
constructor(target: PathOrHref | StrictNavMeta<S>, opts: NavMeta<S> = {}) {
constructor(target: StrictNavMeta<S, Search>);
constructor(target: PathOrHref, opts?: NavMeta<S, Search>);
constructor(
target: PathOrHref | StrictNavMeta<S, Search>,
opts: NavMeta<S, Search> = {}
) {
let { path, href, hash, pop, replace, search, state, skipRender } =
typeof target === "string" || Array.isArray(target) ? opts : target;
if (path) this.path = path;
Expand All @@ -57,7 +69,7 @@ export class NavOpts<S = null> implements NavMeta<S> {
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;
Expand All @@ -66,7 +78,7 @@ export class NavOpts<S = null> implements NavMeta<S> {
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() {
Expand All @@ -79,8 +91,8 @@ export class NavOpts<S = null> implements NavMeta<S> {
}

get go() {
return (path: PathOrHref, opts: NavMeta<S> = {}) =>
new NavOpts<S>(path, {
return (path: PathOrHref, opts: NavMeta<S, Search> = {}) =>
new NavOpts<S, Search>(path, {
search: opts.search,
state: opts.state,
replace: opts.replace ?? this.replace,
Expand Down
64 changes: 43 additions & 21 deletions packages/esroute/src/route-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,40 @@
import { NavOpts } from "./nav-opts.js";
import { Resolve, Routes } from "./routes.js";

export interface Resolved<T, S = any> {
export interface Resolved<
T,
S = never,
Search extends Record<string, string> = Record<never, never>
> {
/** The resolved value of the route. */
value: T;
/** The final navigation options after all redirecting. */
opts: NavOpts<S>;
opts: NavOpts<S, Search>;
}

export type RouteResolver<T, S = any> = (
routes: Routes<T, S>,
opts: NavOpts<S>,
notFound: Resolve<T, S>
) => Promise<Resolved<T, S>>;
export type RouteResolver<
T,
S = never,
Search extends Record<string, string> = Record<never, never>
> = (
routes: Routes<T, S, Search>,
opts: NavOpts<S, Search>,
notFound: Resolve<T, S, Search>
) => Promise<Resolved<T, S, Search>>;

const MAX_REDIRECTS = 10;

export const resolve = async <T, S = any>(
routes: Routes<T, S>,
opts: NavOpts<S>,
notFound: Resolve<T, S>
): Promise<Resolved<T, S>> => {
let value: NavOpts<S> | T = opts;
const navPath: NavOpts<S>[] = [];
export const resolve = async <
T,
S = never,
Search extends Record<string, string> = Record<never, never>
>(
routes: Routes<T, S, Search>,
opts: NavOpts<S, Search>,
notFound: Resolve<T, S, Search>
): Promise<Resolved<T, S, Search>> => {
let value: NavOpts<S, Search> | T = opts;
const navPath: NavOpts<S, Search>[] = [];
while (value instanceof NavOpts && navPath.length <= MAX_REDIRECTS) {
opts = value;
navPath.push(opts);
Expand All @@ -44,18 +56,25 @@ export const resolve = async <T, S = any>(
};

const getResolves = async (
root: Routes,
opts: NavOpts<any>
): Promise<Resolve[] | null> => {
root: Routes<any, any, any>,
opts: NavOpts<any, any>
): Promise<Resolve<any, any, any>[] | null> => {
const { path, params } = opts;
const resolves: Resolve[] = [];
let routes: Routes | Resolve | null | undefined = root;
const resolves: Resolve<any, any, any>[] = [];
let routes:
| Routes<any, any, any>
| Resolve<any, any, any>
| 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<any, any, any>
| Resolve<any, any, any>
| undefined = routes[""];
if (typeof virtual === "function") resolves.unshift(virtual);
if (part in routes) routes = routes[part];
else if ("*" in routes) {
Expand All @@ -80,7 +99,10 @@ const getResolves = async (
return null;
};

const checkGuard = async (routes: Routes, opts: NavOpts<any>) => {
const checkGuard = async (
routes: Routes<any, any, any>,
opts: NavOpts<any, any>
) => {
if (typeof routes["?"] === "function") {
const guardResult = await routes["?"](opts);
if (guardResult instanceof NavOpts) return guardResult;
Expand Down
45 changes: 39 additions & 6 deletions packages/esroute/src/router.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<number>) => state.toString(),
"*": ({ state }: NavOpts<string>) => state,
},
} satisfies Routes<string>;

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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" } });
}
});
});
});
Loading
Loading