@tanstack/vue-router 1.167.4 → 1.168.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.
Files changed (95) hide show
  1. package/dist/esm/Match.js +55 -61
  2. package/dist/esm/Match.js.map +1 -1
  3. package/dist/esm/Matches.js +8 -15
  4. package/dist/esm/Matches.js.map +1 -1
  5. package/dist/esm/Scripts.js +7 -6
  6. package/dist/esm/Scripts.js.map +1 -1
  7. package/dist/esm/Transitioner.js +18 -24
  8. package/dist/esm/Transitioner.js.map +1 -1
  9. package/dist/esm/headContentUtils.js +13 -15
  10. package/dist/esm/headContentUtils.js.map +1 -1
  11. package/dist/esm/index.dev.js +6 -6
  12. package/dist/esm/index.js +6 -6
  13. package/dist/esm/link.js +242 -178
  14. package/dist/esm/link.js.map +1 -1
  15. package/dist/esm/matchContext.d.ts +8 -14
  16. package/dist/esm/matchContext.js +11 -9
  17. package/dist/esm/matchContext.js.map +1 -1
  18. package/dist/esm/not-found.js +6 -3
  19. package/dist/esm/not-found.js.map +1 -1
  20. package/dist/esm/router.js +2 -1
  21. package/dist/esm/router.js.map +1 -1
  22. package/dist/esm/routerStores.d.ts +13 -0
  23. package/dist/esm/routerStores.js +33 -0
  24. package/dist/esm/routerStores.js.map +1 -0
  25. package/dist/esm/ssr/RouterClient.js +1 -1
  26. package/dist/esm/ssr/RouterClient.js.map +1 -1
  27. package/dist/esm/ssr/renderRouterToStream.js +2 -2
  28. package/dist/esm/ssr/renderRouterToStream.js.map +1 -1
  29. package/dist/esm/ssr/renderRouterToString.js +1 -1
  30. package/dist/esm/ssr/renderRouterToString.js.map +1 -1
  31. package/dist/esm/useCanGoBack.d.ts +1 -1
  32. package/dist/esm/useCanGoBack.js +3 -2
  33. package/dist/esm/useCanGoBack.js.map +1 -1
  34. package/dist/esm/useLocation.js +3 -2
  35. package/dist/esm/useLocation.js.map +1 -1
  36. package/dist/esm/useMatch.js +29 -19
  37. package/dist/esm/useMatch.js.map +1 -1
  38. package/dist/esm/useRouterState.js +4 -4
  39. package/dist/esm/useRouterState.js.map +1 -1
  40. package/dist/source/Match.jsx +121 -159
  41. package/dist/source/Match.jsx.map +1 -1
  42. package/dist/source/Matches.jsx +11 -28
  43. package/dist/source/Matches.jsx.map +1 -1
  44. package/dist/source/Scripts.jsx +32 -35
  45. package/dist/source/Scripts.jsx.map +1 -1
  46. package/dist/source/Transitioner.jsx +19 -21
  47. package/dist/source/Transitioner.jsx.map +1 -1
  48. package/dist/source/headContentUtils.jsx +51 -61
  49. package/dist/source/headContentUtils.jsx.map +1 -1
  50. package/dist/source/link.jsx +298 -249
  51. package/dist/source/link.jsx.map +1 -1
  52. package/dist/source/matchContext.d.ts +8 -14
  53. package/dist/source/matchContext.jsx +17 -23
  54. package/dist/source/matchContext.jsx.map +1 -1
  55. package/dist/source/not-found.jsx +6 -5
  56. package/dist/source/not-found.jsx.map +1 -1
  57. package/dist/source/router.js +2 -1
  58. package/dist/source/router.js.map +1 -1
  59. package/dist/source/routerStores.d.ts +13 -0
  60. package/dist/source/routerStores.js +37 -0
  61. package/dist/source/routerStores.js.map +1 -0
  62. package/dist/source/ssr/RouterClient.jsx +1 -1
  63. package/dist/source/ssr/RouterClient.jsx.map +1 -1
  64. package/dist/source/ssr/renderRouterToStream.jsx +2 -2
  65. package/dist/source/ssr/renderRouterToStream.jsx.map +1 -1
  66. package/dist/source/ssr/renderRouterToString.jsx +1 -1
  67. package/dist/source/ssr/renderRouterToString.jsx.map +1 -1
  68. package/dist/source/useCanGoBack.d.ts +1 -1
  69. package/dist/source/useCanGoBack.js +4 -2
  70. package/dist/source/useCanGoBack.js.map +1 -1
  71. package/dist/source/useLocation.jsx +4 -4
  72. package/dist/source/useLocation.jsx.map +1 -1
  73. package/dist/source/useMatch.jsx +60 -38
  74. package/dist/source/useMatch.jsx.map +1 -1
  75. package/dist/source/useRouterState.jsx +4 -4
  76. package/dist/source/useRouterState.jsx.map +1 -1
  77. package/package.json +2 -2
  78. package/skills/vue-router/SKILL.md +3 -0
  79. package/src/Match.tsx +168 -180
  80. package/src/Matches.tsx +18 -31
  81. package/src/Scripts.tsx +40 -40
  82. package/src/Transitioner.tsx +35 -23
  83. package/src/headContentUtils.tsx +101 -107
  84. package/src/link.tsx +445 -300
  85. package/src/matchContext.tsx +23 -25
  86. package/src/not-found.tsx +9 -5
  87. package/src/router.ts +2 -1
  88. package/src/routerStores.ts +54 -0
  89. package/src/ssr/RouterClient.tsx +1 -1
  90. package/src/ssr/renderRouterToStream.tsx +2 -2
  91. package/src/ssr/renderRouterToString.tsx +1 -1
  92. package/src/useCanGoBack.ts +7 -2
  93. package/src/useLocation.tsx +8 -5
  94. package/src/useMatch.tsx +95 -49
  95. package/src/useRouterState.tsx +6 -4
