@tanstack/react-router 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Matches.tsx CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  RootSearchSchema,
12
12
  StaticDataRouteOption,
13
13
  UpdatableStaticRouteOption,
14
+ rootRouteId,
14
15
  } from './route'
15
16
  import {
16
17
  AllParams,
@@ -23,6 +24,12 @@ import {
23
24
  } from './routeInfo'
24
25
  import { RegisteredRouter, RouterState } from './router'
25
26
  import { DeepOptional, Expand, NoInfer, StrictOrFrom, pick } from './utils'
27
+ import {
28
+ CatchNotFound,
29
+ DefaultGlobalNotFound,
30
+ NotFoundError,
31
+ isNotFound,
32
+ } from './not-found'
26
33
 
27
34
  export const matchContext = React.createContext<string | undefined>(undefined)
28
35
 
@@ -66,6 +73,7 @@ export interface RouteMatch<
66
73
  meta?: JSX.IntrinsicElements['meta'][]
67
74
  links?: JSX.IntrinsicElements['link'][]
68
75
  scripts?: JSX.IntrinsicElements['script'][]
76
+ notFoundError?: NotFoundError
69
77
  staticData: StaticDataRouteOption
70
78
  }
71
79
 
@@ -125,6 +133,12 @@ export function Match({ matchId }: { matchId: string }) {
125
133
  router.options.defaultErrorComponent ??
126
134
  ErrorComponent
127
135
 
136
+ const routeNotFoundComponent = route.isRoot
137
+ ? // If it's the root route, use the globalNotFound option, with fallback to the notFoundRoute's component
138
+ route.options.notFoundComponent ??
139
+ router.options.notFoundRoute?.options.component
140
+ : route.options.notFoundComponent
141
+
128
142
  const ResolvedSuspenseBoundary =
129
143
  route.options.wrapInSuspense ??
130
144
  PendingComponent ??
@@ -138,17 +152,35 @@ export function Match({ matchId }: { matchId: string }) {
138
152
  ? CatchBoundary
139
153
  : SafeFragment
140
154
 
155
+ const ResolvedNotFoundBoundary = routeNotFoundComponent
156
+ ? CatchNotFound
157
+ : SafeFragment
158
+
141
159
  return (
142
160
  <matchContext.Provider value={matchId}>
143
161
  <ResolvedSuspenseBoundary fallback={pendingElement}>
144
162
  <ResolvedCatchBoundary
145
163
  getResetKey={() => router.state.resolvedLocation.state?.key}
146
164
  errorComponent={routeErrorComponent}
147
- onCatch={() => {
165
+ onCatch={(error) => {
166
+ // Forward not found errors (we don't want to show the error component for these)
167
+ if (isNotFound(error)) throw error
148
168
  warning(false, `Error in route match: ${matchId}`)
149
169
  }}
150
170
  >
151
- <MatchInner matchId={matchId!} pendingElement={pendingElement} />
171
+ <ResolvedNotFoundBoundary
172
+ fallback={(error) => {
173
+ // If the current not found handler doesn't exist or doesn't handle global not founds, forward it up the tree
174
+ if (!routeNotFoundComponent || (error.global && !route.isRoot))
175
+ throw error
176
+
177
+ return React.createElement(routeNotFoundComponent, {
178
+ data: error.data,
179
+ })
180
+ }}
181
+ >
182
+ <MatchInner matchId={matchId!} pendingElement={pendingElement} />
183
+ </ResolvedNotFoundBoundary>
152
184
  </ResolvedCatchBoundary>
153
185
  </ResolvedSuspenseBoundary>
154
186
  </matchContext.Provider>
@@ -170,16 +202,31 @@ function MatchInner({
170
202
 
171
203
  const route = router.routesById[routeId]!
172
204
 
173
- const match = useRouterState({
174
- select: (s) =>
175
- pick(getRenderedMatches(s).find((d) => d.id === matchId)!, [
205
+ const { match } = useRouterState({
206
+ select: (s) => ({
207
+ match: pick(getRenderedMatches(s).find((d) => d.id === matchId)!, [
176
208
  'status',
177
209
  'error',
178
210
  'showPending',
179
211
  'loadPromise',
212
+ 'notFoundError',
180
213
  ]),
214
+ }),
181
215
  })
182
216
 
217
+ // If a global not-found is found, and it's the root route, render the global not-found component.
218
+ if (match.notFoundError) {
219
+ if (routeId === rootRouteId && !route.options.notFoundComponent)
220
+ return <DefaultGlobalNotFound />
221
+
222
+ invariant(
223
+ route.options.notFoundComponent,
224
+ 'Route matched with notFoundError should have a notFoundComponent',
225
+ )
226
+
227
+ return <route.options.notFoundComponent data={match.notFoundError} />
228
+ }
229
+
183
230
  if (match.status === 'error') {
184
231
  if (isServerSideError(match.error)) {
185
232
  const deserializeError =
package/src/fileRoute.ts CHANGED
@@ -85,12 +85,11 @@ export type ResolveFilePath<
85
85
  export type FileRoutePath<
86
86
  TParentRoute extends AnyRoute,
87
87
  TFilePath extends string,
88
- > =
89
- ResolveFilePath<TParentRoute, TFilePath> extends `_${infer _}`
88
+ > = ResolveFilePath<TParentRoute, TFilePath> extends `_${infer _}`
89
+ ? ''
90
+ : ResolveFilePath<TParentRoute, TFilePath> extends `/_${infer _}`
90
91
  ? ''
91
- : ResolveFilePath<TParentRoute, TFilePath> extends `/_${infer _}`
92
- ? ''
93
- : ResolveFilePath<TParentRoute, TFilePath>
92
+ : ResolveFilePath<TParentRoute, TFilePath>
94
93
 
95
94
  export function createFileRoute<
96
95
  TFilePath extends keyof FileRoutesByPath,
@@ -268,7 +267,7 @@ export function FileRouteLoader<
268
267
 
269
268
  export type LazyRouteOptions = Pick<
270
269
  UpdatableRouteOptions<AnySearchSchema, any>,
271
- 'component' | 'errorComponent' | 'pendingComponent'
270
+ 'component' | 'errorComponent' | 'pendingComponent' | 'notFoundComponent'
272
271
  >
273
272
 
274
273
  export class LazyRoute<TRoute extends AnyRoute> {
package/src/index.tsx CHANGED
@@ -29,3 +29,4 @@ export * from './useRouteContext'
29
29
  export * from './useRouter'
30
30
  export * from './useRouterState'
31
31
  export * from './utils'
32
+ export * from './not-found'
@@ -0,0 +1,54 @@
1
+ import * as React from 'react'
2
+ import { CatchBoundary } from './CatchBoundary'
3
+ import { useRouterState } from './useRouterState'
4
+ import { RegisteredRouter, RouteIds } from '.'
5
+
6
+ export type NotFoundError = {
7
+ global?: boolean
8
+ data?: any
9
+ throw?: boolean
10
+ route?: RouteIds<RegisteredRouter['routeTree']>
11
+ }
12
+
13
+ export function notFound(options: NotFoundError = {}) {
14
+ ;(options as any).isNotFound = true
15
+ if (options.throw) throw options
16
+ return options
17
+ }
18
+
19
+ export function isNotFound(obj: any): obj is NotFoundError {
20
+ return !!obj?.isNotFound
21
+ }
22
+
23
+ export function CatchNotFound(props: {
24
+ fallback?: (error: NotFoundError) => React.ReactElement
25
+ onCatch?: (error: any) => void
26
+ children: React.ReactNode
27
+ }) {
28
+ // TODO: Some way for the user to programmatically reset the not-found boundary?
29
+ const resetKey = useRouterState({
30
+ select: (s) => `not-found-${s.location.pathname}-${s.status}`,
31
+ })
32
+
33
+ return (
34
+ <CatchBoundary
35
+ getResetKey={() => resetKey}
36
+ onCatch={(error) => {
37
+ if (isNotFound(error)) {
38
+ props.onCatch?.(error)
39
+ } else {
40
+ throw error
41
+ }
42
+ }}
43
+ errorComponent={({ error }: { error: NotFoundError }) =>
44
+ props.fallback?.(error)
45
+ }
46
+ >
47
+ {props.children}
48
+ </CatchBoundary>
49
+ )
50
+ }
51
+
52
+ export function DefaultGlobalNotFound() {
53
+ return <p>Not Found</p>
54
+ }
package/src/route.ts CHANGED
@@ -19,6 +19,8 @@ import {
19
19
  } from './utils'
20
20
  import { BuildLocationFn, NavigateFn } from './RouterProvider'
21
21
  import { LazyRoute } from '.'
22
+ import warning from 'tiny-warning'
23
+ import { NotFoundError, notFound } from '.'
22
24
 
23
25
  export const rootRouteId = '__root__' as const
24
26
  export type RootRouteId = typeof rootRouteId
@@ -191,6 +193,7 @@ export type UpdatableRouteOptions<
191
193
  // The content to be rendered when the route is matched. If no component is provided, defaults to `<Outlet />`
192
194
  component?: RouteComponent
193
195
  errorComponent?: false | null | ErrorRouteComponent
196
+ notFoundComponent?: NotFoundRouteComponent
194
197
  pendingComponent?: RouteComponent
195
198
  pendingMs?: number
196
199
  pendingMinMs?: number
@@ -368,12 +371,11 @@ export type MergeFromFromParent<T, U> = IsAny<T, U, T & U>
368
371
  export type ResolveAllParams<
369
372
  TParentRoute extends AnyRoute,
370
373
  TParams extends AnyPathParams,
371
- > =
372
- Record<never, string> extends TParentRoute['types']['allParams']
373
- ? TParams
374
- : Expand<
375
- UnionToIntersection<TParentRoute['types']['allParams'] & TParams> & {}
376
- >
374
+ > = Record<never, string> extends TParentRoute['types']['allParams']
375
+ ? TParams
376
+ : Expand<
377
+ UnionToIntersection<TParentRoute['types']['allParams'] & TParams> & {}
378
+ >
377
379
 
378
380
  export type RouteConstraints = {
379
381
  TParentRoute: AnyRoute
@@ -573,6 +575,10 @@ export class RouteApi<
573
575
  }): TSelected => {
574
576
  return useLoaderData({ ...opts, from: this.id } as any)
575
577
  }
578
+
579
+ notFound = (opts?: NotFoundError) => {
580
+ return notFound({ route: this.id as string, ...opts })
581
+ }
576
582
  }
577
583
 
578
584
  /**
@@ -1249,6 +1255,10 @@ export type ErrorComponentProps = {
1249
1255
  error: unknown
1250
1256
  info: { componentStack: string }
1251
1257
  }
1258
+ export type NotFoundRouteProps = {
1259
+ // TODO: Make sure this is `| null | undefined` (this is for global not-founds)
1260
+ data: unknown
1261
+ }
1252
1262
  //
1253
1263
 
1254
1264
  export type ReactNode = any
@@ -1266,6 +1276,8 @@ export type RouteComponent<TProps = any> = SyncRouteComponent<TProps> &
1266
1276
 
1267
1277
  export type ErrorRouteComponent = RouteComponent<ErrorComponentProps>
1268
1278
 
1279
+ export type NotFoundRouteComponent = SyncRouteComponent<NotFoundRouteProps>
1280
+
1269
1281
  export class NotFoundRoute<
1270
1282
  TParentRoute extends AnyRootRoute,
1271
1283
  TSearchSchemaInput extends Record<string, any> = {},
package/src/router.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  RouteMask,
18
18
  Route,
19
19
  LoaderFnContext,
20
+ rootRouteId,
20
21
  } from './route'
21
22
  import {
22
23
  FullSearchSchema,
@@ -63,6 +64,7 @@ import {
63
64
  } from './path'
64
65
  import invariant from 'tiny-invariant'
65
66
  import { isRedirect } from './redirects'
67
+ import { NotFoundError, isNotFound } from './not-found'
66
68
  import { ResolveRelativePath, ToOptions } from './link'
67
69
  import { NoInfer } from '@tanstack/react-store'
68
70
  // import warning from 'tiny-warning'
@@ -131,9 +133,15 @@ export interface RouterOptions<
131
133
  unmaskOnReload?: boolean
132
134
  Wrap?: (props: { children: any }) => JSX.Element
133
135
  InnerWrap?: (props: { children: any }) => JSX.Element
136
+ /**
137
+ * @deprecated
138
+ * Use `notFoundComponent` instead.
139
+ * See https://tanstack.com/router/v1/docs/guide/not-found-errors#migrating-from-notfoundroute for more info.
140
+ */
134
141
  notFoundRoute?: AnyRoute
135
142
  transformer?: RouterTransformer
136
143
  errorSerializer?: RouterErrorSerializer<TSerializedError>
144
+ globalNotFound?: RouteComponent
137
145
  }
138
146
 
139
147
  export interface RouterTransformer {
@@ -182,7 +190,7 @@ export interface DehydratedRouterState {
182
190
 
183
191
  export type DehydratedRouteMatch = Pick<
184
192
  RouteMatch,
185
- 'id' | 'status' | 'updatedAt' | 'loaderData'
193
+ 'id' | 'status' | 'updatedAt' | 'notFoundError' | 'loaderData'
186
194
  >
187
195
 
188
196
  export interface DehydratedRouter {
@@ -200,6 +208,7 @@ export const componentTypes = [
200
208
  'component',
201
209
  'errorComponent',
202
210
  'pendingComponent',
211
+ 'notFoundComponent',
203
212
  ] as const
204
213
 
205
214
  export type RouterEvents = {
@@ -309,6 +318,12 @@ export class Router<
309
318
  TSerializedError
310
319
  >,
311
320
  ) => {
321
+ if (newOptions.notFoundRoute) {
322
+ console.warn(
323
+ 'The notFoundRoute API is deprecated and will be removed in the next major version. See https://tanstack.com/router/v1/docs/guide/not-found-errors#migrating-from-notfoundroute for more info.',
324
+ )
325
+ }
326
+
312
327
  const previousOptions = this.options
313
328
  this.options = {
314
329
  ...this.options,
@@ -593,17 +608,23 @@ export class Router<
593
608
 
594
609
  let matchedRoutes: AnyRoute[] = [routeCursor]
595
610
 
611
+ let isGlobalNotFound = false
612
+
596
613
  // Check to see if the route needs a 404 entry
597
614
  if (
598
615
  // If we found a route, and it's not an index route and we have left over path
599
- (foundRoute
616
+ foundRoute
600
617
  ? foundRoute.path !== '/' && routeParams['**']
601
618
  : // Or if we didn't find a route and we have left over path
602
- trimPathRight(pathname)) &&
603
- // And we have a 404 route configured
604
- this.options.notFoundRoute
619
+ trimPathRight(pathname)
605
620
  ) {
606
- matchedRoutes.push(this.options.notFoundRoute)
621
+ // If the user has defined an (old) 404 route, use it
622
+ if (this.options.notFoundRoute) {
623
+ matchedRoutes.push(this.options.notFoundRoute)
624
+ } else {
625
+ // If there is no routes found during path matching
626
+ isGlobalNotFound = true
627
+ }
607
628
  }
608
629
 
609
630
  while (routeCursor?.parentRoute) {
@@ -722,7 +743,14 @@ export class Router<
722
743
  )
723
744
 
724
745
  const match: AnyRouteMatch = existingMatch
725
- ? { ...existingMatch, cause }
746
+ ? {
747
+ ...existingMatch,
748
+ cause,
749
+ notFoundError:
750
+ isGlobalNotFound && route.id === rootRouteId
751
+ ? { global: true }
752
+ : undefined,
753
+ }
726
754
  : {
727
755
  id: matchId,
728
756
  routeId: route.id,
@@ -745,6 +773,10 @@ export class Router<
745
773
  loaderDeps,
746
774
  invalid: false,
747
775
  preload: false,
776
+ notFoundError:
777
+ isGlobalNotFound && route.id === rootRouteId
778
+ ? { global: true }
779
+ : undefined,
748
780
  links: route.options.links?.(),
749
781
  scripts: route.options.scripts?.(),
750
782
  staticData: route.options.staticData || {},
@@ -793,8 +825,8 @@ export class Router<
793
825
  this.latestLocation.pathname,
794
826
  fromSearch,
795
827
  )
796
- const stayingMatches = matches?.filter((d) =>
797
- fromMatches?.find((e) => e.routeId === d.routeId),
828
+ const stayingMatches = matches?.filter(
829
+ (d) => fromMatches?.find((e) => e.routeId === d.routeId),
798
830
  )
799
831
 
800
832
  const prevParams = { ...last(fromMatches)?.params }
@@ -1110,6 +1142,10 @@ export class Router<
1110
1142
  throw err
1111
1143
  }
1112
1144
 
1145
+ if (isNotFound(err)) {
1146
+ this.updateMatchesWithNotFound(matches, match, err)
1147
+ }
1148
+
1113
1149
  try {
1114
1150
  route.options.onError?.(err)
1115
1151
  } catch (errorHandlerErr) {
@@ -1212,6 +1248,11 @@ export class Router<
1212
1248
  }
1213
1249
  return true
1214
1250
  }
1251
+
1252
+ if (isNotFound(err)) {
1253
+ this.updateMatchesWithNotFound(matches, match, err)
1254
+ }
1255
+
1215
1256
  return false
1216
1257
  }
1217
1258
 
@@ -1708,7 +1749,15 @@ export class Router<
1708
1749
  return {
1709
1750
  state: {
1710
1751
  dehydratedMatches: this.state.matches.map((d) => ({
1711
- ...pick(d, ['id', 'status', 'updatedAt', 'loaderData']),
1752
+ ...pick(d, [
1753
+ 'id',
1754
+ 'status',
1755
+ 'updatedAt',
1756
+ 'loaderData',
1757
+ // Not-founds that occur during SSR don't require the client to load data before
1758
+ // triggering in order to prevent the flicker of the loading component
1759
+ 'notFoundError',
1760
+ ]),
1712
1761
  // If an error occurs server-side during SSRing,
1713
1762
  // send a small subset of the error to the client
1714
1763
  error: d.error
@@ -1777,6 +1826,46 @@ export class Router<
1777
1826
  })
1778
1827
  }
1779
1828
 
1829
+ // Finds a match that has a notFoundComponent
1830
+ updateMatchesWithNotFound = (
1831
+ matches: AnyRouteMatch[],
1832
+ currentMatch: AnyRouteMatch,
1833
+ err: NotFoundError,
1834
+ ) => {
1835
+ const matchesByRouteId = Object.fromEntries(
1836
+ matches.map((match) => [match.routeId, match]),
1837
+ ) as Record<string, AnyRouteMatch>
1838
+
1839
+ if (err.global) {
1840
+ matchesByRouteId[rootRouteId]!.notFoundError = err
1841
+ } else {
1842
+ // If the err contains a routeId, start searching up from that route
1843
+ let currentRoute = (this.routesById as any)[
1844
+ err.route ?? currentMatch.routeId
1845
+ ] as AnyRoute
1846
+
1847
+ // Go up the tree until we find a route with a notFoundComponent
1848
+ while (!currentRoute.options.notFoundComponent) {
1849
+ currentRoute = currentRoute?.parentRoute
1850
+
1851
+ invariant(
1852
+ currentRoute,
1853
+ 'Found invalid route tree while trying to find not-found handler.',
1854
+ )
1855
+
1856
+ if (currentRoute.id === rootRouteId) break
1857
+ }
1858
+
1859
+ const match = matchesByRouteId[currentRoute.id]
1860
+ invariant(match, 'Could not find match for route: ' + currentRoute.id)
1861
+ match.notFoundError = err
1862
+ }
1863
+ }
1864
+
1865
+ hasNotFoundMatch = () => {
1866
+ return this.__store.state.matches.some((d) => d.notFoundError)
1867
+ }
1868
+
1780
1869
  // resolveMatchPromise = (matchId: string, key: string, value: any) => {
1781
1870
  // state.matches
1782
1871
  // .find((d) => d.id === matchId)