Repository: TanStack/router
Pull request #1495 (2024-04-19 20:21:08)

fix: only allow leaf nodes as `to` suggestions
- Introduce `RouteLeaves` type to only autocomplete leaves
- We have to change `CheckPath` as well (yuck, I can't think of a better way)
- Add some type tests to cover only leaves

chorobin: Before:
```
> tsc --extendedDiagnostics

Files:                         589
Lines of Library:            38118
Lines of Definitions:        77165
Lines of TypeScript:         13986
Lines of JavaScript:             0
Lines of JSON:                   0
Lines of Other:                  0
Identifiers:                114743
Symbols:                    191235
Types:                       50725
Instantiations:             564447
Memory used:               228692K
Assignability cache size:    34134
Identity cache size:          3717
Subtype cache size:              0
Strict subtype cache size:       0
I/O Read time:               0.08s
Parse time:                  0.55s
ResolveModule time:          0.12s
ResolveTypeReference time:   0.01s
ResolveLibrary time:         0.02s
Program time:                0.86s
Bind time:                   0.24s
Check time:                  4.26s
printTime time:              0.00s
Emit time:                   0.00s
Total time:                  5.36s
```

After:

```
> tsc --extendedDiagnostics

Files:                         589
Lines of Library:            38118
Lines of Definitions:        77166
Lines of TypeScript:         13986
Lines of JavaScript:             0
Lines of JSON:                   0
Lines of Other:                  0
Identifiers:                114762
Symbols:                    200101
Types:                       51716
Instantiations:             567375
Memory used:               229438K
Assignability cache size:    35133
Identity cache size:          3717
Subtype cache size:              0
Strict subtype cache size:       0
I/O Read time:               0.07s
Parse time:                  0.56s
ResolveModule time:          0.12s
ResolveTypeReference time:   0.01s
ResolveLibrary time:         0.02s
Program time:                0.86s
Bind time:                   0.29s
Check time:                  4.67s
printTime time:              0.00s
Emit time:                   0.00s
Total time:                  5.82s
```

Patch:
diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx
index 8768a362f..cae8bfc5e 100644
--- a/packages/react-router/src/link.tsx
+++ b/packages/react-router/src/link.tsx
@@ -10,6 +10,7 @@ import type { Trim } from './fileRoute'
 import type { AnyRoute, RootSearchSchema } from './route'
 import type {
   RouteByPath,
+  RouteLeaves,
   RoutePaths,
   RoutePathsAutoComplete,
 } from './routeInfo'
@@ -88,8 +89,8 @@ export type RemoveLeadingSlashes<T> = T extends `/${infer R}`
 
 export type ResolvePaths<TRouteTree extends AnyRoute, TSearchPath> =
   RouteByPath<TRouteTree, RemoveTrailingSlashes<TSearchPath>> extends never
-    ? RoutePaths<TRouteTree>
-    : RoutePaths<RouteByPath<TRouteTree, RemoveTrailingSlashes<TSearchPath>>>
+    ? RouteLeaves<TRouteTree>
+    : RouteLeaves<RouteByPath<TRouteTree, RemoveTrailingSlashes<TSearchPath>>>
 
 export type SearchPaths<
   TRouteTree extends AnyRoute,
@@ -137,7 +138,7 @@ export type AbsolutePathAutoComplete<
           ? never
           : './')
   | (string extends TFrom ? '../' : TFrom extends `/` ? never : '../')
-  | RoutePaths<TRouteTree>
+  | RouteLeaves<TRouteTree>
   | (TFrom extends '/' ? never : SearchPaths<TRouteTree, TFrom>)
 
 export type RelativeToPathAutoComplete<
@@ -432,7 +433,15 @@ export type LinkOptions<
 }
 
 export type CheckPath<TRouteTree extends AnyRoute, TPass, TFail, TFrom, TTo> =
-  ResolveRoute<TRouteTree, TFrom, TTo> extends never ? TFail : TPass
+  ResolveRoute<TRouteTree, TFrom, TTo> extends infer TRoute extends AnyRoute
+    ? [TRoute] extends [never]
+      ? TFail
+      : string extends TTo
+        ? TPass
+        : unknown extends TRoute['children']
+          ? TPass
+          : TFail
+    : TFail
 
 export type ResolveRelativePath<TFrom, TTo = '.'> = TFrom extends string
   ? TTo extends string