package/src/link.tsx CHANGED
@@ -6,16 +6,17 @@ import {
6
6
  preloadWarning,
7
7
  removeTrailingSlash,
8
8
  } from '@tanstack/router-core'
9
+ import { isServer } from '@tanstack/router-core/isServer'
9
10
 
10
- import { useRouterState } from './useRouterState'
11
+ import { useStore } from '@tanstack/vue-store'
11
12
  import { useRouter } from './useRouter'
12
13
  import { useIntersectionObserver } from './utils'
13
- import { useMatches } from './Matches'
14
14
 
15
15
  import type {
16
16
  AnyRouter,
17
17
  Constrain,
18
18
  LinkOptions,
19
+ ParsedLocation,
19
20
  RegisteredRouter,
20
21
  RoutePaths,
21
22
  } from '@tanstack/router-core'
@@ -48,6 +49,14 @@ type LinkHTMLAttributes = AnchorHTMLAttributes &
48
49
  disabled?: boolean
49
50
  }
50
51
 
52
+ type VueStyleLinkEventHandlers = {
53
+ onMouseenter?: EventHandler<MouseEvent>
54
+ onMouseleave?: EventHandler<MouseEvent>
55
+ onMouseover?: EventHandler<MouseEvent>
56
+ onMouseout?: EventHandler<MouseEvent>
57
+ onTouchstart?: EventHandler<TouchEvent>
58
+ }
59
+
51
60
  interface StyledProps {
52
61
  class?: LinkHTMLAttributes['class']
53
62
  style?: LinkHTMLAttributes['style']
@@ -63,6 +72,9 @@ type PropsOfComponent<TComp> =
63
72
  ? P
64
73
  : Record<string, unknown>
65
74
 
75
+ type AnyLinkPropsOptions = UseLinkPropsOptions<any, any, any, any, any>
76
+ type LinkEventOptions = AnyLinkPropsOptions & Partial<VueStyleLinkEventHandlers>
77
+
66
78
  export function useLinkProps<
67
79
  TRouter extends AnyRouter = RegisteredRouter,
68
80
  TFrom extends RoutePaths<TRouter['routeTree']> | string = string,
@@ -92,172 +104,8 @@ export function useLinkProps<
92
104
  }
93
105
  })
94
106
 
