@tanstack/vue-router 1.141.4 → 1.141.6

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.
Files changed (39) hide show
  1. package/dist/esm/ClientOnly.js +33 -0
  2. package/dist/esm/ClientOnly.js.map +1 -0
  3. package/dist/esm/Match.js +66 -39
  4. package/dist/esm/Match.js.map +1 -1
  5. package/dist/esm/Transitioner.js +14 -5
  6. package/dist/esm/Transitioner.js.map +1 -1
  7. package/dist/esm/index.d.ts +2 -1
  8. package/dist/esm/index.js +2 -0
  9. package/dist/esm/index.js.map +1 -1
  10. package/dist/esm/lazyRouteComponent.d.ts +0 -6
  11. package/dist/esm/lazyRouteComponent.js +5 -24
  12. package/dist/esm/lazyRouteComponent.js.map +1 -1
  13. package/dist/esm/link.d.ts +4 -0
  14. package/dist/esm/link.js.map +1 -1
  15. package/dist/esm/route.d.ts +6 -1
  16. package/dist/esm/route.js +25 -0
  17. package/dist/esm/route.js.map +1 -1
  18. package/dist/source/Match.jsx +90 -63
  19. package/dist/source/Match.jsx.map +1 -1
  20. package/dist/source/Transitioner.jsx +12 -5
  21. package/dist/source/Transitioner.jsx.map +1 -1
  22. package/dist/source/index.d.ts +2 -1
  23. package/dist/source/index.jsx +1 -0
  24. package/dist/source/index.jsx.map +1 -1
  25. package/dist/source/lazyRouteComponent.d.ts +0 -6
  26. package/dist/source/lazyRouteComponent.jsx +3 -23
  27. package/dist/source/lazyRouteComponent.jsx.map +1 -1
  28. package/dist/source/link.d.ts +4 -0
  29. package/dist/source/link.jsx.map +1 -1
  30. package/dist/source/route.d.ts +6 -1
  31. package/dist/source/route.js +13 -0
  32. package/dist/source/route.js.map +1 -1
  33. package/package.json +2 -2
  34. package/src/Match.tsx +115 -73
  35. package/src/Transitioner.tsx +15 -6
  36. package/src/index.tsx +2 -0
  37. package/src/lazyRouteComponent.tsx +10 -32
  38. package/src/link.tsx +20 -0
  39. package/src/route.ts +33 -1
package/src/Match.tsx CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  rootRouteId,
10
10
  } from '@tanstack/router-core'
11
11
  import { CatchBoundary, ErrorComponent } from './CatchBoundary'
12
+ import { ClientOnly } from './ClientOnly'
12
13
  import { useRouterState } from './useRouterState'
13
14
  import { useRouter } from './useRouter'
14
15
  import { CatchNotFound } from './not-found'
@@ -16,7 +17,7 @@ import { matchContext } from './matchContext'
16
17
  import { renderRouteNotFound } from './renderRouteNotFound'
17
18
  import { ScrollRestoration } from './scroll-restoration'
18
19
  import type { VNode } from 'vue'
19
- import type { AnyRoute } from '@tanstack/router-core'
20
+ import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core'
20
21
 
21
22
  export const Match = Vue.defineComponent({
22
23
  name: 'Match',
@@ -67,6 +68,8 @@ export const Match = Vue.defineComponent({
67
68
  routeId,
68
69
  parentRouteId,
69
70
  loadedAt: s.loadedAt,
71
+ ssr: match.ssr,
72
+ _displayPending: match._displayPending,
70
73
  }
71
74
  },
72
75
  })
