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
79 changes: 61 additions & 18 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,17 @@
"todomvc-app-css": "^2.4.2",
"todomvc-common": "^1.0.5",
"ts-jest": "^28.0.5",
"typescript": "^4.7.3",
"typescript": "5.0.2",
"vite": "^3.1.3",
"vite-tsconfig-paths": "^3.5.0",
"xstate": "^4.33.0"
"xstate": "^4.33.0",
"zod": "^3.25.76"
},
"peerDependencies": {
"@xstate/react": "^3.x",
"react": ">= 16.8.0 < 19.0.0",
"xstate": ">= 4.20 < 5.0.0",
"zod": "^3.x"
"zod": "^3.x || ^4.x"
},
"scripts": {
"lint": "eslint 'src/**/*'",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export {
useActiveRouteEvents,
TestRoutingContext,
useOnRoute,
type RouteSchema,
} from "./routing";
export { loggingMetaOptions } from "./useService";
export { lazy } from "./lazy";
37 changes: 25 additions & 12 deletions src/routing/createRoute/createRoute.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import { match, compile } from "path-to-regexp";
import { parse, ParsedQuery, stringify } from "query-string";
import * as Z from "zod";

import { XstateTreeHistory } from "../../types";
import { type IsEmptyObject } from "../../utils";
import { joinRoutes } from "../joinRoutes";

/**
* The surface of a zod object schema that routing relies on. It is deliberately
* structural rather than `Z.ZodObject` so that schemas built with either zod 3 or
* zod 4 (`zod` or `zod/v4`) are accepted: both expose `parse`, `merge` and the
* `_output` type marker this file reads.
*
* @public
*/
export interface RouteSchema<TOutput = any> {
_output: TOutput;
parse(data: unknown): TOutput;
merge(other: RouteSchema): RouteSchema;
}

type EmptyKeys<T> = keyof {
[K in keyof T as IsEmptyObject<T[K], true> extends true ? K : never]: T[K];
};
Expand Down Expand Up @@ -162,8 +175,8 @@ export type Route<TParams, TQuery, TEvent, TMeta> = {
history: () => XstateTreeHistory;
basePath: string;
parent?: AnyRoute;
paramsSchema?: Z.ZodObject<any>;
querySchema?: Z.ZodObject<any>;
paramsSchema?: RouteSchema;
querySchema?: RouteSchema;
redirect?: RouteRedirect<TParams, TQuery, TMeta>;
/**
* Optional predicate to control whether this route can be matched.
Expand All @@ -186,8 +199,8 @@ export type AnyRoute = {
basePath: string;
history: () => XstateTreeHistory;
parent?: AnyRoute;
paramsSchema?: Z.ZodObject<any>;
querySchema?: Z.ZodObject<any>;
paramsSchema?: RouteSchema;
querySchema?: RouteSchema;
matcher: (url: string, query: ParsedQuery<string> | undefined) => any;
reverser: any;
redirect?: any;
Expand Down Expand Up @@ -278,9 +291,9 @@ type MergeRouteTypes<TBase, TSupplied> = undefined extends TBase
? TBase
: TBase & TSupplied;

type ResolveZodType<T extends Z.ZodType<any> | undefined> = undefined extends T
type ResolveZodType<T extends RouteSchema | undefined> = undefined extends T
? undefined
: Z.TypeOf<Exclude<T, undefined>>;
: Exclude<T, undefined>["_output"];

/**
* @public
Expand Down Expand Up @@ -315,8 +328,8 @@ export function buildCreateRoute(
simpleRoute<TBaseRoute extends AnyRoute>(baseRoute?: TBaseRoute) {
return <
TEvent extends string,
TParamsSchema extends Z.ZodObject<any> | undefined,
TQuerySchema extends Z.ZodObject<any> | undefined,
TParamsSchema extends RouteSchema | undefined,
TQuerySchema extends RouteSchema | undefined,
TMeta extends Record<string, unknown>
>({
url,
Expand Down Expand Up @@ -419,8 +432,8 @@ export function buildCreateRoute(

return <
TEvent extends string,
TParamsSchema extends Z.ZodObject<any> | undefined,
TQuerySchema extends Z.ZodObject<any> | undefined,
TParamsSchema extends RouteSchema | undefined,
TQuerySchema extends RouteSchema | undefined,
TMeta extends Record<string, unknown>
>({
event,
Expand Down Expand Up @@ -501,7 +514,7 @@ export function buildCreateRoute(
TEvent,
MergeRouteTypes<RouteMeta<TBaseRoute>, TMeta> & SharedMeta
> => {
let fullParamsSchema: Z.ZodObject<any> | undefined = paramsSchema;
let fullParamsSchema: RouteSchema | undefined = paramsSchema;
let parentRoute: AnyRoute | undefined =
baseRoute as unknown as AnyRoute;
while (fullParamsSchema && parentRoute) {
Expand Down
48 changes: 48 additions & 0 deletions src/routing/createRoute/createRoute.zod4.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { createMemoryHistory } from "history";
import * as Z4 from "zod/v4";

import { assert } from "../../utils";

import { buildCreateRoute } from "./createRoute";

// Route schemas are typed structurally (see RouteSchema), so a zod 4 schema must be
// accepted end to end: at the type level (params/query inferred from `_output`) and
// at runtime (`parse` and the parent-merge in `simpleRoute`).
const hist = createMemoryHistory<{ meta?: unknown }>();
const createRoute = buildCreateRoute(() => hist, "/");

describe("createRoute with zod 4 schemas", () => {
const parentRoute = createRoute.simpleRoute()({
url: "/bar/:barId",
event: "GO_BAR",
paramsSchema: Z4.object({ barId: Z4.string().regex(/^\d+$/) }),
querySchema: Z4.object({ someFilter: Z4.string().optional() }),
});
const route = createRoute.simpleRoute(parentRoute)({
url: "/foo/:fooId",
event: "GO_FOO",
paramsSchema: Z4.object({ fooId: Z4.string() }),
querySchema: Z4.object({ page: Z4.string().optional() }),
});

it("infers params and query from the zod 4 schema", () => {
const match = route.matches("/bar/456/foo/123", "?page=2");
assert(match !== false);

const fooId: string = match.params.fooId;
const barId: string = match.params.barId;
const page: string | undefined = match.query.page;

expect({ fooId, barId, page }).toEqual({
fooId: "123",
barId: "456",
page: "2",
});
});

it("rejects params through the merged zod 4 schema", () => {
// `barId` comes from the parent route, so this only fails if the two zod 4
// schemas were merged and the merged schema was parsed.
expect(() => route.matches("/bar/abc/foo/123", "")).toThrow();
});
});
1 change: 1 addition & 0 deletions src/routing/createRoute/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export {
type Meta,
type SharedMeta,
type RouteArgumentFunctions,
type RouteSchema,
} from "./createRoute";
1 change: 1 addition & 0 deletions src/routing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
type SharedMeta,
type RouteArgumentFunctions,
buildCreateRoute,
type RouteSchema,
} from "./createRoute";
export { joinRoutes } from "./joinRoutes";
export { Link, type LinkProps, type StyledLink } from "./Link";
Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"target": "ES2018",
"module": "CommonJS",
"jsx": "react",
"skipLibCheck": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
Expand Down
Loading
Loading