From b706e073b4714a8a3ec60b2bf754347fc3c352ff Mon Sep 17 00:00:00 2001 From: mizukendesu Date: Wed, 26 Aug 2026 03:31:07 +0900 Subject: [PATCH 1/3] fix(react-router): forward shouldThrow in Route.useMatch Co-authored-by: Cursor --- .../route-scoped-use-match-should-throw.md | 5 + packages/react-router/src/fileRoute.ts | 1 + packages/react-router/src/route.tsx | 3 + packages/react-router/src/useMatch.tsx | 5 +- .../react-router/tests/routeApi.test-d.tsx | 19 +++ packages/react-router/tests/useMatch.test.tsx | 117 ++++++++++++++++++ 6 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 .changeset/route-scoped-use-match-should-throw.md 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..fad78dd7bd3 --- /dev/null +++ b/.changeset/route-scoped-use-match-should-throw.md @@ -0,0 +1,5 @@ +--- +"@tanstack/react-router": patch +--- + +Fix route-scoped useMatch APIs to forward the shouldThrow option. diff --git a/packages/react-router/src/fileRoute.ts b/packages/react-router/src/fileRoute.ts index e5fdb4993b9..6602d8aee9b 100644 --- a/packages/react-router/src/fileRoute.ts +++ b/packages/react-router/src/fileRoute.ts @@ -221,6 +221,7 @@ export class LazyRoute { return useMatch({ select: opts?.select, from: this.options.id, + shouldThrow: opts?.shouldThrow, structuralSharing: opts?.structuralSharing, } as any) as any } diff --git a/packages/react-router/src/route.tsx b/packages/react-router/src/route.tsx index 6f6961807b5..814d6cd7fef 100644 --- a/packages/react-router/src/route.tsx +++ b/packages/react-router/src/route.tsx @@ -110,6 +110,7 @@ export class RouteApi< return useMatch({ select: opts?.select, from: this.id, + shouldThrow: opts?.shouldThrow, structuralSharing: opts?.structuralSharing, } as any) as any } @@ -264,6 +265,7 @@ export class Route< return useMatch({ select: opts?.select, from: this.id, + shouldThrow: opts?.shouldThrow, structuralSharing: opts?.structuralSharing, } as any) as any } @@ -535,6 +537,7 @@ export class RootRoute< return useMatch({ select: opts?.select, from: this.id, + shouldThrow: opts?.shouldThrow, structuralSharing: opts?.structuralSharing, } as any) as any } 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/tests/routeApi.test-d.tsx b/packages/react-router/tests/routeApi.test-d.tsx index 587c189fb03..09b7d929c71 100644 --- a/packages/react-router/tests/routeApi.test-d.tsx +++ b/packages/react-router/tests/routeApi.test-d.tsx @@ -87,6 +87,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/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() + }) +}) From e4cf081b24d898773cae7d5fc04c0c534bbaa2b4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:21:43 +0000 Subject: [PATCH 2/3] ci: apply automated fixes --- .changeset/route-scoped-use-match-should-throw.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/route-scoped-use-match-should-throw.md b/.changeset/route-scoped-use-match-should-throw.md index fad78dd7bd3..0e3a32aabcf 100644 --- a/.changeset/route-scoped-use-match-should-throw.md +++ b/.changeset/route-scoped-use-match-should-throw.md @@ -1,5 +1,5 @@ --- -"@tanstack/react-router": patch +'@tanstack/react-router': patch --- Fix route-scoped useMatch APIs to forward the shouldThrow option. From 50a4fa7697ec7faff36160dadf0e0002bde13799 Mon Sep 17 00:00:00 2001 From: mizukendesu Date: Thu, 27 Aug 2026 06:58:36 +0900 Subject: [PATCH 3/3] fix(react-router,solid-router,vue-router): preserve shouldThrow in route-scoped hooks Co-authored-by: Cursor --- .../route-scoped-use-match-should-throw.md | 4 +- packages/react-router/src/fileRoute.ts | 21 +-- packages/react-router/src/route.tsx | 63 +------ packages/react-router/src/useParams.tsx | 5 +- packages/react-router/src/useSearch.tsx | 5 +- .../react-router/tests/routeApi.test-d.tsx | 12 ++ .../tests/routeScopedShouldThrow.test.tsx | 157 ++++++++++++++++++ packages/solid-router/src/fileRoute.ts | 15 +- packages/solid-router/src/route.tsx | 45 +---- packages/solid-router/src/useMatch.tsx | 7 +- packages/solid-router/src/useParams.tsx | 7 +- packages/solid-router/src/useSearch.tsx | 7 +- .../solid-router/tests/routeApi.test-d.tsx | 22 +++ .../tests/routeScopedShouldThrow.test.tsx | 91 ++++++++++ packages/vue-router/src/fileRoute.ts | 15 +- packages/vue-router/src/route.ts | 45 +---- packages/vue-router/src/useMatch.tsx | 7 +- packages/vue-router/src/useParams.tsx | 7 +- packages/vue-router/src/useSearch.tsx | 7 +- packages/vue-router/tests/routeApi.test-d.tsx | 22 +++ .../tests/routeScopedShouldThrow.test.tsx | 91 ++++++++++ 21 files changed, 470 insertions(+), 185 deletions(-) create mode 100644 packages/react-router/tests/routeScopedShouldThrow.test.tsx create mode 100644 packages/solid-router/tests/routeScopedShouldThrow.test.tsx create mode 100644 packages/vue-router/tests/routeScopedShouldThrow.test.tsx diff --git a/.changeset/route-scoped-use-match-should-throw.md b/.changeset/route-scoped-use-match-should-throw.md index 0e3a32aabcf..f45cf7e0db4 100644 --- a/.changeset/route-scoped-use-match-should-throw.md +++ b/.changeset/route-scoped-use-match-should-throw.md @@ -1,5 +1,7 @@ --- '@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch --- -Fix route-scoped useMatch APIs to forward the shouldThrow option. +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 6602d8aee9b..f4d8c9b9676 100644 --- a/packages/react-router/src/fileRoute.ts +++ b/packages/react-router/src/fileRoute.ts @@ -218,12 +218,7 @@ export class LazyRoute { } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.options.id, - shouldThrow: opts?.shouldThrow, - structuralSharing: opts?.structuralSharing, - } as any) as any + return useMatch({ ...opts, from: this.options.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts) => { @@ -231,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 814d6cd7fef..57dca9ef029 100644 --- a/packages/react-router/src/route.tsx +++ b/packages/react-router/src/route.tsx @@ -107,12 +107,7 @@ export class RouteApi< } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.id, - shouldThrow: opts?.shouldThrow, - structuralSharing: opts?.structuralSharing, - } as any) as any + return useMatch({ ...opts, from: this.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts) => { @@ -120,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) => { @@ -262,12 +247,7 @@ export class Route< } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.id, - shouldThrow: opts?.shouldThrow, - structuralSharing: opts?.structuralSharing, - } as any) as any + return useMatch({ ...opts, from: this.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts?) => { @@ -275,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) => { @@ -534,12 +504,7 @@ export class RootRoute< } useMatch: UseMatchRoute = (opts) => { - return useMatch({ - select: opts?.select, - from: this.id, - shouldThrow: opts?.shouldThrow, - structuralSharing: opts?.structuralSharing, - } as any) as any + return useMatch({ ...opts, from: this.id } as any) as any } useRouteContext: UseRouteContextRoute = (opts) => { @@ -547,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/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 09b7d929c71..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<{ 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/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() + }) +})