diff --git a/package-lock.json b/package-lock.json index 1fa8ad4..c193177 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,8 @@ "typescript": "5.0.2", "vite": "^3.1.3", "vite-tsconfig-paths": "^3.5.0", - "xstate": "^5.4.1" + "xstate": "^5.4.1", + "zod": "^3.25.76" }, "peerDependencies": { "@xstate/react": "^4.x", @@ -18737,10 +18738,11 @@ } }, "node_modules/zod": { - "version": "3.17.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.17.3.tgz", - "integrity": "sha512-4oKP5zvG6GGbMlqBkI5FESOAweldEhSOZ6LI6cG+JzUT7ofj1ZOC0PJudpQOpT1iqOFpYYtX5Pw0+o403y4bcg==", - "peer": true, + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -32562,10 +32564,10 @@ } }, "zod": { - "version": "3.17.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.17.3.tgz", - "integrity": "sha512-4oKP5zvG6GGbMlqBkI5FESOAweldEhSOZ6LI6cG+JzUT7ofj1ZOC0PJudpQOpT1iqOFpYYtX5Pw0+o403y4bcg==", - "peer": true + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true } } } diff --git a/package.json b/package.json index 4087481..8fa63ac 100644 --- a/package.json +++ b/package.json @@ -68,13 +68,14 @@ "typescript": "5.0.2", "vite": "^3.1.3", "vite-tsconfig-paths": "^3.5.0", - "xstate": "^5.4.1" + "xstate": "^5.4.1", + "zod": "^3.25.76" }, "peerDependencies": { "@xstate/react": "^4.x", "react": ">= 16.8.0 < 19.0.0", "xstate": "^5.x", - "zod": "^3.x" + "zod": "^3.x || ^4.x" }, "scripts": { "lint": "eslint 'src/**/*'", diff --git a/src/index.ts b/src/index.ts index c638cf3..481a3e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ export { useActiveRouteEvents, TestRoutingContext, useOnRoute, + type RouteSchema, } from "./routing"; export { loggingMetaOptions } from "./useService"; export { lazy } from "./lazy"; diff --git a/src/routing/createRoute/createRoute.ts b/src/routing/createRoute/createRoute.ts index 665fae5..3c1e1b7 100644 --- a/src/routing/createRoute/createRoute.ts +++ b/src/routing/createRoute/createRoute.ts @@ -1,6 +1,5 @@ import { match, compile } from "path-to-regexp"; import { parse, ParsedQuery, stringify } from "query-string"; -import * as Z from "zod"; import { XstateTreeHistory } from "../../types"; import { @@ -9,6 +8,20 @@ import { } 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 { + _output: TOutput; + parse(data: unknown): TOutput; + merge(other: RouteSchema): RouteSchema; +} + /** * @public */ @@ -159,8 +172,8 @@ export type Route = { history: () => XstateTreeHistory; basePath: string; parent?: AnyRoute; - paramsSchema?: Z.ZodObject; - querySchema?: Z.ZodObject; + paramsSchema?: RouteSchema; + querySchema?: RouteSchema; redirect?: RouteRedirect; /** * Optional predicate to control whether this route can be matched. @@ -183,8 +196,8 @@ export type AnyRoute = { basePath: string; history: () => XstateTreeHistory; parent?: AnyRoute; - paramsSchema?: Z.ZodObject; - querySchema?: Z.ZodObject; + paramsSchema?: RouteSchema; + querySchema?: RouteSchema; matcher: (url: string, query: ParsedQuery | undefined) => any; reverser: any; redirect?: any; @@ -275,9 +288,9 @@ type MergeRouteTypes = undefined extends TBase ? TBase : TBase & TSupplied; -type ResolveZodType | undefined> = undefined extends T +type ResolveZodType = undefined extends T ? undefined - : Z.TypeOf>; + : Exclude["_output"]; /** * @public @@ -312,8 +325,8 @@ export function buildCreateRoute( simpleRoute(baseRoute?: TBaseRoute) { return < TEvent extends string, - TParamsSchema extends Z.ZodObject | undefined, - TQuerySchema extends Z.ZodObject | undefined, + TParamsSchema extends RouteSchema | undefined, + TQuerySchema extends RouteSchema | undefined, TMeta extends Record >({ url, @@ -416,8 +429,8 @@ export function buildCreateRoute( return < TEvent extends string, - TParamsSchema extends Z.ZodObject | undefined, - TQuerySchema extends Z.ZodObject | undefined, + TParamsSchema extends RouteSchema | undefined, + TQuerySchema extends RouteSchema | undefined, TMeta extends Record >({ event, @@ -498,7 +511,7 @@ export function buildCreateRoute( TEvent, MergeRouteTypes, TMeta> & SharedMeta > => { - let fullParamsSchema: Z.ZodObject | undefined = paramsSchema; + let fullParamsSchema: RouteSchema | undefined = paramsSchema; let parentRoute: AnyRoute | undefined = baseRoute as unknown as AnyRoute; while (fullParamsSchema && parentRoute) { diff --git a/src/routing/createRoute/createRoute.zod4.spec.ts b/src/routing/createRoute/createRoute.zod4.spec.ts new file mode 100644 index 0000000..23d7f22 --- /dev/null +++ b/src/routing/createRoute/createRoute.zod4.spec.ts @@ -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(); + }); +}); diff --git a/src/routing/createRoute/index.ts b/src/routing/createRoute/index.ts index af7dfa0..893e83b 100644 --- a/src/routing/createRoute/index.ts +++ b/src/routing/createRoute/index.ts @@ -12,4 +12,5 @@ export { type Meta, type SharedMeta, type RouteArgumentFunctions, + type RouteSchema, } from "./createRoute"; diff --git a/src/routing/index.ts b/src/routing/index.ts index 862b990..f6f57d2 100644 --- a/src/routing/index.ts +++ b/src/routing/index.ts @@ -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"; diff --git a/xstate-tree.api.md b/xstate-tree.api.md index 2684352..f1462cd 100644 --- a/xstate-tree.api.md +++ b/xstate-tree.api.md @@ -16,7 +16,6 @@ import { ParsedQuery } from 'query-string'; import { default as React_2 } from 'react'; import type { SnapshotFrom } from 'xstate'; import type { StateValue } from 'xstate'; -import * as Z from 'zod'; // @public (undocumented) export type Actions = (args: { @@ -38,8 +37,8 @@ export type AnyRoute = { basePath: string; history: () => XstateTreeHistory; parent?: AnyRoute; - paramsSchema?: Z.ZodObject; - querySchema?: Z.ZodObject; + paramsSchema?: RouteSchema; + querySchema?: RouteSchema; matcher: (url: string, query: ParsedQuery | undefined) => any; reverser: any; redirect?: any; @@ -57,15 +56,7 @@ export function broadcast(event: GlobalEvents): void; // @public export function buildCreateRoute(history: () => XstateTreeHistory, basePath: string): { - simpleRoute(baseRoute?: TBaseRoute | undefined): | undefined, TQuerySchema extends Z.ZodObject | undefined, TMeta extends Record>({ url, paramsSchema, querySchema, ...args }: { + simpleRoute(baseRoute?: TBaseRoute | undefined): | undefined, TQuerySchema extends RouteSchema | undefined, TMeta extends Record>({ url, paramsSchema, querySchema, ...args }: { event: TEvent; url: string; paramsSchema?: TParamsSchema | undefined; @@ -75,15 +66,7 @@ export function buildCreateRoute(history: () => XstateTreeHistory, basePath: str preload?: RouteArgumentFunctions, ResolveZodType>, ResolveZodType, MergeRouteTypes, TMeta>, RouteArguments, ResolveZodType>, ResolveZodType, MergeRouteTypes, TMeta>>> | undefined; canMatch?: RouteArgumentFunctions, ResolveZodType>, ResolveZodType, MergeRouteTypes, TMeta> & SharedMeta, RouteArguments, ResolveZodType>, ResolveZodType, MergeRouteTypes, TMeta> & SharedMeta>> | undefined; }) => Route, ResolveZodType>, ResolveZodType, TEvent, MergeRouteTypes, TMeta> & SharedMeta>; - route(baseRoute?: TBaseRoute_1 | undefined): | undefined, TQuerySchema_1 extends Z.ZodObject | undefined, TMeta_1 extends Record>({ event, matcher, reverser, paramsSchema, querySchema, redirect, preload, canMatch, }: { + route(baseRoute?: TBaseRoute_1 | undefined): | undefined, TQuerySchema_1 extends RouteSchema | undefined, TMeta_1 extends Record>({ event, matcher, reverser, paramsSchema, querySchema, redirect, preload, canMatch, }: { event: TEvent_1; paramsSchema?: TParamsSchema_1 | undefined; querySchema?: TQuerySchema_1 | undefined; @@ -240,8 +223,8 @@ export type Route = { history: () => XstateTreeHistory; basePath: string; parent?: AnyRoute; - paramsSchema?: Z.ZodObject; - querySchema?: Z.ZodObject; + paramsSchema?: RouteSchema; + querySchema?: RouteSchema; redirect?: RouteRedirect; canMatch?: RouteArgumentFunctions; }; @@ -283,6 +266,16 @@ export type RouteParams = T extends Route ? TPa // @public export type RouteQuery = T extends Route ? TQuery : undefined; +// @public +export interface RouteSchema { + // (undocumented) + merge(other: RouteSchema): RouteSchema; + // (undocumented) + _output: TOutput; + // (undocumented) + parse(data: unknown): TOutput; +} + // @public (undocumented) export type Routing404Event = { type: "ROUTING_404"; @@ -413,9 +406,9 @@ export type XstateTreeMachineStateSchemaV2