diff --git a/packages/zod-adapter/package.json b/packages/zod-adapter/package.json index f9290819f17..0b6eb5df26a 100644 --- a/packages/zod-adapter/package.json +++ b/packages/zod-adapter/package.json @@ -67,12 +67,12 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@tanstack/react-router": "workspace:^", - "zod": "^3.24.2", + "zod": "^3.25.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, "peerDependencies": { - "zod": "^3.23.8", + "zod": "^3.25.0", "@tanstack/react-router": ">=1.43.2" } } diff --git a/packages/zod-adapter/src/index.ts b/packages/zod-adapter/src/index.ts index 28d0d727cf9..cbf8767d4d9 100644 --- a/packages/zod-adapter/src/index.ts +++ b/packages/zod-adapter/src/index.ts @@ -1,4 +1,5 @@ -import { z } from 'zod' +import * as z3 from 'zod/v3' +import * as z4 from 'zod/v4' import type { ValidatorAdapter } from '@tanstack/react-router' export interface ZodTypeLike { @@ -58,12 +59,35 @@ export const zodValidator = < } } -export const fallback = ( +const isZod4Schema = ( + schema: z3.ZodTypeAny | z4.ZodType, +): schema is z4.ZodType => { + return ( + '_zod' in schema && typeof schema._zod === 'object' && 'def' in schema._zod + ) +} + +export function fallback( schema: TSchema, fallback: TSchema['_input'], -): z.ZodPipeline< - z.ZodType, - z.ZodCatch -> => { - return z.custom().pipe(schema.catch(fallback)) +): z3.ZodPipeline< + z3.ZodType, + z3.ZodCatch +> +export function fallback( + schema: TSchema, + fallback: z4.input, +): z4.ZodPipe< + z4.ZodType, + z4.ZodCatch +> +export function fallback( + schema: TSchema, + fallback: any, +): any { + if (isZod4Schema(schema)) { + return z4.custom().pipe(schema.catch(fallback)) + } else { + return z3.custom().pipe(schema.catch(fallback)) + } } diff --git a/packages/zod-adapter/tests/index.test-d.ts b/packages/zod-adapter/tests/zod-v3.test-d.ts similarity index 86% rename from packages/zod-adapter/tests/index.test-d.ts rename to packages/zod-adapter/tests/zod-v3.test-d.ts index 55f4f039d5d..ea55862b8fe 100644 --- a/packages/zod-adapter/tests/index.test-d.ts +++ b/packages/zod-adapter/tests/zod-v3.test-d.ts @@ -5,7 +5,7 @@ import { Link, } from '@tanstack/react-router' import { test, expectTypeOf } from 'vitest' -import { zodValidator } from '../src' +import { fallback, zodValidator } from '../src' import { z } from 'zod' test('when creating a route with zod validation', () => { @@ -56,6 +56,42 @@ test('when creating a route with zod validation', () => { }> }) +test('when creating a route with zod validation and fallback handler', () => { + const rootRoute = createRootRoute({ + validateSearch: zodValidator( + z.object({ + page: z.number().optional().default(0), + sort: fallback(z.enum(['oldest', 'newest']), 'oldest').default( + 'oldest', + ), + }), + ), + }) + + const router = createRouter({ routeTree: rootRoute }) + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ page?: number; sort?: 'oldest' | 'newest' } | undefined>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ page?: number; sort?: 'oldest' | 'newest' }>() + + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page: number + sort: 'oldest' | 'newest' + }>() + + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page: number + sort: 'oldest' | 'newest' + }> +}) + test('when creating a route with zod validation where input is output', () => { const rootRoute = createRootRoute({ validateSearch: zodValidator( diff --git a/packages/zod-adapter/tests/index.test.tsx b/packages/zod-adapter/tests/zod-v3.test.tsx similarity index 78% rename from packages/zod-adapter/tests/index.test.tsx rename to packages/zod-adapter/tests/zod-v3.test.tsx index d59ec207fc8..e727db5e797 100644 --- a/packages/zod-adapter/tests/index.test.tsx +++ b/packages/zod-adapter/tests/zod-v3.test.tsx @@ -1,5 +1,5 @@ import { afterEach, expect, test, vi } from 'vitest' -import { zodValidator } from '../src' +import { fallback, zodValidator } from '../src' import { z } from 'zod' import { createRootRoute, @@ -78,6 +78,70 @@ test('when navigating to a route with zodValidator', async () => { expect(await screen.findByText('Page: 0')).toBeInTheDocument() }) +test('when navigating to a route with zodValidator with fallback value', async () => { + const rootRoute = createRootRoute() + + const Index = () => { + return ( + <> +

Index

+ + to="/invoices" + search={{ + // to test fallback we need to cast to any to test invalid input + sort: 0 as any, + }} + > + To Invoices + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: Index, + }) + + const Invoices = () => { + const search = invoicesRoute.useSearch() + + return ( + <> +

Invoices

+ Sort by: {search.sort} + + ) + } + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateSearch: zodValidator( + z.object({ + sort: fallback(z.enum(['oldest', 'newest']), 'oldest').default( + 'oldest', + ), + }), + ), + component: Invoices, + }) + + const routeTree = rootRoute.addChildren([indexRoute, invoicesRoute]) + const router = createRouter({ routeTree }) + + render() + + const invoicesLink = await screen.findByRole('link', { + name: 'To Invoices', + }) + + fireEvent.click(invoicesLink) + + expect(await screen.findByText('Sort by: oldest')).toBeInTheDocument() +}) + test('when navigating to a route with zodValidator input set to output', async () => { const rootRoute = createRootRoute() diff --git a/packages/zod-adapter/tests/zod-v4.test-d.ts b/packages/zod-adapter/tests/zod-v4.test-d.ts new file mode 100644 index 00000000000..59a04e7fede --- /dev/null +++ b/packages/zod-adapter/tests/zod-v4.test-d.ts @@ -0,0 +1,286 @@ +import { + createRootRoute, + createRoute, + createRouter, + Link, +} from '@tanstack/react-router' +import { test, expectTypeOf } from 'vitest' +import { fallback, zodValidator } from '../src' +import { z } from 'zod/v4' + +test('when creating a route with zod validation', () => { + const rootRoute = createRootRoute({ + validateSearch: zodValidator( + z.object({ + page: z.number().optional().default(0), + }), + ), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: zodValidator( + z.object({ + indexPage: z.number().optional().default(0), + }), + ), + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createRouter({ routeTree }) + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ page?: number; indexPage?: number } | undefined>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ page?: number; indexPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ page: number } | { page: number; indexPage: number }>() + + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page: number + }>() + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page: number + }> +}) + +test('when creating a route with zod validation and fallback handler', () => { + const rootRoute = createRootRoute({ + validateSearch: zodValidator( + z.object({ + page: z.number().optional().default(0), + sort: fallback(z.enum(['oldest', 'newest']), 'oldest').default( + 'oldest', + ), + }), + ), + }) + + const router = createRouter({ routeTree: rootRoute }) + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ page?: number; sort?: 'oldest' | 'newest' } | undefined>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ page?: number; sort?: 'oldest' | 'newest' }>() + + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page: number + sort: 'oldest' | 'newest' + }>() + + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page: number + sort: 'oldest' | 'newest' + }> +}) + +test('when creating a route with zod validation where input is output', () => { + const rootRoute = createRootRoute({ + validateSearch: zodValidator( + z.object({ + page: z.number().optional().default(0), + }), + ), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: zodValidator({ + schema: z.object({ + indexPage: z.number().optional().default(0), + }), + input: 'output', + }), + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createRouter({ routeTree }) + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ page?: number; indexPage: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ page?: number; indexPage: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ page: number } | { page: number; indexPage: number }>() + + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page: number + }>() + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page: number + }> +}) + +test('when creating a route with zod validation where output is input', () => { + const rootRoute = createRootRoute({ + validateSearch: zodValidator({ + schema: z.object({ + page: z.number().optional().default(0), + }), + output: 'input', + }), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: zodValidator({ + schema: z.object({ + indexPage: z.number().optional().default(0), + }), + output: 'input', + }), + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createRouter({ routeTree }) + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ page?: number; indexPage?: number } | undefined>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ page?: number; indexPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ page?: number } | { page?: number; indexPage?: number }>() + + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page?: number + }>() + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page?: number + }> +}) + +test('when using zod schema without adapter', () => { + const rootRoute = createRootRoute({ + validateSearch: z.object({ + page: z.number().optional().default(0), + }), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: z.object({ + indexPage: z.number().optional().default(0), + }), + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createRouter({ routeTree }) + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ page?: number; indexPage?: number } | undefined>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ page?: number; indexPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ page: number } | { page: number; indexPage: number }>() + + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page: number + }>() + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page: number + }> +}) + +test('when using zod schema with function', () => { + const rootRoute = createRootRoute({ + validateSearch: (input) => + z + .object({ + page: z.number().optional().default(0), + }) + .parse(input), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: (input) => + z + .object({ + indexPage: z.number().optional().default(0), + }) + .parse(input), + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createRouter({ routeTree }) + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ page: number; indexPage: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ page: number; indexPage: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ page: number } | { page: number; indexPage: number }>() + + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page: number + }>() + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page: number + }> +}) diff --git a/packages/zod-adapter/tests/zod-v4.test.tsx b/packages/zod-adapter/tests/zod-v4.test.tsx new file mode 100644 index 00000000000..c8af99b322a --- /dev/null +++ b/packages/zod-adapter/tests/zod-v4.test.tsx @@ -0,0 +1,326 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { fallback, zodValidator } from '../src' +import { z } from 'zod/v4' +import { + createRootRoute, + createRoute, + createRouter, + Link, + RouterProvider, +} from '@tanstack/react-router' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom/vitest' + +afterEach(() => { + vi.resetAllMocks() + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +test('when navigating to a route with zodValidator', async () => { + const rootRoute = createRootRoute() + + const Index = () => { + return ( + <> +