@@ -86,6 +89,10 @@ export const Match = Vue.defineComponent({
86
89
  router?.options?.defaultPendingComponent,
87
90
  )
88
91
 
92
+ const pendingElement = Vue.computed(() =>
93
+ PendingComponent.value ? Vue.h(PendingComponent.value) : undefined,
94
+ )
95
+
89
96
  const routeErrorComponent = Vue.computed(
90
97
  () =>
91
98
  route.value?.options?.errorComponent ??
@@ -104,6 +111,17 @@ export const Match = Vue.defineComponent({
104
111
  : route.value?.options?.notFoundComponent,
105
112
  )
106
113
 
114
+ const hasShellComponent = Vue.computed(() => {
115
+ if (!route.value?.isRoot) return false
116
+ return !!(route.value.options as RootRouteOptions).shellComponent
117
+ })
118
+
119
+ const ShellComponent = Vue.computed(() =>
120
+ hasShellComponent.value
121
+ ? ((route.value!.options as RootRouteOptions).shellComponent as any)
122
+ : null,
123
+ )
124
+
107
125
  // Create a ref for the current matchId that we provide to child components
108
126
  // This ref is updated to the ACTUAL matchId found (which may differ from props during transitions)
109
127
  const matchIdRef = Vue.ref(matchData.value?.matchId ?? props.matchId)
@@ -127,82 +145,89 @@ export const Match = Vue.defineComponent({
127
145
  // Use the actual matchId from matchData, not props (which may be stale)
128
146
  const actualMatchId = matchData.value?.matchId ?? props.matchId
129
147
 
130
- // Determine which components to render
131
- let content: VNode = Vue.h(MatchInner, { matchId: actualMatchId })
132
-
133
- // Wrap in NotFound boundary if needed
134
- if (routeNotFoundComponent.value) {
135
- content = Vue.h(CatchNotFound, {
136
- fallback: (error: any) => {
137
- // If the current not found handler doesn't exist or it has a
138
- // route ID which doesn't match the current route, rethrow the error
139
- if (
140
- !routeNotFoundComponent.value ||
141
- (error.routeId && error.routeId !== matchData.value?.routeId) ||
142
- (!error.routeId && route.value && !route.value.isRoot)
148
+ const resolvedNoSsr =
149
+ matchData.value?.ssr === false || matchData.value?.ssr === 'data-only'
150
+ const shouldClientOnly =
151
+ resolvedNoSsr || !!matchData.value?._displayPending
152
+
153
+ const renderMatchContent = (): VNode => {
154
+ const matchInner = Vue.h(MatchInner, { matchId: actualMatchId })
155
+
156
+ let content: VNode = shouldClientOnly
157
+ ? Vue.h(
158
+ ClientOnly,
159
+ {
160
+ fallback: pendingElement.value,
161
+ },
162
+ {
163
+ default: () => matchInner,
164
+ },
143
165
  )
144
- throw error
145
-
146
- return Vue.h(routeNotFoundComponent.value, error)
147
- },
148
- children: content,
149
- })
150
- }
166
+ : matchInner
167
+
168
+ // Wrap in NotFound boundary if needed
169
+ if (routeNotFoundComponent.value) {
170
+ content = Vue.h(CatchNotFound, {
171
+ fallback: (error: any) => {
172
+ // If the current not found handler doesn't exist or it has a
173
+ // route ID which doesn't match the current route, rethrow the error
174
+ if (
175
+ !routeNotFoundComponent.value ||
176
+ (error.routeId && error.routeId !== matchData.value?.routeId) ||
177
+ (!error.routeId && route.value && !route.value.isRoot)
178
+ )
179
+ throw error
180
+
181
+ return Vue.h(routeNotFoundComponent.value, error)
182
+ },
183
+ children: content,
184
+ })
185
+ }
151
186
 
152
- // Wrap in error boundary if needed
153
- if (routeErrorComponent.value) {
154
- content = CatchBoundary({
155
- getResetKey: () => matchData.value?.loadedAt ?? 0,
156
- errorComponent: routeErrorComponent.value || ErrorComponent,
157
- onCatch: (error: Error) => {
158
- // Forward not found errors (we don't want to show the error component for these)
159
- if (isNotFound(error)) throw error
160
- warning(false, `Error in route match: ${actualMatchId}`)
161
- routeOnCatch.value?.(error)
162
- },
163
- children: content,
164
- })
165
- }
187
+ // Wrap in error boundary if needed
188
+ if (routeErrorComponent.value) {
189
+ content = CatchBoundary({
190
+ getResetKey: () => matchData.value?.loadedAt ?? 0,
191
+ errorComponent: routeErrorComponent.value || ErrorComponent,
192
+ onCatch: (error: Error) => {
193
+ // Forward not found errors (we don't want to show the error component for these)
194
+ if (isNotFound(error)) throw error
195
+ warning(false, `Error in route match: ${actualMatchId}`)
196
+ routeOnCatch.value?.(error)
197
+ },
198
+ children: content,
199
+ })
200
+ }
166
201
 
167
- // Wrap in suspense if needed
168
- // Root routes should also wrap in Suspense if they have a pendingComponent
169
- const needsSuspense =
170
- route.value &&
171
- (route.value?.options?.wrapInSuspense ??
172
- PendingComponent.value ??
173
- false)
202
+ // Add scroll restoration if needed
203
+ const withScrollRestoration: Array<VNode> = [
204
+ content,
205
+ matchData.value?.parentRouteId === rootRouteId &&
206
+ router.options.scrollRestoration
207
+ ? Vue.h(Vue.Fragment, null, [
208
+ Vue.h(OnRendered),
209
+ Vue.h(ScrollRestoration),
210
+ ])
211
+ : null,
212
+ ].filter(Boolean) as Array<VNode>
213
+
214
+ // Return single child directly to avoid Fragment wrapper that causes hydration mismatch
215
+ if (withScrollRestoration.length === 1) {
216
+ return withScrollRestoration[0]!
217
+ }
174
218
 
175
- if (needsSuspense) {
176
- content = Vue.h(
177
- Vue.Suspense,
178
- {
179
- fallback: PendingComponent.value
180
- ? Vue.h(PendingComponent.value)
181
- : null,
182
- },
183
- {
184
- default: () => content,
185
- },
186
- )
219
+ return Vue.h(Vue.Fragment, null, withScrollRestoration)
187
220
  }
188
221
 
189
- // Add scroll restoration if needed
190
- const withScrollRestoration: Array<VNode> = [
191
- content,
192
- matchData.value?.parentRouteId === rootRouteId &&
193
- router.options.scrollRestoration
194
- ? Vue.h(Vue.Fragment, null, [
195
- Vue.h(OnRendered),
196
- Vue.h(ScrollRestoration),
197
- ])
198
- : null,
199
- ].filter(Boolean) as Array<VNode>
200
-
201
- // Return single child directly to avoid Fragment wrapper that causes hydration mismatch
202
- if (withScrollRestoration.length === 1) {
203
- return withScrollRestoration[0]!
222
+ if (!hasShellComponent.value) {
223
+ return renderMatchContent()
204
224
  }
205
- return Vue.h(Vue.Fragment, null, withScrollRestoration)
225
+
226
+ return Vue.h(ShellComponent.value, null, {
227
+ // Important: return a fresh VNode on each slot invocation so that shell
228
+ // components can re-render without reusing a cached VNode instance.
229
+ default: () => renderMatchContent(),
230
+ })
206
231
  }
207
232
  },
208
233
  })
@@ -304,6 +329,9 @@ export const MatchInner = Vue.defineComponent({
304
329
  id: match.id,
305
330
  status: match.status,
306
331
  error: match.error,
332
+ ssr: match.ssr,
333
+ _forcePending: match._forcePending,
334
+ _displayPending: match._displayPending,
307
335
  },
308
336
  remountKey,
309
337
  }
@@ -320,11 +348,25 @@ export const MatchInner = Vue.defineComponent({
320
348
 
321
349
  return (): VNode | null => {
322
350
  // If match doesn't exist, return null (component is being unmounted or not ready)
323
- if (!combinedState.value || !match.value || !route.value) {
324
- return null
325
- }
351
+ if (!combinedState.value || !match.value || !route.value) return null
326
352
 
327
353
  // Handle different match statuses
354
+ if (match.value._displayPending) {
355
+ const PendingComponent =
356
+ route.value.options.pendingComponent ??
357
+ router.options.defaultPendingComponent
358
+
359
+ return PendingComponent ? Vue.h(PendingComponent) : null
360
+ }
361
+
362
+ if (match.value._forcePending) {
363
+ const PendingComponent =
364
+ route.value.options.pendingComponent ??
365
+ router.options.defaultPendingComponent
366
+
367
+ return PendingComponent ? Vue.h(PendingComponent) : null
368
+ }
369
+
328
370
  if (match.value.status === 'notFound') {
329
371
  invariant(isNotFound(match.value.error), 'Expected a notFound error')
330
372
  return renderRouteNotFound(router, route.value, match.value.error)
@@ -136,6 +136,13 @@ export function useTransitionerSetup() {
136
136
 
137
137
  Vue.onMounted(() => {
138
138
  isMounted.value = true
139
+ if (!isAnyPending.value) {
140
+ router.__store.setState((s) =>
141
+ s.status === 'pending'
142
+ ? { ...s, status: 'idle', resolvedLocation: s.location }
143
+ : s,
144
+ )
145
+ }
139
146
  })
140
147
 
141
148
  Vue.onUnmounted(() => {
@@ -201,6 +208,14 @@ export function useTransitionerSetup() {
201
208
  Vue.watch(isAnyPending, (newValue) => {
202
209
  if (!isMounted.value) return
203
210
  try {
211
+ if (!newValue && router.__store.state.status === 'pending') {
212
+ router.__store.setState((s) => ({
213
+ ...s,
214
+ status: 'idle',
215
+ resolvedLocation: s.location,
216
+ }))
217
+ }
218
+
204
219
  // The router was pending and now it's not
205
220
  if (previousIsAnyPending.value.previous && !newValue) {
206
221
  const changeInfo = getLocationChangeInfo(router.state)
@@ -209,12 +224,6 @@ export function useTransitionerSetup() {
209
224
  ...changeInfo,
210
225
  })
211
226
 
212
- router.__store.setState((s) => ({
213
- ...s,
214
- status: 'idle',
215
- resolvedLocation: s.location,
216
- }))
217
-
218
227
  if (changeInfo.hrefChanged) {
219
228
  handleHashScroll(router)
220
229
  }
package/src/index.tsx CHANGED
@@ -219,6 +219,7 @@ export type {
219
219
  ActiveLinkOptions,
220
220
  LinkProps,
221
221
  LinkComponent,
222
+ LinkComponentRoute,
222
223
  LinkComponentProps,
223
224
  CreateLinkProps,
224
225
  } from './link'
@@ -346,3 +347,4 @@ export type {
346
347
  LocationRewrite,
347
348
  LocationRewriteFunction,
348
349
  } from '@tanstack/router-core'
350
+ export { ClientOnly } from './ClientOnly'
@@ -1,5 +1,6 @@
1
1
  import * as Vue from 'vue'
2
2
  import { Outlet } from './Match'
3
+ import { ClientOnly } from './ClientOnly'
3
4
  import type { AsyncRouteComponent } from './route'
4
5
 
5
6
  // If the load fails due to module not found, it may mean a new version of
@@ -19,34 +20,6 @@ function isModuleNotFoundError(error: any): boolean {
19
20
  )
20
21
  }
21
22
 
22
- export function ClientOnly(props: { children?: any; fallback?: Vue.VNode }) {
23
- const hydrated = useHydrated()
24
-
25
- return () => {
26
- if (hydrated.value) {
27
- return props.children
28
- }
29
- return props.fallback || null
30
- }
31
- }
32
-
33
- export function useHydrated() {
34
- // Only hydrate on client-side, never on server
35
- const hydrated = Vue.ref(false)
36
-
37
- // If on server, return false
38
- if (typeof window === 'undefined') {
39
- return Vue.computed(() => false)
40
- }
41
-
42
- // On client, set to true once mounted
43
- Vue.onMounted(() => {
44
- hydrated.value = true
45
- })
46
-
47
- return hydrated
48
- }
49
-
50
23
  export function lazyRouteComponent<
51
24
  T extends Record<string, any>,
52
25
  TKey extends keyof T = 'default',
@@ -156,10 +129,15 @@ export function lazyRouteComponent<
156
129
 
157
130
  // If SSR is disabled for this component
158
131
  if (ssr?.() === false) {
159
- return Vue.h(ClientOnly, {
160
- fallback: Vue.h(Outlet),
161
- children: Vue.h(component.value, props),
162
- })
132
+ return Vue.h(
133
+ ClientOnly,
134
+ {
135
+ fallback: Vue.h(Outlet),
136
+ },
137
+ {
138
+ default: () => Vue.h(component.value, props),
139
+ },
140
+ )
163
141
  }
164
142
 
165
143
  // Regular render with the loaded component
package/src/link.tsx CHANGED
@@ -630,6 +630,26 @@ export type LinkComponent<TComp> = <
630
630
  props: LinkComponentProps<TComp, TRouter, TFrom, TTo, TMaskFrom, TMaskTo>,
631
631
  ) => Vue.VNode
632
632
 
633
+ export interface LinkComponentRoute<
634
+ in out TDefaultFrom extends string = string,
635
+ > {
636
+ defaultFrom: TDefaultFrom
637
+ <
638
+ TRouter extends AnyRouter = RegisteredRouter,
639
+ const TTo extends string | undefined = undefined,
640
+ const TMaskTo extends string = '',
641
+ >(
642
+ props: LinkComponentProps<
643
+ 'a',
644
+ TRouter,
645
+ this['defaultFrom'],
646
+ TTo,
647
+ this['defaultFrom'],
648
+ TMaskTo
649
+ >,
650
+ ): Vue.VNode
651
+ }
652
+
633
653
  export function createLink<const TComp>(
634
654
  Comp: Constrain<TComp, any, (props: CreateLinkProps) => Vue.VNode>,
635
655
  ): LinkComponent<TComp> {
package/src/route.ts CHANGED
@@ -4,6 +4,8 @@ import {
4
4
  BaseRouteApi,
5
5
  notFound,
6
6
  } from '@tanstack/router-core'
7
+ import * as Vue from 'vue'
8
+ import { Link } from './link'
7
9
  import { useLoaderData } from './useLoaderData'
8
10
  import { useLoaderDeps } from './useLoaderDeps'
9
11
  import { useParams } from './useParams'
@@ -42,8 +44,8 @@ import type { UseMatchRoute } from './useMatch'
42
44
  import type { UseLoaderDepsRoute } from './useLoaderDeps'
43
45
  import type { UseParamsRoute } from './useParams'
44
46
  import type { UseSearchRoute } from './useSearch'
45
- import type * as Vue from 'vue'
46
47
  import type { UseRouteContextRoute } from './useRouteContext'
48
+ import type { LinkComponentRoute } from './link'
47
49
 
48
50
  // Structural type for Vue SFC components (.vue files)
49
51
  // Uses structural matching to accept Vue components without breaking
@@ -73,6 +75,7 @@ declare module '@tanstack/router-core' {
73
75
  useLoaderDeps: UseLoaderDepsRoute<TId>
74
76
  useLoaderData: UseLoaderDataRoute<TId>
75
77
  useNavigate: () => UseNavigateResult<TFullPath>
78
+ Link: LinkComponentRoute<TFullPath>
76
79
  }
77
80
  }
78
81
 
@@ -140,6 +143,19 @@ export class RouteApi<
140
143
  notFound = (opts?: NotFoundError) => {
141
144
  return notFound({ routeId: this.id as string, ...opts })
142
145
  }
146
+
147
+ Link: LinkComponentRoute<RouteTypesById<TRouter, TId>['fullPath']> = ((
148
+ props,
149
+ ctx?: Vue.SetupContext,
150
+ ) => {
151
+ const router = useRouter()
152
+ const fullPath = router.routesById[this.id as string].fullPath
153
+ return Vue.h(
154
+ Link as any,
155
+ { from: fullPath as never, ...(props as any) },
156
+ ctx?.slots,
157
+ )
158
+ }) as LinkComponentRoute<RouteTypesById<TRouter, TId>['fullPath']>
143
159
  }
144
160
 
145
161
  export class Route<
@@ -277,6 +293,14 @@ export class Route<
277
293
  useNavigate = (): UseNavigateResult<TFullPath> => {
278
294
  return useNavigate({ from: this.fullPath })
279
295
  }
296
+
297
+ Link: LinkComponentRoute<TFullPath> = ((props, ctx?: Vue.SetupContext) => {
298
+ return Vue.h(
299
+ Link as any,
300
+ { from: this.fullPath as never, ...(props as any) },
301
+ ctx?.slots,
302
+ )
303
+ }) as LinkComponentRoute<TFullPath>
280
304
  }
281
305
 
282
306
  export function createRoute<
@@ -515,6 +539,14 @@ export class RootRoute<
515
539
  useNavigate = (): UseNavigateResult<'/'> => {
516
540
  return useNavigate({ from: this.fullPath })
517
541
  }
542
+
543
+ Link: LinkComponentRoute<'/'> = ((props, ctx?: Vue.SetupContext) => {
544
+ return Vue.h(
545
+ Link as any,
546
+ { from: this.fullPath as never, ...(props as any) },
547
+ ctx?.slots,
548
+ )
549
+ }) as LinkComponentRoute<'/'>
518
550
  }
519
551
 
520
552
  export function createRouteMask<