95
- const buildLocationKey = useRouterState({
96
- select: (s) => {
97
- const leaf = s.matches[s.matches.length - 1]
98
- return {
99
- search: leaf?.search,
100
- hash: s.location.hash,
101
- path: leaf?.pathname, // path + params
102
- }
103
- },
104
- })
105
-
106
- // when `from` is not supplied, use the leaf route of the current matches as the `from` location
107
- const from = useMatches({
108
- select: (matches) => options.from ?? matches[matches.length - 1]?.fullPath,
109
- })
110
-
111
- const _options = Vue.computed(() => ({
112
- ...options,
113
- from: from.value,
114
- }))
115
-
116
- const next = Vue.computed(() => {
117
- // Depend on search to rebuild when search changes
118
- buildLocationKey.value
119
- return router.buildLocation(_options.value as any)
120
- })
121
-
122
- const preload = Vue.computed(() => {
123
- if (_options.value.reloadDocument) {
124
- return false
125
- }
126
- return options.preload ?? router.options.defaultPreload
127
- })
128
-
129
- const preloadDelay = Vue.computed(
130
- () => options.preloadDelay ?? router.options.defaultPreloadDelay ?? 0,
131
- )
132
-
133
- const isActive = useRouterState({
134
- select: (s) => {
135
- const activeOptions = options.activeOptions
136
- if (activeOptions?.exact) {
137
- const testExact = exactPathTest(
138
- s.location.pathname,
139
- next.value.pathname,
140
- router.basepath,
141
- )
142
- if (!testExact) {
143
- return false
144
- }
145
- } else {
146
- const currentPathSplit = removeTrailingSlash(
147
- s.location.pathname,
148
- router.basepath,
149
- ).split('/')
150
- const nextPathSplit = removeTrailingSlash(
151
- next.value?.pathname,
152
- router.basepath,
153
- )?.split('/')
154
-
155
- const pathIsFuzzyEqual = nextPathSplit?.every(
156
- (d, i) => d === currentPathSplit[i],
157
- )
158
- if (!pathIsFuzzyEqual) {
159
- return false
160
- }
161
- }
162
-
163
- if (activeOptions?.includeSearch ?? true) {
164
- const searchTest = deepEqual(s.location.search, next.value.search, {
165
- partial: !activeOptions?.exact,
166
- ignoreUndefined: !activeOptions?.explicitUndefined,
167
- })
168
- if (!searchTest) {
169
- return false
170
- }
171
- }
172
-
173
- if (activeOptions?.includeHash) {
174
- return s.location.hash === next.value.hash
175
- }
176
- return true
177
- },
178
- })
179
-
180
- const doPreload = () =>
181
- router.preloadRoute(_options.value as any).catch((err: any) => {
182
- console.warn(err)
183
- console.warn(preloadWarning)
184
- })
185
-
186
- const preloadViewportIoCallback = (
187
- entry: IntersectionObserverEntry | undefined,
188
- ) => {
189
- if (entry?.isIntersecting) {
190
- doPreload()
191
- }
192
- }
193
-
194
107
  const ref = Vue.ref<Element | null>(null)
195
-
196
- useIntersectionObserver(
197
- ref,
198
- preloadViewportIoCallback,
199
- { rootMargin: '100px' },
200
- { disabled: () => !!options.disabled || !(preload.value === 'viewport') },
201
- )
202
-
203
- Vue.effect(() => {
204
- if (hasRenderFetched) {
205
- return
206
- }
207
- if (!options.disabled && preload.value === 'render') {
208
- doPreload()
209
- hasRenderFetched = true
210
- }
211
- })
212
-
213
- // Create safe props that can be spread
214
- const getPropsSafeToSpread = () => {
215
- const result: Record<string, any> = {}
216
- const optionRecord = options as unknown as Record<string, unknown>
217
- for (const key in options) {
218
- if (
219
- ![
220
- 'activeProps',
221
- 'inactiveProps',
222
- 'activeOptions',
223
- 'to',
224
- 'preload',
225
- 'preloadDelay',
226
- 'hashScrollIntoView',
227
- 'replace',
228
- 'startTransition',
229
- 'resetScroll',
230
- 'viewTransition',
231
- 'children',
232
- 'target',
233
- 'disabled',
234
- 'style',
235
- 'class',
236
- 'onClick',
237
- 'onBlur',
238
- 'onFocus',
239
- 'onMouseEnter',
240
- 'onMouseLeave',
241
- 'onMouseOver',
242
- 'onMouseOut',
243
- 'onTouchStart',
244
- 'ignoreBlocker',
245
- 'params',
246
- 'search',
247
- 'hash',
248
- 'state',
249
- 'mask',
250
- 'reloadDocument',
251
- '_asChild',
252
- 'from',
253
- 'additionalProps',
254
- ].includes(key)
255
- ) {
256
- result[key] = optionRecord[key]
257
- }
258
- }
259
- return result
260
- }
108
+ const eventHandlers = getLinkEventHandlers(options as LinkEventOptions)
261
109
 
262
110
  if (type.value === 'external') {
263
111
  // Block dangerous protocols like javascript:, blob:, data:
@@ -267,7 +115,7 @@ export function useLinkProps<
267
115
  }
268
116
  // Return props without href to prevent navigation
269
117
  const safeProps: Record<string, unknown> = {
270
- ...getPropsSafeToSpread(),
118
+ ...getPropsSafeToSpread(options as AnyLinkPropsOptions),
271
119
  ref,
272
120
  // No href attribute - blocks the dangerous protocol
273
121
  target: options.target,
@@ -277,11 +125,11 @@ export function useLinkProps<
277
125
  onClick: options.onClick,
278
126
  onBlur: options.onBlur,
279
127
  onFocus: options.onFocus,
280
- onMouseEnter: options.onMouseEnter,
281
- onMouseLeave: options.onMouseLeave,
282
- onMouseOver: options.onMouseOver,
283
- onMouseOut: options.onMouseOut,
284
- onTouchStart: options.onTouchStart,
128
+ onMouseenter: eventHandlers.onMouseenter,
129
+ onMouseleave: eventHandlers.onMouseleave,
130
+ onMouseover: eventHandlers.onMouseover,
131
+ onMouseout: eventHandlers.onMouseout,
132
+ onTouchstart: eventHandlers.onTouchstart,
285
133
  }