Index

+ + to="/invoices" + search={{ + page: 0, + }} + > + To Invoices + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: Index, + }) + + const Invoices = () => { + const search = invoicesRoute.useSearch() + + return ( + <> +

Invoices

+ Page: {search.page} + + ) + } + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateSearch: zodValidator( + z.object({ + page: z.number(), + }), + ), + component: Invoices, + }) + + const routeTree = rootRoute.addChildren([indexRoute, invoicesRoute]) + const router = createRouter({ routeTree }) + + render() + + const invoicesLink = await screen.findByRole('link', { + name: 'To Invoices', + }) + + fireEvent.click(invoicesLink) + + expect(await screen.findByText('Page: 0')).toBeInTheDocument() +}) + +test('when navigating to a route with zodValidator with fallback value', async () => { + const rootRoute = createRootRoute() + + const Index = () => { + return ( + <> +

Index

+ + to="/invoices" + search={{ + // to test fallback we need to cast to any to test invalid input + sort: 0 as any, + }} + > + To Invoices + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: Index, + }) + + const Invoices = () => { + const search = invoicesRoute.useSearch() + + return ( + <> +

Invoices

+ Sort by: {search.sort} + + ) + } + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateSearch: zodValidator( + z.object({ + sort: fallback(z.enum(['oldest', 'newest']), 'oldest').default( + 'oldest', + ), + }), + ), + component: Invoices, + }) + + const routeTree = rootRoute.addChildren([indexRoute, invoicesRoute]) + const router = createRouter({ routeTree }) + + render() + + const invoicesLink = await screen.findByRole('link', { + name: 'To Invoices', + }) + + fireEvent.click(invoicesLink) + + expect(await screen.findByText('Sort by: oldest')).toBeInTheDocument() +}) + +test('when navigating to a route with zodValidator input set to output', async () => { + const rootRoute = createRootRoute() + + const Index = () => { + return ( + <> +

Index

+ + to="/invoices" + search={{ + page: 0, + }} + > + To Invoices + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: Index, + }) + + const Invoices = () => { + const search = invoicesRoute.useSearch() + + return ( + <> +

Invoices

+ Page: {search.page} + + ) + } + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateSearch: zodValidator({ + schema: z.object({ + page: z.number(), + }), + input: 'input', + }), + component: Invoices, + }) + + const routeTree = rootRoute.addChildren([indexRoute, invoicesRoute]) + const router = createRouter({ routeTree }) + + render() + + const invoicesLink = await screen.findByRole('link', { + name: 'To Invoices', + }) + + fireEvent.click(invoicesLink) + + expect(await screen.findByText('Page: 0')).toBeInTheDocument() +}) + +test('when navigating to a route using zod without the adapter', async () => { + const rootRoute = createRootRoute() + + const Index = () => { + return ( + <> +

Index

+ + to="/invoices" + search={{ + page: 0, + }} + > + To Invoices + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: Index, + }) + + const Invoices = () => { + const search = invoicesRoute.useSearch() + + return ( + <> +

Invoices

+ Page: {search.page} + + ) + } + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateSearch: z.object({ + page: z.number(), + }), + component: Invoices, + }) + + const routeTree = rootRoute.addChildren([indexRoute, invoicesRoute]) + const router = createRouter({ routeTree }) + + render() + + const invoicesLink = await screen.findByRole('link', { + name: 'To Invoices', + }) + + fireEvent.click(invoicesLink) + + expect(await screen.findByText('Page: 0')).toBeInTheDocument() +}) + +test('when navigating to a route using zod in a function without the adapter', async () => { + const rootRoute = createRootRoute() + + const Index = () => { + return ( + <> +

Index

+ + to="/invoices" + search={{ + page: 0, + }} + > + To Invoices + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: Index, + }) + + const Invoices = () => { + const search = invoicesRoute.useSearch() + + return ( + <> +

Invoices

+ Page: {search.page} + + ) + } + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateSearch: (input) => + z + .object({ + page: z.number(), + }) + .parse(input), + component: Invoices, + }) + + const routeTree = rootRoute.addChildren([indexRoute, invoicesRoute]) + const router = createRouter({ routeTree }) + + render() + + const invoicesLink = await screen.findByRole('link', { + name: 'To Invoices', + }) + + fireEvent.click(invoicesLink) + + expect(await screen.findByText('Page: 0')).toBeInTheDocument() +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00d1bccb070..2dc193a4c6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6984,7 +6984,7 @@ importers: specifier: ^19.0.0 version: 19.0.0(react@19.0.0) zod: - specifier: ^3.24.2 + specifier: ^3.25.0 version: 3.25.57 packages: