diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index a00874fb15d..e24a694d2fc 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -76,11 +76,9 @@ export type Last> = T extends [...infer _, infer L] ? L : never -export type RemoveTrailingSlashes = T extends '/' - ? T - : T extends `${infer R}/` - ? RemoveTrailingSlashes - : T +export type RemoveTrailingSlashes = T extends `${infer R}/` + ? RemoveTrailingSlashes + : T export type RemoveLeadingSlashes = T extends `/${infer R}` ? RemoveLeadingSlashes @@ -217,10 +215,11 @@ export type ResolveRoute< ? TFrom : ResolveRelativePath >, -> = - RouteByPath extends never +> = TPath extends string + ? RouteByPath extends never ? RouteByPath - : RouteByPath + : RouteByPath + : never type PostProcessParams< T, diff --git a/packages/react-router/tests/fileRoute.test.ts b/packages/react-router/tests/fileRoute.test.ts index ab758918486..f00a76836c0 100644 --- a/packages/react-router/tests/fileRoute.test.ts +++ b/packages/react-router/tests/fileRoute.test.ts @@ -5,6 +5,7 @@ import { createFileRoute, createLazyRoute, createLazyFileRoute, + LazyRoute, } from '../src' describe('createFileRoute has the same hooks as getRouteApi', () => { @@ -16,7 +17,7 @@ describe('createFileRoute has the same hooks as getRouteApi', () => { it.each(hookNames.map((name) => [name]))( 'should have the "%s" hook defined', (hookName) => { - expect(route[hookName]).toBeDefined() + expect(hookName as keyof LazyRoute).toBeDefined() }, ) }) @@ -30,20 +31,20 @@ describe('createLazyFileRoute has the same hooks as getRouteApi', () => { it.each(hookNames.map((name) => [name]))( 'should have the "%s" hook defined', (hookName) => { - expect(route[hookName]).toBeDefined() + expect(route[hookName as keyof LazyRoute]).toBeDefined() }, ) }) describe('createLazyRoute has the same hooks as getRouteApi', () => { const routeApi = getRouteApi('foo') - const hookNames = Object.keys(routeApi).filter((key) => key.startsWith('use')) const route = createLazyRoute({})({}) + const hookNames = Object.keys(routeApi).filter((key) => key.startsWith('use')) it.each(hookNames.map((name) => [name]))( 'should have the "%s" hook defined', (hookName) => { - expect(route[hookName]).toBeDefined() + expect(route[hookName as keyof LazyRoute]).toBeDefined() }, ) }) diff --git a/packages/react-router/tests/link.test-d.tsx b/packages/react-router/tests/link.test-d.tsx index 4f00de4b6f4..ce9e99ed835 100644 --- a/packages/react-router/tests/link.test-d.tsx +++ b/packages/react-router/tests/link.test-d.tsx @@ -1,4 +1,4 @@ -import { test, expectTypeOf } from 'vitest' +import { expectTypeOf, test } from 'vitest' import { Link, createRoute } from '../src' import { createRootRoute } from '../src' @@ -40,15 +40,23 @@ const invoiceRoute = createRoute({ validateSearch: () => ({ page: 0 }), }) +const invoiceEditRoute = createRoute({ + getParentRoute: () => invoiceRoute, + path: 'edit', +}) + const routeTree = rootRoute.addChildren([ postsRoute.addChildren([postRoute, postsIndexRoute]), - invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + invoicesRoute.addChildren([ + invoicesIndexRoute, + invoiceRoute.addChildren([invoiceEditRoute]), + ]), indexRoute, ]) type RouteTree = typeof routeTree -test('when navigating to the root, to autocompletes to all routes, ../ and ./', () => { +test('when navigating to the root', () => { expectTypeOf(Link) .parameter(0) .toHaveProperty('to') @@ -60,13 +68,14 @@ test('when navigating to the root, to autocompletes to all routes, ../ and ./', | '/invoices' | '/invoices/' | '/invoices/$invoiceId' + | '/invoices/$invoiceId/edit' | '../' | './' | undefined >() }) -test('when navigating from a static route to the root, to autocompletes to all routes', () => { +test('when navigating from a route with no params and no search to the root', () => { expectTypeOf(Link) .parameter(0) .toHaveProperty('to') @@ -78,20 +87,21 @@ test('when navigating from a static route to the root, to autocompletes to all r | '/invoices' | '/invoices/' | '/invoices/$invoiceId' + | '/invoices/$invoiceId/edit' | '../' | './' | undefined >() }) -test('when navigating from a static route to the current route, to autocompletes to relative routes', () => { +test('when navigating from a route with no params and no search to the current route', () => { expectTypeOf(Link) .parameter(0) .toHaveProperty('to') .toEqualTypeOf<'./$postId' | undefined | './'>() }) -test('when navigating from a static route to the parent route, to autocompletes to relative routes', () => { +test('when navigating from a route with no params and no search to the parent route', () => { expectTypeOf(Link) .parameter(0) .toHaveProperty('to') @@ -100,6 +110,7 @@ test('when navigating from a static route to the parent route, to autocompletes | '../posts/' | '../posts/$postId' | '../invoices/$invoiceId' + | '../invoices/$invoiceId/edit' | '../invoices' | '../invoices/' | '../' @@ -121,45 +132,60 @@ test('from autocompletes to all absolute routes', () => { | '/invoices' | '/invoices/' | '/invoices/$invoiceId' + | '/invoices/$invoiceId/edit' | undefined >() }) -test('when navigating to the same route params is optional', () => { +test('when navigating to the same route', () => { const TestLink = Link + expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() -}) -test('when naviating to the same route params can be true', () => { - const TestLink = Link expectTypeOf(TestLink) .parameter(0) .toHaveProperty('params') .extract() .toEqualTypeOf() -}) -test('when navigating to the parent route params is optional', () => { - const TestLink = Link - expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() + expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(TestLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(TestLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() }) -test('when navigating to the parent route params can be true', () => { +test('when navigating to the parent route', () => { const TestLink = Link + + expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() expectTypeOf(TestLink) .parameter(0) .toHaveProperty('params') .extract() .toEqualTypeOf() -}) -test('when navigating from a route with params to the same route, params is optional', () => { - const TestLink = Link - expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() + expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(TestLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() }) -test('when navigating from a route with params to the same route, params can be true', () => { +test('when navigating from a route with params to the same route', () => { const TestLink = Link + + expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() expectTypeOf(TestLink) .parameter(0) .toHaveProperty('params') @@ -167,24 +193,14 @@ test('when navigating from a route with params to the same route, params can be .toEqualTypeOf() }) -test('when navigating to a route with params, params is required', () => { +test('when navigating to a route with params', () => { const TestLink = Link + expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ params: unknown }>() -}) -test('when navigating to a route with params, params can be a object of required params', () => { - const TestLink = Link const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('params') - params.exclude().toMatchTypeOf<{ postId: string }>() -}) - -test('when navigating to a route with params, params is a function from all params to next params', () => { - const TestLink = Link - const params = expectTypeOf(TestLink) - .parameter(0) - .toHaveProperty('params') - .extract() + params.exclude().toEqualTypeOf<{ postId: string }>() params.returns.toEqualTypeOf<{ postId: string }>() params @@ -192,65 +208,87 @@ test('when navigating to a route with params, params is a function from all para .toEqualTypeOf<{} | { invoiceId: string } | { postId: string }>() }) -test('when navigating from a route with no params to a route with params, params are required', () => { +test('when navigating from a route with no params to a route with params', () => { const TestLink = Link expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ params: unknown }>() -}) -test('when navigating from a route with no params to a route with params, params can be an object of required params', () => { - const TestLink = Link const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('params') - params.exclude().toMatchTypeOf<{ invoiceId: string }>() -}) - -test('when navigating from a route with no params to a route with params, params is a function from no params to next params', () => { - const TestLink = Link - const params = expectTypeOf(TestLink) - .parameter(0) - .toHaveProperty('params') - .extract() + params + .exclude() + .branded.toEqualTypeOf<{ invoiceId: string }>() params.returns.branded.toEqualTypeOf<{ invoiceId: string }>() params.parameter(0).toEqualTypeOf<{}>() }) -test('when navigating to the same route search is optional', () => { - const TestLink = Link - expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ search: unknown }>() -}) +test('when navigating from a route to a route with the same params', () => { + const TestLink = Link + const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('params') -test('when navigating to the same search params can be true', () => { - const TestLink = Link - expectTypeOf(TestLink) - .parameter(0) - .toHaveProperty('search') - .extract() - .toEqualTypeOf() -}) + expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() -test('when navigating to the parent route search params is optional', () => { - const TestLink = Link - expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ search: unknown }>() + params + .exclude() + .branded.toEqualTypeOf<{ invoiceId?: string | undefined } | undefined>() + + params.returns.branded.toEqualTypeOf<{ invoiceId?: string | undefined }>() + params.parameter(0).toEqualTypeOf<{ invoiceId: string }>() }) -test('when navigating to the parent route search params can be true', () => { - const TestLink = Link - expectTypeOf(TestLink) +test('when navigating to a union of routes with params', () => { + const TestLink = Link< + RouteTree, + string, + '/invoices/$invoiceId/' | '/posts/$postId/' + > + const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('params') + + expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() + + params + .exclude() + .toEqualTypeOf<{ invoiceId: string } | { postId: string } | undefined>() + + params.returns.branded.toEqualTypeOf< + { invoiceId: string } | { postId: string } + >() + + params .parameter(0) - .toHaveProperty('search') - .extract() - .toEqualTypeOf() + .toEqualTypeOf<{} | { invoiceId: string } | { postId: string }>() }) -test('when navigating from a route with search params to the same route, search params is required', () => { - const TestLink = Link - expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ search: unknown }>() +test('when navigating to a union of routes including the root', () => { + const TestLink = Link< + RouteTree, + string, + '/' | '/invoices/$invoiceId/' | '/posts/$postId/' + > + const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('params') + + expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() + + params + .exclude() + .toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} | undefined + >() + + params.returns.branded.toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} + >() + + params + .parameter(0) + .toEqualTypeOf<{} | { invoiceId: string } | { postId: string }>() }) -test('when navigating from a route with search params to the same route, search params can be true', () => { +test('when navigating from a route with search params to the same route', () => { const TestLink = Link + + expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ search: unknown }>() expectTypeOf(TestLink) .parameter(0) .toHaveProperty('search') @@ -258,49 +296,60 @@ test('when navigating from a route with search params to the same route, search .toEqualTypeOf() }) -test('when navigating to a route with search params, search params is required', () => { - const TestLink = Link - expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ search: unknown }>() -}) - -test('when navigating to a route with search params, search params can be a object of required params', () => { +test('when navigating to a route with search params', () => { const TestLink = Link const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('search') - params.exclude().toMatchTypeOf<{ page: number }>() -}) - -test('when navigating to a route with search params, search params is a function from all params to current params', () => { - const TestLink = Link - const params = expectTypeOf(TestLink) - .parameter(0) - .toHaveProperty('search') - .extract() + expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ search: unknown }>() + params.exclude().toEqualTypeOf<{ page: number }>() params.returns.toEqualTypeOf<{ page: number }>() params.parameter(0).toEqualTypeOf<{} | { page: number }>() }) -test('when navigating from a route with no search params to a route with search params, search params are required', () => { +test('when navigating from a route with no search params to a route with search params', () => { const TestLink = Link + const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('search') expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ search: unknown }>() + params.exclude().toEqualTypeOf<{ page: number }>() + params.returns.branded.toEqualTypeOf<{ page: number }>() + params.parameter(0).toEqualTypeOf<{}>() }) -test('when navigating from a route with no search params to a route with search params, search params can be an object of required params', () => { - const TestLink = Link +test('when navigating to a union of routes with search params', () => { + const TestLink = Link< + RouteTree, + string, + '/invoices/$invoiceId/' | '/posts/$postId/' + > const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('search') - params.exclude().toMatchTypeOf<{ page: number }>() + expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ search: unknown }>() + + params + .exclude() + .toEqualTypeOf<{ page: number } | {} | undefined>() + + params.returns.branded.toEqualTypeOf<{ page: number } | {}>() + + params.parameter(0).toEqualTypeOf<{} | { page: number }>() }) -test('when navigating to a route with search params, search params is a function from all search params to current search params', () => { - const TestLink = Link - const params = expectTypeOf(TestLink) - .parameter(0) - .toHaveProperty('search') - .extract() +test('when navigating to a union of routes with search params including the root', () => { + const TestLink = Link< + RouteTree, + string, + '/' | '/invoices/$invoiceId/' | '/posts/$postId/' + > + const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('search') - params.returns.branded.toEqualTypeOf<{ page: number }>() - params.parameter(0).toEqualTypeOf<{}>() + expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ search: unknown }>() + + params + .exclude() + .toEqualTypeOf<{ page: number } | {} | undefined>() + + params.returns.toEqualTypeOf<{ page: number } | {}>() + params.parameter(0).toEqualTypeOf<{} | { page: number }>() }) diff --git a/packages/react-router/tests/route.test.ts b/packages/react-router/tests/route.test.ts index abc2a0f686a..a001f7bfa7d 100644 --- a/packages/react-router/tests/route.test.ts +++ b/packages/react-router/tests/route.test.ts @@ -47,7 +47,7 @@ describe('createRoute has the same hooks as getRouteApi', () => { it.each(hookNames.map((name) => [name]))( 'should have the "%s" hook defined', (hookName) => { - expect(route[hookName]).toBeDefined() + expect(route[hookName as keyof typeof route]).toBeDefined() }, ) }) diff --git a/packages/react-router/tsconfig.json b/packages/react-router/tsconfig.json index 0fba1a6e6dd..01ad9218979 100644 --- a/packages/react-router/tsconfig.json +++ b/packages/react-router/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "jsx": "react", }, - "include": ["src", "vite.config.ts"], + "include": ["src", "tests", "vite.config.ts"], }