286
134
 
287
135
  // Remove undefined values
@@ -298,7 +146,7 @@ export function useLinkProps<
298
146
 
299
147
  // External links just have simple props
300
148
  const externalProps: Record<string, unknown> = {
301
- ...getPropsSafeToSpread(),
149
+ ...getPropsSafeToSpread(options as AnyLinkPropsOptions),
302
150
  ref,
303
151
  href: options.to,
304
152
  target: options.target,
@@ -308,11 +156,11 @@ export function useLinkProps<
308
156
  onClick: options.onClick,
309
157
  onBlur: options.onBlur,
310
158
  onFocus: options.onFocus,
311
- onMouseEnter: options.onMouseEnter,
312
- onMouseLeave: options.onMouseLeave,
313
- onMouseOver: options.onMouseOver,
314
- onMouseOut: options.onMouseOut,
315
- onTouchStart: options.onTouchStart,
159
+ onMouseenter: eventHandlers.onMouseenter,
160
+ onMouseleave: eventHandlers.onMouseleave,
161
+ onMouseover: eventHandlers.onMouseover,
162
+ onMouseout: eventHandlers.onMouseout,
163
+ onTouchstart: eventHandlers.onTouchstart,
316
164
  }
317
165
 
318
166
  // Remove undefined values
@@ -327,6 +175,113 @@ export function useLinkProps<
327
175
  ) as unknown as LinkHTMLAttributes
328
176
  }
329
177
 
178
+ // During SSR we render exactly once and do not need reactivity.
179
+ // Avoid store subscriptions, effects and observers on the server.
180
+ if (isServer ?? router.isServer) {
181
+ const next = router.buildLocation(options as any)
182
+ const href = getHref({
183
+ options: options as AnyLinkPropsOptions,
184
+ router,
185
+ nextLocation: next,
186
+ })
187
+
188
+ const isActive = getIsActive({
189
+ loc: router.stores.location.state,
190
+ nextLoc: next,
191
+ activeOptions: options.activeOptions,
192
+ router,
193
+ })
194
+
195
+ const {
196
+ resolvedActiveProps,
197
+ resolvedInactiveProps,
198
+ resolvedClassName,
199
+ resolvedStyle,
200
+ } = resolveStyleProps({
201
+ options: options as AnyLinkPropsOptions,
202
+ isActive,
203
+ })
204
+
205
+ const result = combineResultProps({
206
+ href,
207
+ options: options as AnyLinkPropsOptions,
208
+ isActive,
209
+ isTransitioning: false,
210
+ resolvedActiveProps,
211
+ resolvedInactiveProps,
212
+ resolvedClassName,
213
+ resolvedStyle,
214
+ })
215
+
216
+ return Vue.ref(
217
+ result as LinkHTMLAttributes,
218
+ ) as unknown as LinkHTMLAttributes
219
+ }
220
+
221
+ const currentLocation = useStore(router.stores.location, (l) => l, {
222
+ equal: (prev, next) => prev.href === next.href,
223
+ })
224
+
225
+ const next = Vue.computed(() => {
226
+ // Rebuild when inherited search/hash or the current route context changes.
227
+
228
+ const opts = { _fromLocation: currentLocation.value, ...options }
229
+ return router.buildLocation(opts as any)
230
+ })
231
+
232
+ const preload = Vue.computed(() => {
233
+ if (options.reloadDocument) {
234
+ return false
235
+ }
236
+ return options.preload ?? router.options.defaultPreload
237
+ })
238
+
239
+ const preloadDelay = Vue.computed(
240
+ () => options.preloadDelay ?? router.options.defaultPreloadDelay ?? 0,
241
+ )
242
+
243
+ const isActive = Vue.computed(() =>
244
+ getIsActive({
245
+ activeOptions: options.activeOptions,
246
+ loc: currentLocation.value,
247
+ nextLoc: next.value,
248
+ router,
249
+ }),
250
+ )
251
+
252
+ const doPreload = () =>
253
+ router
254
+ .preloadRoute({ ...options, _builtLocation: next.value } as any)
255
+ .catch((err: any) => {
256
+ console.warn(err)
257
+ console.warn(preloadWarning)
258
+ })
259
+
260
+ const preloadViewportIoCallback = (
261
+ entry: IntersectionObserverEntry | undefined,
262
+ ) => {
263
+ if (entry?.isIntersecting) {
264
+ doPreload()
265
+ }
266
+ }
267
+
268
+ useIntersectionObserver(
269
+ ref,
270
+ preloadViewportIoCallback,
271
+ { rootMargin: '100px' },
272
+ { disabled: () => !!options.disabled || !(preload.value === 'viewport') },
273
+ )
274
+
275
+ Vue.effect(() => {
276
+ if (hasRenderFetched) {
277
+ return
278
+ }
279
+ if (!options.disabled && preload.value === 'render') {
280
+ doPreload()
281
+ hasRenderFetched = true
282
+ }
283
+ })
284
+
330
285
  // The click handler
