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
20 changes: 11 additions & 9 deletions package-lock.json

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

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**/*'",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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,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 {
Expand All @@ -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<TOutput = any> {
_output: TOutput;
parse(data: unknown): TOutput;
merge(other: RouteSchema): RouteSchema;
}

/**
* @public
*/
Expand Down Expand Up @@ -159,8 +172,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 @@ -183,8 +196,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 @@ -275,9 +288,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 @@ -312,8 +325,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 @@ -416,8 +429,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 @@ -498,7 +511,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
45 changes: 19 additions & 26 deletions xstate-tree.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TMachine extends AnyStateMachine, TSelectorsOutput, TOut> = (args: {
Expand All @@ -38,8 +37,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 All @@ -57,15 +56,7 @@ export function broadcast(event: GlobalEvents): void;

// @public
export function buildCreateRoute(history: () => XstateTreeHistory, basePath: string): {
simpleRoute<TBaseRoute extends AnyRoute>(baseRoute?: TBaseRoute | undefined): <TEvent extends string, TParamsSchema extends Z.ZodObject<any, "strip", Z.ZodTypeAny, {
[x: string]: any;
}, {
[x: string]: any;
}> | undefined, TQuerySchema extends Z.ZodObject<any, "strip", Z.ZodTypeAny, {
[x: string]: any;
}, {
[x: string]: any;
}> | undefined, TMeta extends Record<string, unknown>>({ url, paramsSchema, querySchema, ...args }: {
simpleRoute<TBaseRoute extends AnyRoute>(baseRoute?: TBaseRoute | undefined): <TEvent extends string, TParamsSchema extends RouteSchema<any> | undefined, TQuerySchema extends RouteSchema<any> | undefined, TMeta extends Record<string, unknown>>({ url, paramsSchema, querySchema, ...args }: {
event: TEvent;
url: string;
paramsSchema?: TParamsSchema | undefined;
Expand All @@ -75,15 +66,7 @@ export function buildCreateRoute(history: () => XstateTreeHistory, basePath: str
preload?: RouteArgumentFunctions<void, MergeRouteTypes<RouteParams<TBaseRoute>, ResolveZodType<TParamsSchema>>, ResolveZodType<TQuerySchema>, MergeRouteTypes<RouteMeta<TBaseRoute>, TMeta>, RouteArguments<MergeRouteTypes<RouteParams<TBaseRoute>, ResolveZodType<TParamsSchema>>, ResolveZodType<TQuerySchema>, MergeRouteTypes<RouteMeta<TBaseRoute>, TMeta>>> | undefined;
canMatch?: RouteArgumentFunctions<boolean, MergeRouteTypes<RouteParams<TBaseRoute>, ResolveZodType<TParamsSchema>>, ResolveZodType<TQuerySchema>, MergeRouteTypes<RouteMeta<TBaseRoute>, TMeta> & SharedMeta, RouteArguments<MergeRouteTypes<RouteParams<TBaseRoute>, ResolveZodType<TParamsSchema>>, ResolveZodType<TQuerySchema>, MergeRouteTypes<RouteMeta<TBaseRoute>, TMeta> & SharedMeta>> | undefined;
}) => Route<MergeRouteTypes<RouteParams<TBaseRoute>, ResolveZodType<TParamsSchema>>, ResolveZodType<TQuerySchema>, TEvent, MergeRouteTypes<RouteMeta<TBaseRoute>, TMeta> & SharedMeta>;
route<TBaseRoute_1 extends AnyRoute>(baseRoute?: TBaseRoute_1 | undefined): <TEvent_1 extends string, TParamsSchema_1 extends Z.ZodObject<any, "strip", Z.ZodTypeAny, {
[x: string]: any;
}, {
[x: string]: any;
}> | undefined, TQuerySchema_1 extends Z.ZodObject<any, "strip", Z.ZodTypeAny, {
[x: string]: any;
}, {
[x: string]: any;
}> | undefined, TMeta_1 extends Record<string, unknown>>({ event, matcher, reverser, paramsSchema, querySchema, redirect, preload, canMatch, }: {
route<TBaseRoute_1 extends AnyRoute>(baseRoute?: TBaseRoute_1 | undefined): <TEvent_1 extends string, TParamsSchema_1 extends RouteSchema<any> | undefined, TQuerySchema_1 extends RouteSchema<any> | undefined, TMeta_1 extends Record<string, unknown>>({ event, matcher, reverser, paramsSchema, querySchema, redirect, preload, canMatch, }: {
event: TEvent_1;
paramsSchema?: TParamsSchema_1 | undefined;
querySchema?: TQuerySchema_1 | undefined;
Expand Down Expand Up @@ -240,8 +223,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>;
canMatch?: RouteArgumentFunctions<boolean, TParams, TQuery, TMeta>;
};
Expand Down Expand Up @@ -283,6 +266,16 @@ export type RouteParams<T> = T extends Route<infer TParams, any, any, any> ? TPa
// @public
export type RouteQuery<T> = T extends Route<any, infer TQuery, any, any> ? TQuery : undefined;

// @public
export interface RouteSchema<TOutput = any> {
// (undocumented)
merge(other: RouteSchema): RouteSchema;
// (undocumented)
_output: TOutput;
// (undocumented)
parse(data: unknown): TOutput;
}

// @public (undocumented)
export type Routing404Event = {
type: "ROUTING_404";
Expand Down Expand Up @@ -413,9 +406,9 @@ export type XstateTreeMachineStateSchemaV2<TMachine extends AnyStateMachine, TSe

// Warnings were encountered during analysis:
//
// src/routing/createRoute/createRoute.ts:292:19 - (ae-forgotten-export) The symbol "MergeRouteTypes" needs to be exported by the entry point index.d.ts
// src/routing/createRoute/createRoute.ts:292:19 - (ae-forgotten-export) The symbol "ResolveZodType" needs to be exported by the entry point index.d.ts
// src/routing/createRoute/createRoute.ts:329:9 - (ae-forgotten-export) The symbol "RouteRedirect" needs to be exported by the entry point index.d.ts
// src/routing/createRoute/createRoute.ts:305:19 - (ae-forgotten-export) The symbol "MergeRouteTypes" needs to be exported by the entry point index.d.ts
// src/routing/createRoute/createRoute.ts:305:19 - (ae-forgotten-export) The symbol "ResolveZodType" needs to be exported by the entry point index.d.ts
// src/routing/createRoute/createRoute.ts:342:9 - (ae-forgotten-export) The symbol "RouteRedirect" needs to be exported by the entry point index.d.ts
// src/types.ts:164:3 - (ae-incompatible-release-tags) The symbol "canHandleEvent" is marked as @public, but its signature references "CanHandleEvent" which is marked as @internal
// src/types.ts:165:3 - (ae-incompatible-release-tags) The symbol "inState" is marked as @public, but its signature references "MatchesFrom" which is marked as @internal

Expand Down
Loading