From 4a27d80c9e6eaabd3f5e13516081389692242a03 Mon Sep 17 00:00:00 2001 From: Taylor Lodge Date: Wed, 2 Sep 2026 11:03:46 +1200 Subject: [PATCH 1/2] feat(routing): accept zod 4 route schemas 4.x port of the same change on master (PR #68). Route `paramsSchema`/`querySchema` were typed as zod 3's `Z.ZodObject`, which zod 4 schemas do not satisfy. Routing only ever calls `parse` and `merge` on a schema and reads its `_output` marker, and both zod majors expose all three, so the schema type is now the structural `RouteSchema` and the `zod` import is gone from the routing source. The peer range widens to `^3.x || ^4.x`; nothing changes for zod 3 callers. The 4.x line needs this as well as 5.x because a consumer that hosts v4 machines under a v5 routing root (kawaka's `hostV4Machine`) hands v5 routes to v4 APIs such as `useIsRouteActive`; that assignability only held while both lines spelled the schema type identically. `createRoute.zod4.spec.ts` drives `simpleRoute` with `zod/v4` schemas so the compatibility is tested rather than assumed; the dev `zod` moves to 3.25.76 to make that subpath available in-repo. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xa9BAPFheAQLJwta16v67z --- package-lock.json | 22 +++++---- package.json | 5 +- src/index.ts | 1 + src/routing/createRoute/createRoute.ts | 37 +++++++++----- .../createRoute/createRoute.zod4.spec.ts | 48 +++++++++++++++++++ src/routing/createRoute/index.ts | 1 + src/routing/index.ts | 1 + xstate-tree.api.md | 45 ++++++++--------- 8 files changed, 110 insertions(+), 50 deletions(-) create mode 100644 src/routing/createRoute/createRoute.zod4.spec.ts diff --git a/package-lock.json b/package-lock.json index 567e0d2..a7d3c78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,13 +58,14 @@ "typescript": "^4.7.3", "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" } }, "node_modules/@ampproject/remapping": { @@ -18495,10 +18496,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" } @@ -32172,10 +32174,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 0fab4b6..1379e94 100644 --- a/package.json +++ b/package.json @@ -69,13 +69,14 @@ "typescript": "^4.7.3", "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/**/*'", diff --git a/src/index.ts b/src/index.ts index bae11ee..5ea2abc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,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 55e4359..5ba7a37 100644 --- a/src/routing/createRoute/createRoute.ts +++ b/src/routing/createRoute/createRoute.ts @@ -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 { + _output: TOutput; + parse(data: unknown): TOutput; + merge(other: RouteSchema): RouteSchema; +} + type EmptyKeys = keyof { [K in keyof T as IsEmptyObject extends true ? K : never]: T[K]; }; @@ -162,8 +175,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. @@ -186,8 +199,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; @@ -278,9 +291,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 @@ -315,8 +328,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, @@ -419,8 +432,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, @@ -501,7 +514,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 5b2eef9..cdd2777 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 46a6c4d..8656b89 100644 --- a/xstate-tree.api.md +++ b/xstate-tree.api.md @@ -22,7 +22,6 @@ import { ServiceMap } from 'xstate'; import type { StateFrom } from 'xstate'; import { StateMachine } from 'xstate'; import { TypegenDisabled } from 'xstate'; -import * as Z from 'zod'; // @public (undocumented) export type Actions = (args: { @@ -51,8 +50,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; @@ -76,15 +75,7 @@ export function buildActions 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; @@ -94,15 +85,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; @@ -291,8 +274,8 @@ export type Route = { history: () => XstateTreeHistory; basePath: string; parent?: AnyRoute; - paramsSchema?: Z.ZodObject; - querySchema?: Z.ZodObject; + paramsSchema?: RouteSchema; + querySchema?: RouteSchema; redirect?: RouteRedirect; canMatch?: RouteArgumentFunctions; }; @@ -335,6 +318,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"; @@ -492,9 +485,9 @@ export type XstateTreeMachineStateSchemaV2 Date: Wed, 2 Sep 2026 11:58:05 +1200 Subject: [PATCH 2/2] build: typecheck with TypeScript 5.0.2 and skipLibCheck, as master does `test-examples` runs `tsc --noEmit` over src and examples, which now includes `createRoute.zod4.spec.ts`. That spec imports `zod/v4`, whose declarations use `const` type parameters (TypeScript 5.0) and `NoInfer` (5.4), so the 4.x line's TypeScript ^4.7 fails to parse them and 5.0 still cannot check them. master already typechecks with TypeScript 5.0.2 and `skipLibCheck: true`; this brings 4.x to the same configuration. Only in-repo typechecking changes; the published declarations no longer mention zod at all. lint, test, test-examples, build and api-extractor all pass locally with this. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xa9BAPFheAQLJwta16v67z --- package-lock.json | 57 ++++++++++++++++++++++++++++++++++++++++------- package.json | 2 +- tsconfig.json | 1 + 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index a7d3c78..34b7771 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,7 @@ "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", @@ -952,6 +952,20 @@ "node": ">=8" } }, + "node_modules/@commitlint/load/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "node_modules/@commitlint/message": { "version": "17.0.0", "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-17.0.0.tgz", @@ -1904,6 +1918,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/@microsoft/api-extractor/node_modules/typescript": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", + "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "node_modules/@microsoft/tsdoc": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.1.tgz", @@ -17926,16 +17954,17 @@ } }, "node_modules/typescript": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", - "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.2.tgz", + "integrity": "sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=12.20" } }, "node_modules/uglify-js": { @@ -19174,6 +19203,12 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true + }, + "typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true } } }, @@ -19939,6 +19974,12 @@ "requires": { "path-parse": "^1.0.6" } + }, + "typescript": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", + "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", + "dev": true } } }, @@ -31781,9 +31822,9 @@ "dev": true }, "typescript": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", - "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.2.tgz", + "integrity": "sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw==", "dev": true }, "uglify-js": { diff --git a/package.json b/package.json index 1379e94..7c35220 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "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", diff --git a/tsconfig.json b/tsconfig.json index 90e47b3..82088b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "target": "ES2018", "module": "CommonJS", "jsx": "react", + "skipLibCheck": true, "strict": true, "noImplicitAny": true, "strictNullChecks": true,