diff --git a/.changeset/route-scoped-use-match-should-throw.md b/.changeset/route-scoped-use-match-should-throw.md new file mode 100644 index 00000000000..f45cf7e0db4 --- /dev/null +++ b/.changeset/route-scoped-use-match-should-throw.md @@ -0,0 +1,7 @@ +--- +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +--- + +Fix route-scoped `useMatch`, `useSearch`, and `useParams` APIs to forward the `shouldThrow` option and preserve optional return types when `shouldThrow: false`. diff --git a/packages/react-router/src/fileRoute.ts b/packages/react-router/src/fileRoute.ts index e5fdb4993b9..f4d8c9b9676 100644 --- a/packages/react-router/src/fileRoute.ts +++ b/packages/react-router/src/fileRoute.ts @@ -218,11 +218,7 @@ export class LazyRoute { } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.options.id, - structuralSharing: opts?.structuralSharing, - } as any) as any + return useMatch({ ...opts, from: this.options.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts) => { @@ -230,21 +226,11 @@ export class LazyRoute { } useSearch: UseSearchRoute = (opts) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - return useSearch({ - select: opts?.select, - structuralSharing: opts?.structuralSharing, - from: this.options.id, - } as any) as any + return useSearch({ ...opts, from: this.options.id } as any) } useParams: UseParamsRoute = (opts) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - return useParams({ - select: opts?.select, - structuralSharing: opts?.structuralSharing, - from: this.options.id, - } as any) as any + return useParams({ ...opts, from: this.options.id } as any) } useLoaderDeps: UseLoaderDepsRoute = (opts) => { diff --git a/packages/react-router/src/route.tsx b/packages/react-router/src/route.tsx index 6f6961807b5..57dca9ef029 100644 --- a/packages/react-router/src/route.tsx +++ b/packages/react-router/src/route.tsx @@ -107,11 +107,7 @@ export class RouteApi< } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.id, - structuralSharing: opts?.structuralSharing, - } as any) as any + return useMatch({ ...opts, from: this.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts) => { @@ -119,21 +115,11 @@ export class RouteApi< } useSearch: UseSearchRoute = (opts) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - return useSearch({ - select: opts?.select, - structuralSharing: opts?.structuralSharing, - from: this.id, - } as any) as any + return useSearch({ ...opts, from: this.id } as any) } useParams: UseParamsRoute = (opts) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - return useParams({ - select: opts?.select, - structuralSharing: opts?.structuralSharing, - from: this.id, - } as any) as any + return useParams({ ...opts, from: this.id } as any) } useLoaderDeps: UseLoaderDepsRoute = (opts) => { @@ -261,11 +247,7 @@ export class Route< } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.id, - structuralSharing: opts?.structuralSharing, - } as any) as any + return useMatch({ ...opts, from: this.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts?) => { @@ -273,21 +255,11 @@ export class Route< } useSearch: UseSearchRoute = (opts) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - return useSearch({ - select: opts?.select, - structuralSharing: opts?.structuralSharing, - from: this.id, - } as any) as any + return useSearch({ ...opts, from: this.id } as any) } useParams: UseParamsRoute = (opts) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - return useParams({ - select: opts?.select, - structuralSharing: opts?.structuralSharing, - from: this.id, - } as any) as any + return useParams({ ...opts, from: this.id } as any) } useLoaderDeps: UseLoaderDepsRoute = (opts) => { @@ -532,11 +504,7 @@ export class RootRoute< } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.id, - structuralSharing: opts?.structuralSharing, - } as any) as any + return useMatch({ ...opts, from: this.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts) => { @@ -544,21 +512,11 @@ export class RootRoute< } useSearch: UseSearchRoute = (opts) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - return useSearch({ - select: opts?.select, - structuralSharing: opts?.structuralSharing, - from: this.id, - } as any) as any + return useSearch({ ...opts, from: this.id } as any) } useParams: UseParamsRoute = (opts) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - return useParams({ - select: opts?.select, - structuralSharing: opts?.structuralSharing, - from: this.id, - } as any) as any + return useParams({ ...opts, from: this.id } as any) } useLoaderDeps: UseLoaderDepsRoute = (opts) => { diff --git a/packages/react-router/src/useMatch.tsx b/packages/react-router/src/useMatch.tsx index 35d66739620..d66a69f6405 100644 --- a/packages/react-router/src/useMatch.tsx +++ b/packages/react-router/src/useMatch.tsx @@ -79,17 +79,18 @@ export type UseMatchRoute = < TRouter extends AnyRouter = RegisteredRouter, TSelected = unknown, TStructuralSharing extends boolean = boolean, + TThrow extends boolean = true, >( opts?: UseMatchBaseOptions< TRouter, TFrom, true, - true, + TThrow, TSelected, TStructuralSharing > & StructuralSharingOption, -) => UseMatchResult +) => ThrowOrOptional, TThrow> export type UseMatchOptions< TRouter extends AnyRouter, diff --git a/packages/react-router/src/useParams.tsx b/packages/react-router/src/useParams.tsx index 444eee7a416..44a6e47f9d3 100644 --- a/packages/react-router/src/useParams.tsx +++ b/packages/react-router/src/useParams.tsx @@ -49,17 +49,18 @@ export type UseParamsRoute = < TRouter extends AnyRouter = RegisteredRouter, TSelected = unknown, TStructuralSharing extends boolean = boolean, + TThrow extends boolean = true, >( opts?: UseParamsBaseOptions< TRouter, TFrom, /* TStrict */ true, - /* TThrow */ true, + TThrow, TSelected, TStructuralSharing > & StructuralSharingOption, -) => UseParamsResult +) => ThrowOrOptional, TThrow> /** * Access the current route's path parameters with type-safety. diff --git a/packages/react-router/src/useSearch.tsx b/packages/react-router/src/useSearch.tsx index 09f1a29b6a9..3979967d76e 100644 --- a/packages/react-router/src/useSearch.tsx +++ b/packages/react-router/src/useSearch.tsx @@ -49,17 +49,18 @@ export type UseSearchRoute = < TRouter extends AnyRouter = RegisteredRouter, TSelected = unknown, TStructuralSharing extends boolean = boolean, + TThrow extends boolean = true, >( opts?: UseSearchBaseOptions< TRouter, TFrom, /* TStrict */ true, - /* TThrow */ true, + TThrow, TSelected, TStructuralSharing > & StructuralSharingOption, -) => UseSearchResult +) => ThrowOrOptional, TThrow> /** * Read and select the current route's search parameters with type-safety. diff --git a/packages/react-router/tests/routeApi.test-d.tsx b/packages/react-router/tests/routeApi.test-d.tsx index 587c189fb03..d0553a9e0b2 100644 --- a/packages/react-router/tests/routeApi.test-d.tsx +++ b/packages/react-router/tests/routeApi.test-d.tsx @@ -60,6 +60,12 @@ describe('getRouteApi', () => { expectTypeOf(invoiceRouteApi.useParams()).toEqualTypeOf<{ invoiceId: string }>() + + expectTypeOf( + invoiceRouteApi.useParams({ + shouldThrow: false, + }), + ).toEqualTypeOf<{ invoiceId: string } | undefined>() }) test('useContext', () => { expectTypeOf( @@ -72,6 +78,12 @@ describe('getRouteApi', () => { expectTypeOf(invoiceRouteApi.useSearch()).toEqualTypeOf<{ page: number }>() + + expectTypeOf( + invoiceRouteApi.useSearch({ + shouldThrow: false, + }), + ).toEqualTypeOf<{ page: number } | undefined>() }) test('useLoaderData', () => { expectTypeOf(invoiceRouteApi.useLoaderData()).toEqualTypeOf<{ @@ -87,6 +99,25 @@ describe('getRouteApi', () => { expectTypeOf(invoiceRouteApi.useMatch()).toEqualTypeOf< MakeRouteMatch >() + + expectTypeOf( + invoiceRouteApi.useMatch({ shouldThrow: true }), + ).toEqualTypeOf>() + + expectTypeOf( + invoiceRouteApi.useMatch({ + shouldThrow: false, + }), + ).toEqualTypeOf< + MakeRouteMatch | undefined + >() + + expectTypeOf( + invoiceRouteApi.useMatch({ + shouldThrow: false, + select: (match) => match.params.invoiceId, + }), + ).toEqualTypeOf() }) test('Link', () => { const Link = invoiceRouteApi.Link diff --git a/packages/react-router/tests/routeScopedShouldThrow.test.tsx b/packages/react-router/tests/routeScopedShouldThrow.test.tsx new file mode 100644 index 00000000000..94c954c9671 --- /dev/null +++ b/packages/react-router/tests/routeScopedShouldThrow.test.tsx @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, test } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + getRouteApi, +} from '../src' +import type { RouteComponent, RouterHistory } from '../src' + +afterEach(() => { + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +function createPostsRouter( + RootComponent: RouteComponent, + history?: RouterHistory, +) { + const rootRoute = createRootRoute({ + component: RootComponent, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

IndexTitle

, + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$postId', + validateSearch: () => ({ page: 0 }), + component: () =>

PostsTitle

, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: history ?? createMemoryHistory({ initialEntries: ['/'] }), + }) + + return { postsRoute, router } +} + +describe('Route.useSearch', () => { + test('returns undefined when the target route is inactive and shouldThrow is false', async () => { + const route = {} as { + current: ReturnType['postsRoute'] + } + + function RootComponent() { + const search = route.current.useSearch({ shouldThrow: false }) + expect(search).toBeUndefined() + return + } + + const created = createPostsRouter(RootComponent) + route.current = created.postsRoute + render() + expect(await screen.findByText('IndexTitle')).toBeInTheDocument() + }) + + test.each([undefined, true])( + 'throws when the target route is inactive and shouldThrow is %s', + async (shouldThrow) => { + const route = {} as { + current: ReturnType['postsRoute'] + } + + function RootComponent() { + route.current.useSearch({ shouldThrow }) + return + } + + const created = createPostsRouter(RootComponent) + route.current = created.postsRoute + render() + const postsError = await screen.findByText( + 'Invariant failed: Could not find an active match from "/posts/$postId"', + ) + expect(postsError).toBeInTheDocument() + }, + ) +}) + +describe('Route.useParams', () => { + test('returns undefined when the target route is inactive and shouldThrow is false', async () => { + const route = {} as { + current: ReturnType['postsRoute'] + } + + function RootComponent() { + const params = route.current.useParams({ shouldThrow: false }) + expect(params).toBeUndefined() + return + } + + const created = createPostsRouter(RootComponent) + route.current = created.postsRoute + render() + expect(await screen.findByText('IndexTitle')).toBeInTheDocument() + }) + + test.each([undefined, true])( + 'throws when the target route is inactive and shouldThrow is %s', + async (shouldThrow) => { + const route = {} as { + current: ReturnType['postsRoute'] + } + + function RootComponent() { + route.current.useParams({ shouldThrow }) + return + } + + const created = createPostsRouter(RootComponent) + route.current = created.postsRoute + render() + const postsError = await screen.findByText( + 'Invariant failed: Could not find an active match from "/posts/$postId"', + ) + expect(postsError).toBeInTheDocument() + }, + ) +}) + +describe('RouteApi.useSearch', () => { + test('returns undefined when the target route is inactive and shouldThrow is false', async () => { + const postsRouteApi = getRouteApi('/posts/$postId') + + function RootComponent() { + const search = postsRouteApi.useSearch({ shouldThrow: false }) + expect(search).toBeUndefined() + return + } + + const created = createPostsRouter(RootComponent) + render() + expect(await screen.findByText('IndexTitle')).toBeInTheDocument() + }) +}) + +describe('RouteApi.useParams', () => { + test('returns undefined when the target route is inactive and shouldThrow is false', async () => { + const postsRouteApi = getRouteApi('/posts/$postId') + + function RootComponent() { + const params = postsRouteApi.useParams({ shouldThrow: false }) + expect(params).toBeUndefined() + return + } + + const created = createPostsRouter(RootComponent) + render() + expect(await screen.findByText('IndexTitle')).toBeInTheDocument() + }) +}) diff --git a/packages/react-router/tests/useMatch.test.tsx b/packages/react-router/tests/useMatch.test.tsx index ac4c52f6cca..59bde60fa0b 100644 --- a/packages/react-router/tests/useMatch.test.tsx +++ b/packages/react-router/tests/useMatch.test.tsx @@ -14,6 +14,7 @@ import { createRootRoute, createRoute, createRouter, + getRouteApi, useMatch, } from '../src' import type { RouteComponent, RouterHistory } from '../src' @@ -288,3 +289,119 @@ describe('useMatch', () => { }) }) }) + +describe('Route.useMatch', () => { + function createPostsRouter( + RootComponent: RouteComponent, + history?: RouterHistory, + ) { + const rootRoute = createRootRoute({ + component: RootComponent, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

IndexTitle

, + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

PostsTitle

, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: history ?? createMemoryHistory({ initialEntries: ['/'] }), + }) + + return { postsRoute, router } + } + + type PostsRoute = ReturnType['postsRoute'] + + test('returns undefined when the target route is inactive and shouldThrow is false', async () => { + const route = {} as { current: PostsRoute } + + function RootComponent() { + const match = route.current.useMatch({ shouldThrow: false }) + expect(match).toBeUndefined() + return + } + + const created = createPostsRouter(RootComponent) + route.current = created.postsRoute + render() + expect(await screen.findByText('IndexTitle')).toBeInTheDocument() + }) + + test.each([undefined, true])( + 'throws when the target route is inactive and shouldThrow is %s', + async (shouldThrow) => { + const route = {} as { current: PostsRoute } + + function RootComponent() { + route.current.useMatch({ shouldThrow }) + return + } + + const created = createPostsRouter(RootComponent) + route.current = created.postsRoute + render() + const postsError = await screen.findByText( + 'Invariant failed: Could not find an active match from "/posts"', + ) + expect(postsError).toBeInTheDocument() + }, + ) + + test('returns the match when the target route is active and shouldThrow is false', async () => { + const route = {} as { current: PostsRoute } + + function RootComponent() { + const match = route.current.useMatch({ shouldThrow: false }) + expect(match).toBeDefined() + expect(match?.routeId).toBe('/posts') + return + } + + const created = createPostsRouter( + RootComponent, + createMemoryHistory({ initialEntries: ['/posts'] }), + ) + route.current = created.postsRoute + render() + expect(await screen.findByText('PostsTitle')).toBeInTheDocument() + }) +}) + +describe('RouteApi.useMatch', () => { + test('returns undefined when the target route is inactive and shouldThrow is false', async () => { + const postsRouteApi = getRouteApi('/posts') + + function RootComponent() { + const match = postsRouteApi.useMatch({ shouldThrow: false }) + expect(match).toBeUndefined() + return + } + + const rootRoute = createRootRoute({ + component: RootComponent, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

IndexTitle

, + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

PostsTitle

, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('IndexTitle')).toBeInTheDocument() + }) +}) diff --git a/packages/solid-router/src/fileRoute.ts b/packages/solid-router/src/fileRoute.ts index 1115ee54144..a1fcae713fc 100644 --- a/packages/solid-router/src/fileRoute.ts +++ b/packages/solid-router/src/fileRoute.ts @@ -208,10 +208,7 @@ export class LazyRoute { } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.options.id, - } as any) as any + return useMatch({ ...opts, from: this.options.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts) => { @@ -219,17 +216,11 @@ export class LazyRoute { } useSearch: UseSearchRoute = (opts) => { - return useSearch({ - select: opts?.select, - from: this.options.id, - } as any) as any + return useSearch({ ...opts, from: this.options.id } as any) } useParams: UseParamsRoute = (opts) => { - return useParams({ - select: opts?.select, - from: this.options.id, - } as any) as any + return useParams({ ...opts, from: this.options.id } as any) } useLoaderDeps: UseLoaderDepsRoute = (opts) => { diff --git a/packages/solid-router/src/route.tsx b/packages/solid-router/src/route.tsx index b5e7fe312dd..e31804e2074 100644 --- a/packages/solid-router/src/route.tsx +++ b/packages/solid-router/src/route.tsx @@ -98,10 +98,7 @@ export class RouteApi< } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.id, - } as any) as any + return useMatch({ ...opts, from: this.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts) => { @@ -109,17 +106,11 @@ export class RouteApi< } useSearch: UseSearchRoute = (opts) => { - return useSearch({ - select: opts?.select, - from: this.id, - } as any) as any + return useSearch({ ...opts, from: this.id } as any) } useParams: UseParamsRoute = (opts) => { - return useParams({ - select: opts?.select, - from: this.id, - } as any) as any + return useParams({ ...opts, from: this.id } as any) } useLoaderDeps: UseLoaderDepsRoute = (opts) => { @@ -246,10 +237,7 @@ export class Route< } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.id, - } as any) as any + return useMatch({ ...opts, from: this.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts?) => { @@ -257,17 +245,11 @@ export class Route< } useSearch: UseSearchRoute = (opts) => { - return useSearch({ - select: opts?.select, - from: this.id, - } as any) as any + return useSearch({ ...opts, from: this.id } as any) } useParams: UseParamsRoute = (opts) => { - return useParams({ - select: opts?.select, - from: this.id, - } as any) as any + return useParams({ ...opts, from: this.id } as any) } useLoaderDeps: UseLoaderDepsRoute = (opts) => { @@ -484,10 +466,7 @@ export class RootRoute< } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.id, - } as any) as any + return useMatch({ ...opts, from: this.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts) => { @@ -495,17 +474,11 @@ export class RootRoute< } useSearch: UseSearchRoute = (opts) => { - return useSearch({ - select: opts?.select, - from: this.id, - } as any) as any + return useSearch({ ...opts, from: this.id } as any) } useParams: UseParamsRoute = (opts) => { - return useParams({ - select: opts?.select, - from: this.id, - } as any) as any + return useParams({ ...opts, from: this.id } as any) } useLoaderDeps: UseLoaderDepsRoute = (opts) => { diff --git a/packages/solid-router/src/useMatch.tsx b/packages/solid-router/src/useMatch.tsx index dc3d6ff37d5..8d5443cb4ea 100644 --- a/packages/solid-router/src/useMatch.tsx +++ b/packages/solid-router/src/useMatch.tsx @@ -28,9 +28,12 @@ export interface UseMatchBaseOptions< export type UseMatchRoute = < TRouter extends AnyRouter = RegisteredRouter, TSelected = unknown, + TThrow extends boolean = true, >( - opts?: UseMatchBaseOptions, -) => Solid.Accessor> + opts?: UseMatchBaseOptions, +) => Solid.Accessor< + ThrowOrOptional, TThrow> +> export type UseMatchOptions< TRouter extends AnyRouter, diff --git a/packages/solid-router/src/useParams.tsx b/packages/solid-router/src/useParams.tsx index f036155d97b..bec382bbb94 100644 --- a/packages/solid-router/src/useParams.tsx +++ b/packages/solid-router/src/useParams.tsx @@ -33,15 +33,18 @@ export type UseParamsOptions< export type UseParamsRoute = < TRouter extends AnyRouter = RegisteredRouter, TSelected = unknown, + TThrow extends boolean = true, >( opts?: UseParamsBaseOptions< TRouter, TFrom, /* TStrict */ true, - /* TThrow */ true, + TThrow, TSelected >, -) => Accessor> +) => Accessor< + ThrowOrOptional, TThrow> +> export function useParams< TRouter extends AnyRouter = RegisteredRouter, diff --git a/packages/solid-router/src/useSearch.tsx b/packages/solid-router/src/useSearch.tsx index 556cfc3dce0..79ed25ccb40 100644 --- a/packages/solid-router/src/useSearch.tsx +++ b/packages/solid-router/src/useSearch.tsx @@ -33,15 +33,18 @@ export type UseSearchOptions< export type UseSearchRoute = < TRouter extends AnyRouter = RegisteredRouter, TSelected = unknown, + TThrow extends boolean = true, >( opts?: UseSearchBaseOptions< TRouter, TFrom, /* TStrict */ true, - /* TThrow */ true, + TThrow, TSelected >, -) => Accessor> +) => Accessor< + ThrowOrOptional, TThrow> +> export function useSearch< TRouter extends AnyRouter = RegisteredRouter, diff --git a/packages/solid-router/tests/routeApi.test-d.tsx b/packages/solid-router/tests/routeApi.test-d.tsx index 2a1095ccd45..e6770e26524 100644 --- a/packages/solid-router/tests/routeApi.test-d.tsx +++ b/packages/solid-router/tests/routeApi.test-d.tsx @@ -63,6 +63,12 @@ describe('getRouteApi', () => { invoiceId: string }> >() + + expectTypeOf( + invoiceRouteApi.useParams({ + shouldThrow: false, + }), + ).toEqualTypeOf>() }) test('useContext', () => { expectTypeOf( @@ -79,6 +85,12 @@ describe('getRouteApi', () => { page: number }> >() + + expectTypeOf( + invoiceRouteApi.useSearch({ + shouldThrow: false, + }), + ).toEqualTypeOf>() }) test('useLoaderData', () => { expectTypeOf(invoiceRouteApi.useLoaderData()).toEqualTypeOf< @@ -98,6 +110,16 @@ describe('getRouteApi', () => { expectTypeOf(invoiceRouteApi.useMatch()).toEqualTypeOf< Accessor> >() + + expectTypeOf( + invoiceRouteApi.useMatch({ + shouldThrow: false, + }), + ).toEqualTypeOf< + Accessor< + MakeRouteMatch | undefined + > + >() }) test('Link', () => { const Link = invoiceRouteApi.Link diff --git a/packages/solid-router/tests/routeScopedShouldThrow.test.tsx b/packages/solid-router/tests/routeScopedShouldThrow.test.tsx new file mode 100644 index 00000000000..c3c66575bd3 --- /dev/null +++ b/packages/solid-router/tests/routeScopedShouldThrow.test.tsx @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, test } from 'vitest' +import { cleanup, render, screen } from '@solidjs/testing-library' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + getRouteApi, +} from '../src' +import type { RouteComponent, RouterHistory } from '../src' + +afterEach(() => { + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +function createPostsRouter( + RootComponent: RouteComponent, + history?: RouterHistory, +) { + const rootRoute = createRootRoute({ + component: RootComponent, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

IndexTitle

, + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$postId', + validateSearch: () => ({ page: 0 }), + component: () =>

PostsTitle

, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: history ?? createMemoryHistory({ initialEntries: ['/'] }), + }) + + return { postsRoute, router } +} + +describe('RouteApi.useSearch', () => { + test('returns undefined when the target route is inactive and shouldThrow is false', async () => { + const postsRouteApi = getRouteApi('/posts/$postId') + + function RootComponent() { + const search = postsRouteApi.useSearch({ shouldThrow: false }) + expect(search()).toBeUndefined() + return + } + + const created = createPostsRouter(RootComponent) + render(() => ) + expect(await screen.findByText('IndexTitle')).toBeInTheDocument() + }) +}) + +describe('RouteApi.useParams', () => { + test('returns undefined when the target route is inactive and shouldThrow is false', async () => { + const postsRouteApi = getRouteApi('/posts/$postId') + + function RootComponent() { + const params = postsRouteApi.useParams({ shouldThrow: false }) + expect(params()).toBeUndefined() + return + } + + const created = createPostsRouter(RootComponent) + render(() => ) + expect(await screen.findByText('IndexTitle')).toBeInTheDocument() + }) +}) + +describe('RouteApi.useMatch', () => { + test('returns undefined when the target route is inactive and shouldThrow is false', async () => { + const postsRouteApi = getRouteApi('/posts/$postId') + + function RootComponent() { + const match = postsRouteApi.useMatch({ shouldThrow: false }) + expect(match()).toBeUndefined() + return + } + + const created = createPostsRouter(RootComponent) + render(() => ) + expect(await screen.findByText('IndexTitle')).toBeInTheDocument() + }) +}) diff --git a/packages/vue-router/src/fileRoute.ts b/packages/vue-router/src/fileRoute.ts index 0e0b6c8368f..69334b79ee9 100644 --- a/packages/vue-router/src/fileRoute.ts +++ b/packages/vue-router/src/fileRoute.ts @@ -208,10 +208,7 @@ export class LazyRoute { } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.options.id, - } as any) as any + return useMatch({ ...opts, from: this.options.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts) => { @@ -219,17 +216,11 @@ export class LazyRoute { } useSearch: UseSearchRoute = (opts) => { - return useSearch({ - select: opts?.select, - from: this.options.id, - } as any) as any + return useSearch({ ...opts, from: this.options.id } as any) } useParams: UseParamsRoute = (opts) => { - return useParams({ - select: opts?.select, - from: this.options.id, - } as any) as any + return useParams({ ...opts, from: this.options.id } as any) } useLoaderDeps: UseLoaderDepsRoute = (opts) => { diff --git a/packages/vue-router/src/route.ts b/packages/vue-router/src/route.ts index 53fa54de17c..db9cd80a5c7 100644 --- a/packages/vue-router/src/route.ts +++ b/packages/vue-router/src/route.ts @@ -99,10 +99,7 @@ export class RouteApi< } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.id, - } as any) as any + return useMatch({ ...opts, from: this.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts) => { @@ -110,17 +107,11 @@ export class RouteApi< } useSearch: UseSearchRoute = (opts) => { - return useSearch({ - select: opts?.select, - from: this.id, - } as any) as any + return useSearch({ ...opts, from: this.id } as any) } useParams: UseParamsRoute = (opts) => { - return useParams({ - select: opts?.select, - from: this.id, - } as any) as any + return useParams({ ...opts, from: this.id } as any) } useLoaderDeps: UseLoaderDepsRoute = (opts) => { @@ -252,10 +243,7 @@ export class Route< } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.id, - } as any) as any + return useMatch({ ...opts, from: this.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts?) => { @@ -263,17 +251,11 @@ export class Route< } useSearch: UseSearchRoute = (opts) => { - return useSearch({ - select: opts?.select, - from: this.id, - } as any) as any + return useSearch({ ...opts, from: this.id } as any) } useParams: UseParamsRoute = (opts) => { - return useParams({ - select: opts?.select, - from: this.id, - } as any) as any + return useParams({ ...opts, from: this.id } as any) } useLoaderDeps: UseLoaderDepsRoute = (opts) => { @@ -494,10 +476,7 @@ export class RootRoute< } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.id, - } as any) as any + return useMatch({ ...opts, from: this.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts) => { @@ -505,17 +484,11 @@ export class RootRoute< } useSearch: UseSearchRoute = (opts) => { - return useSearch({ - select: opts?.select, - from: this.id, - } as any) as any + return useSearch({ ...opts, from: this.id } as any) } useParams: UseParamsRoute = (opts) => { - return useParams({ - select: opts?.select, - from: this.id, - } as any) as any + return useParams({ ...opts, from: this.id } as any) } useLoaderDeps: UseLoaderDepsRoute = (opts) => { diff --git a/packages/vue-router/src/useMatch.tsx b/packages/vue-router/src/useMatch.tsx index a02b5ae109f..927962553d7 100644 --- a/packages/vue-router/src/useMatch.tsx +++ b/packages/vue-router/src/useMatch.tsx @@ -30,9 +30,12 @@ export interface UseMatchBaseOptions< export type UseMatchRoute = < TRouter extends AnyRouter = RegisteredRouter, TSelected = unknown, + TThrow extends boolean = true, >( - opts?: UseMatchBaseOptions, -) => Vue.Ref> + opts?: UseMatchBaseOptions, +) => Vue.Ref< + ThrowOrOptional, TThrow> +> export type UseMatchOptions< TRouter extends AnyRouter, diff --git a/packages/vue-router/src/useParams.tsx b/packages/vue-router/src/useParams.tsx index 5b501cc2961..6891c08adc1 100644 --- a/packages/vue-router/src/useParams.tsx +++ b/packages/vue-router/src/useParams.tsx @@ -33,15 +33,18 @@ export type UseParamsOptions< export type UseParamsRoute = < TRouter extends AnyRouter = RegisteredRouter, TSelected = unknown, + TThrow extends boolean = true, >( opts?: UseParamsBaseOptions< TRouter, TFrom, /* TStrict */ true, - /* TThrow */ true, + TThrow, TSelected >, -) => Vue.Ref> +) => Vue.Ref< + ThrowOrOptional, TThrow> +> export function useParams< TRouter extends AnyRouter = RegisteredRouter, diff --git a/packages/vue-router/src/useSearch.tsx b/packages/vue-router/src/useSearch.tsx index 877a1cf8764..43f61d743f7 100644 --- a/packages/vue-router/src/useSearch.tsx +++ b/packages/vue-router/src/useSearch.tsx @@ -33,15 +33,18 @@ export type UseSearchOptions< export type UseSearchRoute = < TRouter extends AnyRouter = RegisteredRouter, TSelected = unknown, + TThrow extends boolean = true, >( opts?: UseSearchBaseOptions< TRouter, TFrom, /* TStrict */ true, - /* TThrow */ true, + TThrow, TSelected >, -) => Vue.Ref> +) => Vue.Ref< + ThrowOrOptional, TThrow> +> export function useSearch< TRouter extends AnyRouter = RegisteredRouter, diff --git a/packages/vue-router/tests/routeApi.test-d.tsx b/packages/vue-router/tests/routeApi.test-d.tsx index b2b41638373..073e9fd395d 100644 --- a/packages/vue-router/tests/routeApi.test-d.tsx +++ b/packages/vue-router/tests/routeApi.test-d.tsx @@ -62,6 +62,12 @@ describe('getRouteApi', () => { invoiceId: string }> >() + + expectTypeOf( + invoiceRouteApi.useParams({ + shouldThrow: false, + }), + ).toEqualTypeOf>() }) test('useContext', () => { expectTypeOf( @@ -78,6 +84,12 @@ describe('getRouteApi', () => { page: number }> >() + + expectTypeOf( + invoiceRouteApi.useSearch({ + shouldThrow: false, + }), + ).toEqualTypeOf>() }) test('useLoaderData', () => { expectTypeOf(invoiceRouteApi.useLoaderData()).toEqualTypeOf< @@ -97,6 +109,16 @@ describe('getRouteApi', () => { expectTypeOf(invoiceRouteApi.useMatch()).toEqualTypeOf< Vue.Ref> >() + + expectTypeOf( + invoiceRouteApi.useMatch({ + shouldThrow: false, + }), + ).toEqualTypeOf< + Vue.Ref< + MakeRouteMatch | undefined + > + >() }) }) diff --git a/packages/vue-router/tests/routeScopedShouldThrow.test.tsx b/packages/vue-router/tests/routeScopedShouldThrow.test.tsx new file mode 100644 index 00000000000..9ad826ef870 --- /dev/null +++ b/packages/vue-router/tests/routeScopedShouldThrow.test.tsx @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, test } from 'vitest' +import { cleanup, render, screen } from '@testing-library/vue' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + getRouteApi, +} from '../src' +import type { RouteComponent, RouterHistory } from '../src' + +afterEach(() => { + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +function createPostsRouter( + RootComponent: RouteComponent, + history?: RouterHistory, +) { + const rootRoute = createRootRoute({ + component: RootComponent, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

IndexTitle

, + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$postId', + validateSearch: () => ({ page: 0 }), + component: () =>

PostsTitle

, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: history ?? createMemoryHistory({ initialEntries: ['/'] }), + }) + + return { postsRoute, router } +} + +describe('RouteApi.useSearch', () => { + test('returns undefined when the target route is inactive and shouldThrow is false', async () => { + const postsRouteApi = getRouteApi('/posts/$postId') + + function RootComponent() { + const search = postsRouteApi.useSearch({ shouldThrow: false }) + expect(search.value).toBeUndefined() + return + } + + const created = createPostsRouter(RootComponent) + render() + expect(await screen.findByText('IndexTitle')).toBeInTheDocument() + }) +}) + +describe('RouteApi.useParams', () => { + test('returns undefined when the target route is inactive and shouldThrow is false', async () => { + const postsRouteApi = getRouteApi('/posts/$postId') + + function RootComponent() { + const params = postsRouteApi.useParams({ shouldThrow: false }) + expect(params.value).toBeUndefined() + return + } + + const created = createPostsRouter(RootComponent) + render() + expect(await screen.findByText('IndexTitle')).toBeInTheDocument() + }) +}) + +describe('RouteApi.useMatch', () => { + test('returns undefined when the target route is inactive and shouldThrow is false', async () => { + const postsRouteApi = getRouteApi('/posts/$postId') + + function RootComponent() { + const match = postsRouteApi.useMatch({ shouldThrow: false }) + expect(match.value).toBeUndefined() + return + } + + const created = createPostsRouter(RootComponent) + render() + expect(await screen.findByText('IndexTitle')).toBeInTheDocument() + }) +})