331
286
  const handleClick = (e: PointerEvent): void => {
332
287
  // Check actual element's target attribute as fallback
@@ -344,7 +299,7 @@ export function useLinkProps<
344
299
  e.button === 0
345
300
  ) {
346
301
  // Don't prevent default or handle navigation if reloadDocument is true
347
- if (_options.value.reloadDocument) {
302
+ if (options.reloadDocument) {
348
303
  return
349
304
  }
350
305
 
@@ -359,7 +314,7 @@ export function useLinkProps<
359
314
 
360
315
  // All is well? Navigate!
361
316
  router.navigate({
362
- ..._options.value,
317
+ ...options,
363
318
  replace: options.replace,
364
319
  resetScroll: options.resetScroll,
365
320
  hashScrollIntoView: options.hashScrollIntoView,
@@ -421,75 +376,20 @@ export function useLinkProps<
421
376
  }
422
377
 
423
378
  // Get the active and inactive props
424
- const resolvedActiveProps = Vue.computed<StyledProps>(() => {
425
- const activeProps = options.activeProps || (() => ({ class: 'active' }))
426
- const props = isActive.value
427
- ? typeof activeProps === 'function'
428
- ? activeProps()
429
- : activeProps
430
- : {}
431
-
432
- return props || { class: undefined, style: undefined }
433
- })
434
-
435
- const resolvedInactiveProps = Vue.computed<StyledProps>(() => {
436
- const inactiveProps = options.inactiveProps || (() => ({}))
437
- const props = isActive.value
438
- ? {}
439
- : typeof inactiveProps === 'function'
440
- ? inactiveProps()
441
- : inactiveProps
442
-
443
- return props || { class: undefined, style: undefined }
444
- })
445
-
446
- const resolvedClassName = Vue.computed(() => {
447
- const classes = [
448
- options.class,
449
- resolvedActiveProps.value?.class,
450
- resolvedInactiveProps.value?.class,
451
- ].filter(Boolean)
452
- return classes.length ? classes.join(' ') : undefined
453
- })
454
-
455
- const resolvedStyle = Vue.computed(() => {
456
- const result: Record<string, string | number> = {}
457
-
458
- // Merge styles from all sources
459
- if (options.style) {
460
- Object.assign(result, options.style)
461
- }
462
-
463
- if (resolvedActiveProps.value?.style) {
464
- Object.assign(result, resolvedActiveProps.value.style)
465
- }
466
-
467
- if (resolvedInactiveProps.value?.style) {
468
- Object.assign(result, resolvedInactiveProps.value.style)
469
- }
470
-
471
- return Object.keys(result).length > 0 ? result : undefined
472
- })
473
-
474
- const href = Vue.computed(() => {
475
- if (options.disabled) {
476
- return undefined
477
- }
478
- const nextLocation = next.value
479
- const location = nextLocation?.maskedLocation ?? nextLocation
480
-
481
- // Use publicHref - it contains the correct href for display
482
- // When a rewrite changes the origin, publicHref is the full URL
483
- // Otherwise it's the origin-stripped path
484
- // This avoids constructing URL objects in the hot path
485
- const publicHref = location?.publicHref
486
- if (!publicHref) return undefined
487
-
488
- const external = location?.external
489
- if (external) return publicHref
379
+ const resolvedStyleProps = Vue.computed(() =>
380
+ resolveStyleProps({
381
+ options: options as AnyLinkPropsOptions,
382
+ isActive: isActive.value,
383
+ }),
384
+ )
490
385
 
491
- return router.history.createHref(publicHref) || '/'
492
- })
386
+ const href = Vue.computed(() =>
387
+ getHref({
388
+ options: options as AnyLinkPropsOptions,
389
+ router,
390
+ nextLocation: next.value,
391
+ }),
392
+ )
493
393
 
494
394
  // Create static event handlers that don't change between renders
495
395
  const staticEventHandlers = {
@@ -506,23 +406,23 @@ export function useLinkProps<
506
406
  enqueueIntentPreload,
507
407
  ]) as any,
508
408
  onMouseenter: composeEventHandlers<MouseEvent>([
509
- options.onMouseEnter,
409
+ eventHandlers.onMouseenter,
510
410
  enqueueIntentPreload,
511
411
  ]) as any,
512
412
  onMouseover: composeEventHandlers<MouseEvent>([
513
- options.onMouseOver,
413
+ eventHandlers.onMouseover,
514
414
  enqueueIntentPreload,
515
415
  ]) as any,
516
416
  onMouseleave: composeEventHandlers<MouseEvent>([
517
- options.onMouseLeave,
417
+ eventHandlers.onMouseleave,
518
418
  handleLeave,
519
419
  ]) as any,
520
420
  onMouseout: composeEventHandlers<MouseEvent>([
521
- options.onMouseOut,
421
+ eventHandlers.onMouseout,
522
422
  handleLeave,
523
423
  ]) as any,
524
424
  onTouchstart: composeEventHandlers<TouchEvent>([
525
- options.onTouchStart,
425
+ eventHandlers.onTouchstart,
526
426
  handleTouchStart,
527
427
  ]) as any,
528
428
  }
@@ -530,62 +430,309 @@ export function useLinkProps<
530
430
  // Compute all props synchronously to avoid hydration mismatches
531
431
  // Using Vue.computed ensures props are calculated at render time, not after
532
432
  const computedProps = Vue.computed<LinkHTMLAttributes>(() => {
533
- const result: Record<string, unknown> = {
534
- ...getPropsSafeToSpread(),
433
+ const {
434
+ resolvedActiveProps,
435
+ resolvedInactiveProps,
436
+ resolvedClassName,
437
+ resolvedStyle,
438
+ } = resolvedStyleProps.value
439
+ return combineResultProps({
535
440
  href: href.value,
441
+ options: options as AnyLinkPropsOptions,
536
442
  ref,
537
- ...staticEventHandlers,
538
- disabled: !!options.disabled,
539
- target: options.target,
540
- }
443
+ staticEventHandlers,
444
+ isActive: isActive.value,
445
+ isTransitioning: isTransitioning.value,
446
+ resolvedActiveProps,
447
+ resolvedInactiveProps,
448
+ resolvedClassName,
449
+ resolvedStyle,
450
+ })
451
+ })
541
452
 
542
- // Add style if present
543
- if (resolvedStyle.value) {
544
- result.style = resolvedStyle.value
545
- }
453
+ // Return the computed ref itself - callers should access .value
454
+ return computedProps as unknown as LinkHTMLAttributes
455
+ }
546
456
 