diff --git a/packages/react-router/src/routeInfo.ts b/packages/react-router/src/routeInfo.ts
index f643d41fb..3e9d79664 100644
--- a/packages/react-router/src/routeInfo.ts
+++ b/packages/react-router/src/routeInfo.ts
@@ -1,14 +1,23 @@
 import type { AnyRoute } from './route'
-import type { Expand, UnionToIntersection, UnionToTuple } from './utils'
+import type { UnionToIntersection, UnionToTuple } from './utils'
 
 export type ParseRoute<TRouteTree, TAcc = TRouteTree> = TRouteTree extends {
   types: { children: infer TChildren }
 }
-  ? TChildren extends ReadonlyArray<unknown>
+  ? TChildren extends ReadonlyArray<any>
     ? ParseRoute<TChildren[number], TAcc | TChildren[number]>
     : TAcc
   : TAcc
 
+export type RouteLeaves<TRouteTree> =
+  ParseRoute<TRouteTree> extends infer TRoute extends AnyRoute
+    ? TRoute extends any
+      ? TRoute['types']['children'] extends ReadonlyArray<any>
+        ? never
+        : TRoute['fullPath']
+      : never
+    : never
+
 export type RoutesById<TRouteTree extends AnyRoute> = {
   [K in ParseRoute<TRouteTree> as K['id']]: K
 }


Test patch:
diff --git a/packages/react-router/tests/link.test-d.tsx b/packages/react-router/tests/link.test-d.tsx
index 12eee7530..a542599d1 100644
--- a/packages/react-router/tests/link.test-d.tsx
+++ b/packages/react-router/tests/link.test-d.tsx
@@ -79,22 +79,14 @@ test('when navigating to the root', () => {
       | './'
       | ''
       | '/'
-      | '/invoices'
       | '/invoices/'
-      | '/invoices/$invoiceId'
-      | '/invoices/$invoiceId/details'
       | '/invoices/$invoiceId/details/$detailId'
       | '/invoices/$invoiceId/edit'
-      | '/posts'
       | '/posts/'
       | '/posts/$postId'
-      | 'invoices'
       | 'invoices/'
-      | 'invoices/$invoiceId'
-      | 'invoices/$invoiceId/details'
       | 'invoices/$invoiceId/details/$detailId'
       | 'invoices/$invoiceId/edit'
-      | 'posts'
       | 'posts/'
       | 'posts/$postId'
       | undefined
@@ -110,13 +102,9 @@ test('when navigating from a route with no params and no search to the root', ()
       | './'
       | '/'
       | ''
-      | '/invoices'
       | '/invoices/'
-      | '/invoices/$invoiceId'
-      | '/invoices/$invoiceId/details'
       | '/invoices/$invoiceId/edit'
       | '/invoices/$invoiceId/details/$detailId'
-      | '/posts'
       | '/posts/'
       | '/posts/$postId'
       | '$postId'
@@ -136,20 +124,39 @@ test('when navigating from a route with no params and no search to the parent ro
     .parameter(0)
     .toHaveProperty('to')
     .toEqualTypeOf<
-      | '../posts'
       | '../posts/'
       | '../posts/$postId'
-      | '../invoices/$invoiceId'
       | '../invoices/$invoiceId/edit'
-      | '../invoices/$invoiceId/details'
       | '../invoices/$invoiceId/details/$detailId'
-      | '../invoices'
       | '../invoices/'
       | '../'
       | undefined
     >()
 })
 
+test('cannot navigate to a branch', () => {
+  expectTypeOf(Link<RouteTree, string, '/invoices/invoiceId'>)
+    .parameter(0)
+    .toHaveProperty('to')
+    .toEqualTypeOf<
+      | ''
+      | '../'
+      | './'
+      | '/'
+      | '/invoices/'
+      | '/invoices/$invoiceId/details/$detailId'
+      | '/invoices/$invoiceId/edit'
+      | '/posts/'
+      | '/posts/$postId'
+      | 'invoices/'
+      | 'invoices/$invoiceId/details/$detailId'
+      | 'invoices/$invoiceId/edit'
+      | 'posts/'
+      | 'posts/$postId'
+      | undefined
+    >()
+})
+
 test('from autocompletes to all absolute routes', () => {
   const TestLink = Link<RouteTree, '/', '/'>
   expectTypeOf(TestLink)
@@ -248,7 +255,7 @@ test('when navigating to a route with params', () => {
 })
 
 test('when navigating from a route with no params to a route with params', () => {
-  const TestLink = Link<RouteTree, '/invoices/', './$invoiceId/'>
+  const TestLink = Link<RouteTree, '/invoices/', './$invoiceId/edit'>
 
   expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ params: unknown }>()