547
- // Add class if present
548
- if (resolvedClassName.value) {
549
- result.class = resolvedClassName.value
550
- }
457
+ function resolveStyleProps({
458
+ options,
459
+ isActive,
460
+ }: {
461
+ options: AnyLinkPropsOptions
462
+ isActive: boolean
463
+ }) {
464
+ const activeProps = options.activeProps || (() => ({ class: 'active' }))
465
+ const resolvedActiveProps: StyledProps = (isActive
466
+ ? typeof activeProps === 'function'
467
+ ? activeProps()
468
+ : activeProps
469
+ : {}) || { class: undefined, style: undefined }
470
+
471
+ const inactiveProps = options.inactiveProps || (() => ({}))
472
+
473
+ const resolvedInactiveProps: StyledProps = (isActive
474
+ ? {}
475
+ : typeof inactiveProps === 'function'
476
+ ? inactiveProps()
477
+ : inactiveProps) || { class: undefined, style: undefined }
478
+
479
+ const classes = [
480
+ options.class,
481
+ resolvedActiveProps?.class,
482
+ resolvedInactiveProps?.class,
483
+ ].filter(Boolean)
484
+ const resolvedClassName = classes.length ? classes.join(' ') : undefined
485
+
486
+ const result: Record<string, string | number> = {}
487
+
488
+ // Merge styles from all sources
489
+ if (options.style) {
490
+ Object.assign(result, options.style)
491
+ }
492
+
493
+ if (resolvedActiveProps?.style) {
494
+ Object.assign(result, resolvedActiveProps.style)
495
+ }
496
+
497
+ if (resolvedInactiveProps?.style) {
498
+ Object.assign(result, resolvedInactiveProps.style)
499
+ }
500
+
501
+ const resolvedStyle = Object.keys(result).length > 0 ? result : undefined
502
+ return {
503
+ resolvedActiveProps,
504
+ resolvedInactiveProps,
505
+ resolvedClassName,
506
+ resolvedStyle,
507
+ }
508
+ }
509
+
510
+ function combineResultProps({
511
+ href,
512
+ options,
513
+ isActive,
514
+ isTransitioning,
515
+ resolvedActiveProps,
516
+ resolvedInactiveProps,
517
+ resolvedClassName,
518
+ resolvedStyle,
519
+ ref,
520
+ staticEventHandlers,
521
+ }: {
522
+ initial?: LinkHTMLAttributes
523
+ href: string | undefined
524
+ options: AnyLinkPropsOptions
525
+ isActive: boolean
526
+ isTransitioning: boolean
527
+ resolvedActiveProps: StyledProps
528
+ resolvedInactiveProps: StyledProps
529
+ resolvedClassName?: string
530
+ resolvedStyle?: Record<string, string | number>
531
+ ref?: Vue.VNodeRef | undefined
532
+ staticEventHandlers?: {
533
+ onClick: any
534
+ onBlur: any
535
+ onFocus: any
536
+ onMouseenter: any
537
+ onMouseover: any
538
+ onMouseleave: any
539
+ onMouseout: any
540
+ onTouchstart: any
541
+ }
542
+ }) {
543
+ const result: Record<string, unknown> = {
544
+ ...getPropsSafeToSpread(options),
545
+ ref,
546
+ ...staticEventHandlers,
547
+ href,
548
+ disabled: !!options.disabled,
549
+ target: options.target,
550
+ }
551
+
552
+ if (resolvedStyle) {
553
+ result.style = resolvedStyle
554
+ }
555
+
556
+ if (resolvedClassName) {
557
+ result.class = resolvedClassName
558
+ }
559
+
560
+ if (options.disabled) {
561
+ result.role = 'link'
562
+ result['aria-disabled'] = true
563
+ }
564
+
565
+ if (isActive) {
566
+ result['data-status'] = 'active'
567
+ result['aria-current'] = 'page'
568
+ }
551
569
 
552
- // Add disabled props
553
- if (options.disabled) {
554
- result.role = 'link'
555
- result['aria-disabled'] = true
570
+ if (isTransitioning) {
571
+ result['data-transitioning'] = 'transitioning'
572
+ }
573
+
574
+ for (const key of Object.keys(resolvedActiveProps)) {
575
+ if (key !== 'class' && key !== 'style') {
576
+ result[key] = resolvedActiveProps[key]
556
577
  }
578
+ }
557
579
 
558
- // Add active status
559
- if (isActive.value) {
560
- result['data-status'] = 'active'
561
- result['aria-current'] = 'page'
580
+ for (const key of Object.keys(resolvedInactiveProps)) {
581
+ if (key !== 'class' && key !== 'style') {
582
+ result[key] = resolvedInactiveProps[key]
562
583
  }
584
+ }
585
+ return result
586
+ }
587
+
588
+ function getLinkEventHandlers(
589
+ options: LinkEventOptions,
590
+ ): VueStyleLinkEventHandlers {
591
+ return {
592
+ onMouseenter: options.onMouseEnter ?? options.onMouseenter,
593
+ onMouseleave: options.onMouseLeave ?? options.onMouseleave,
594
+ onMouseover: options.onMouseOver ?? options.onMouseover,
595
+ onMouseout: options.onMouseOut ?? options.onMouseout,
596
+ onTouchstart: options.onTouchStart ?? options.onTouchstart,
597
+ }
598
+ }
563
599
 
564
- // Add transitioning status
565
- if (isTransitioning.value) {
566
- result['data-transitioning'] = 'transitioning'
600
+ const propsUnsafeToSpread = new Set([
601
+ 'activeProps',
602
+ 'inactiveProps',
603
+ 'activeOptions',
604
+ 'to',
605
+ 'preload',
606
+ 'preloadDelay',
607
+ 'hashScrollIntoView',
608
+ 'replace',
609
+ 'startTransition',
610
+ 'resetScroll',
611
+ 'viewTransition',
612
+ 'children',
613
+ 'target',
614
+ 'disabled',
615
+ 'style',
616
+ 'class',
617
+ 'onClick',
618
+ 'onBlur',
619
+ 'onFocus',
620
+ 'onMouseEnter',
621
+ 'onMouseenter',
622
+ 'onMouseLeave',
623
+ 'onMouseleave',
624
+ 'onMouseOver',
625
+ 'onMouseover',
626
+ 'onMouseOut',
627
+ 'onMouseout',
628
+ 'onTouchStart',
629
+ 'onTouchstart',
630
+ 'ignoreBlocker',
631
+ 'params',
632
+ 'search',
633
+ 'hash',
634
+ 'state',
635
+ 'mask',
636
+ 'reloadDocument',
637
+ '_asChild',
638
+ 'from',
639
+ 'additionalProps',
640
+ ])
641
+
642
+ // Create safe props that can be spread
643
+ const getPropsSafeToSpread = (options: AnyLinkPropsOptions) => {
644
+ const result: Record<string, unknown> = {}
645
+ for (const key in options) {
646
+ if (!propsUnsafeToSpread.has(key)) {
647
+ result[key] = (options as Record<string, unknown>)[key]
567
648
  }
649
+ }
568
650
 
569
- // Merge active/inactive props (excluding class and style which are handled above)
570
- const activeP = resolvedActiveProps.value
571
- const inactiveP = resolvedInactiveProps.value
651
+ return result
652
+ }
572
653
 
573
- for (const key of Object.keys(activeP)) {
574
- if (key !== 'class' && key !== 'style') {
575
- result[key] = (activeP as any)[key]
576
- }
654
+ function getIsActive({
655
+ activeOptions,
656
+ loc,
657
+ nextLoc,
658
+ router,
659
+ }: {
660
+ activeOptions: LinkOptions['activeOptions']
661
+ loc: {
662
+ pathname: string
663
+ search: any
664
+ hash: string
665
+ }
666
+ nextLoc: {
667
+ pathname: string
668
+ search: any
669
+ hash: string
670
+ }
671
+ router: AnyRouter
672
+ }) {
673
+ if (activeOptions?.exact) {
674
+ const testExact = exactPathTest(
675
+ loc.pathname,
676
+ nextLoc.pathname,
677
+ router.basepath,
678
+ )
679
+ if (!testExact) {
680
+ return false
577
681
  }
578
- for (const key of Object.keys(inactiveP)) {
579
- if (key !== 'class' && key !== 'style') {
580
- result[key] = (inactiveP as any)[key]
581
- }
682
+ } else {
683
+ const currentPath = removeTrailingSlash(loc.pathname, router.basepath)
684
+ const nextPath = removeTrailingSlash(nextLoc.pathname, router.basepath)
685
+
686
+ const pathIsFuzzyEqual =
687
+ currentPath.startsWith(nextPath) &&
688
+ (currentPath.length === nextPath.length ||
689
+ currentPath[nextPath.length] === '/')
690
+ if (!pathIsFuzzyEqual) {
691
+ return false
582
692
  }
693
+ }
583
694
 
584
- return result
585
- })
695
+ if (activeOptions?.includeSearch ?? true) {
696
+ const searchTest = deepEqual(loc.search, nextLoc.search, {
697
+ partial: !activeOptions?.exact,
698
+ ignoreUndefined: !activeOptions?.explicitUndefined,
699
+ })
700
+ if (!searchTest) {
701
+ return false
702
+ }
703
+ }
586
704
 
587
- // Return the computed ref itself - callers should access .value
588
- return computedProps as unknown as LinkHTMLAttributes
705
+ if (activeOptions?.includeHash) {
706
+ return loc.hash === nextLoc.hash
707
+ }
708
+ return true
709
+ }
710
+
711
+ function getHref({
712
+ options,
713
+ router,
714
+ nextLocation,
715
+ }: {
716
+ options: AnyLinkPropsOptions
717
+ router: AnyRouter
718
+ nextLocation?: ParsedLocation
719
+ }) {
720
+ if (options.disabled) {
721
+ return undefined
722
+ }
723
+ const location = nextLocation?.maskedLocation ?? nextLocation
724
+
725
+ // Use publicHref - it contains the correct href for display
726
+ // When a rewrite changes the origin, publicHref is the full URL
727
+ // Otherwise it's the origin-stripped path
728
+ // This avoids constructing URL objects in the hot path
729
+ const publicHref = location?.publicHref
730
+ if (!publicHref) return undefined
731
+
732
+ const external = location?.external
733
+ if (external) return publicHref
734
+
735
+ return router.history.createHref(publicHref) || '/'
589
736
  }
590
737
 
591
738
  // Type definitions
@@ -747,17 +894,15 @@ const LinkImpl = Vue.defineComponent({
747
894
  ],
748
895
  setup(props, { attrs, slots }) {
749
896
  // Call useLinkProps ONCE during setup with combined props and attrs
750
- // The returned object is a computed ref that updates reactively
751
897
  const allProps = { ...props, ...attrs }
752
- const linkPropsComputed = useLinkProps(
753
- allProps as any,
754
- ) as unknown as Vue.ComputedRef<LinkHTMLAttributes>
898
+ const linkPropsSource = useLinkProps(allProps as any) as
899
+ | LinkHTMLAttributes
900
+ | Vue.ComputedRef<LinkHTMLAttributes>
755
901
 
756
902
  return () => {
757
903
  const Component = props._asChild || 'a'
758
904
 
759
- // Access the computed value to get fresh props each render
760
- const linkProps = linkPropsComputed.value
905
+ const linkProps = Vue.unref(linkPropsSource)
761
906
 
762
907
  const isActive = linkProps['data-status'] === 'active'
763
908
  const isTransitioning =