@tanstack/react-router 1.157.0 → 1.157.2

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/link.tsx CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  exactPathTest,
6
6
  functionalUpdate,
7
7
  isDangerousProtocol,
8
+ isServer,
8
9
  preloadWarning,
9
10
  removeTrailingSlash,
10
11
  } from '@tanstack/router-core'
@@ -50,10 +51,10 @@ export function useLinkProps<
50
51
  forwardedRef?: React.ForwardedRef<Element>,
51
52
  ): React.ComponentPropsWithRef<'a'> {
52
53
  const router = useRouter()
53
- const [isTransitioning, setIsTransitioning] = React.useState(false)
54
- const hasRenderFetched = React.useRef(false)
55
54
  const innerRef = useForwardedRef(forwardedRef)
56
- const isHydrated = useHydrated()
55
+
56
+ // Determine if we're on the server - used for tree-shaking client-only code
57
+ const _isServer = isServer ?? router.isServer
57
58
 
58
59
  const {
59
60
  // custom props
@@ -93,7 +94,290 @@ export function useLinkProps<
93
94
  ...propsSafeToSpread
94
95
  } = options
95
96
 
97
+ // ==========================================================================
98
+ // SERVER EARLY RETURN
99
+ // On the server, we return static props without any event handlers,
100
+ // effects, or client-side interactivity.
101
+ //
102
+ // For SSR parity (to avoid hydration errors), we still compute the link's
103
+ // active status on the server, but we avoid creating any router-state
104
+ // subscriptions by reading from `router.state` directly.
105
+ //
106
+ // Note: `location.hash` is not available on the server.
107
+ // ==========================================================================
108
+ if (_isServer) {
109
+ const safeInternal = isSafeInternal(to)
110
+
111
+ // If `to` is obviously an absolute URL, treat as external and avoid
112
+ // computing the internal location via `buildLocation`.
113
+ if (
114
+ typeof to === 'string' &&
115
+ !safeInternal &&
116
+ // Quick checks to avoid `new URL` in common internal-like cases
117
+ to.indexOf(':') > -1
118
+ ) {
119
+ try {
120
+ new URL(to)
121
+ if (isDangerousProtocol(to)) {
122
+ if (process.env.NODE_ENV !== 'production') {
123
+ console.warn(`Blocked Link with dangerous protocol: ${to}`)
124
+ }
125
+ return {
126
+ ...propsSafeToSpread,
127
+ ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'],
128
+ href: undefined,
129
+ ...(children && { children }),
130
+ ...(target && { target }),
131
+ ...(disabled && { disabled }),
132
+ ...(style && { style }),
133
+ ...(className && { className }),
134
+ }
135
+ }
136
+
137
+ return {
138
+ ...propsSafeToSpread,
139
+ ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'],
140
+ href: to,
141
+ ...(children && { children }),
142
+ ...(target && { target }),
143
+ ...(disabled && { disabled }),
144
+ ...(style && { style }),
145
+ ...(className && { className }),
146
+ }
147
+ } catch {
148
+ // Not an absolute URL
149
+ }
150
+ }
151
+
152
+ const next = router.buildLocation({ ...options, from: options.from } as any)
153
+
154
+ // Use publicHref - it contains the correct href for display
155
+ // When a rewrite changes the origin, publicHref is the full URL
156
+ // Otherwise it's the origin-stripped path
157
+ // This avoids constructing URL objects in the hot path
158
+ const hrefOptionPublicHref = next.maskedLocation
159
+ ? next.maskedLocation.publicHref
160
+ : next.publicHref
161
+ const hrefOptionExternal = next.maskedLocation
162
+ ? next.maskedLocation.external
163
+ : next.external
164
+ const hrefOption = getHrefOption(
165
+ hrefOptionPublicHref,
166
+ hrefOptionExternal,
167
+ router.history,
168
+ disabled,
169
+ )
170
+
171
+ const externalLink = (() => {
172
+ if (hrefOption?.external) {
173
+ if (isDangerousProtocol(hrefOption.href)) {
174
+ if (process.env.NODE_ENV !== 'production') {
175
+ console.warn(
176
+ `Blocked Link with dangerous protocol: ${hrefOption.href}`,
177
+ )
178
+ }
179
+ return undefined
180
+ }
181
+ return hrefOption.href
182
+ }
183
+
184
+ if (safeInternal) return undefined
185
+
186
+ // Only attempt URL parsing when it looks like an absolute URL.
187
+ if (typeof to === 'string' && to.indexOf(':') > -1) {
188
+ try {
189
+ new URL(to)
190
+ if (isDangerousProtocol(to)) {
191
+ if (process.env.NODE_ENV !== 'production') {
192
+ console.warn(`Blocked Link with dangerous protocol: ${to}`)
193
+ }
194
+ return undefined
195
+ }
196
+ return to
197
+ } catch {}
198
+ }
199
+
200
+ return undefined
201
+ })()
202
+
203
+ const isActive = (() => {
204
+ if (externalLink) return false
205
+
206
+ const currentLocation = router.state.location
207
+
208
+ const exact = activeOptions?.exact ?? false
209
+
210
+ if (exact) {
211
+ const testExact = exactPathTest(
212
+ currentLocation.pathname,
213
+ next.pathname,
214
+ router.basepath,
215
+ )
216
+ if (!testExact) {
217
+ return false
218
+ }
219
+ } else {
220
+ const currentPathSplit = removeTrailingSlash(
221
+ currentLocation.pathname,
222
+ router.basepath,
223
+ )
224
+ const nextPathSplit = removeTrailingSlash(
225
+ next.pathname,
226
+ router.basepath,
227
+ )
228
+
229
+ const pathIsFuzzyEqual =
230
+ currentPathSplit.startsWith(nextPathSplit) &&
231
+ (currentPathSplit.length === nextPathSplit.length ||
232
+ currentPathSplit[nextPathSplit.length] === '/')
233
+
234
+ if (!pathIsFuzzyEqual) {
235
+ return false
236
+ }
237
+ }
238
+
239
+ const includeSearch = activeOptions?.includeSearch ?? true
240
+ if (includeSearch) {
241
+ if (currentLocation.search !== next.search) {
242
+ const currentSearchEmpty =
243
+ !currentLocation.search ||
244
+ (typeof currentLocation.search === 'object' &&
245
+ Object.keys(currentLocation.search).length === 0)
246
+ const nextSearchEmpty =
247
+ !next.search ||
248
+ (typeof next.search === 'object' &&
249
+ Object.keys(next.search).length === 0)
250
+
251
+ if (!(currentSearchEmpty && nextSearchEmpty)) {
252
+ const searchTest = deepEqual(currentLocation.search, next.search, {
253
+ partial: !exact,
254
+ ignoreUndefined: !activeOptions?.explicitUndefined,
255
+ })
256
+ if (!searchTest) {
257
+ return false
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ // Hash is not available on the server
264
+ if (activeOptions?.includeHash) {
265
+ return false
266
+ }
267
+
268
+ return true
269
+ })()
270
+
271
+ if (externalLink) {
272
+ return {
273
+ ...propsSafeToSpread,
274
+ ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'],
275
+ href: externalLink,
276
+ ...(children && { children }),
277
+ ...(target && { target }),
278
+ ...(disabled && { disabled }),
279
+ ...(style && { style }),
280
+ ...(className && { className }),
281
+ }
282
+ }
283
+
284
+ const resolvedActiveProps: React.HTMLAttributes<HTMLAnchorElement> =
285
+ isActive
286
+ ? (functionalUpdate(activeProps as any, {}) ?? STATIC_ACTIVE_OBJECT)
287
+ : STATIC_EMPTY_OBJECT
288
+
289
+ const resolvedInactiveProps: React.HTMLAttributes<HTMLAnchorElement> =
290
+ isActive
291
+ ? STATIC_EMPTY_OBJECT
292
+ : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT)
293
+
294
+ const resolvedStyle = (() => {
295
+ const baseStyle = style
296
+ const activeStyle = resolvedActiveProps.style
297
+ const inactiveStyle = resolvedInactiveProps.style
298
+
299
+ if (!baseStyle && !activeStyle && !inactiveStyle) {
300
+ return undefined
301
+ }
302
+
303
+ if (baseStyle && !activeStyle && !inactiveStyle) {
304
+ return baseStyle
305
+ }
306
+
307
+ if (!baseStyle && activeStyle && !inactiveStyle) {
308
+ return activeStyle
309
+ }
310
+
311
+ if (!baseStyle && !activeStyle && inactiveStyle) {
312
+ return inactiveStyle
313
+ }
314
+
315
+ return {
316
+ ...baseStyle,
317
+ ...activeStyle,
318
+ ...inactiveStyle,
319
+ }
320
+ })()
321
+
322
+ const resolvedClassName = (() => {
323
+ const baseClassName = className
324
+ const activeClassName = resolvedActiveProps.className
325
+ const inactiveClassName = resolvedInactiveProps.className
326
+
327
+ if (!baseClassName && !activeClassName && !inactiveClassName) {
328
+ return ''
329
+ }
330
+
331
+ let out = ''
332
+
333
+ if (baseClassName) {
334
+ out = baseClassName
335
+ }
336
+
337
+ if (activeClassName) {
338
+ out = out ? `${out} ${activeClassName}` : activeClassName
339
+ }
340
+
341
+ if (inactiveClassName) {
342
+ out = out ? `${out} ${inactiveClassName}` : inactiveClassName
343
+ }
344
+
345
+ return out
346
+ })()
347
+
348
+ return {
349
+ ...propsSafeToSpread,
350
+ ...resolvedActiveProps,
351
+ ...resolvedInactiveProps,
352
+ href: hrefOption?.href,
353
+ ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'],
354
+ disabled: !!disabled,
355
+ target,
356
+ ...(resolvedStyle && { style: resolvedStyle }),
357
+ ...(resolvedClassName && { className: resolvedClassName }),
358
+ ...(disabled && STATIC_DISABLED_PROPS),
359
+ ...(isActive && STATIC_ACTIVE_PROPS),
360
+ }
361
+ }
362
+
363
+ // ==========================================================================
364
+ // CLIENT-ONLY CODE
365
+ // Everything below this point only runs on the client. The `isServer` check
366
+ // above is a compile-time constant that bundlers use for dead code elimination,
367
+ // so this entire section is removed from server bundles.
368
+ //
369
+ // We disable the rules-of-hooks lint rule because these hooks appear after
370
+ // an early return. This is safe because:
371
+ // 1. `isServer` is a compile-time constant from conditional exports
372
+ // 2. In server bundles, this code is completely eliminated by the bundler
373
+ // 3. In client bundles, `isServer` is `false`, so the early return never executes
374
+ // ==========================================================================
375
+
376
+ // eslint-disable-next-line react-hooks/rules-of-hooks
377
+ const isHydrated = useHydrated()
378
+
96
379
  // subscribe to search params to re-build location if it changes
380
+ // eslint-disable-next-line react-hooks/rules-of-hooks
97
381
  const currentSearch = useRouterState({
98
382
  select: (s) => s.location.search,
99
383
  structuralSharing: true as any,
@@ -101,6 +385,7 @@ export function useLinkProps<
101
385
 
102
386
  const from = options.from
103
387
 
388
+ // eslint-disable-next-line react-hooks/rules-of-hooks
104
389
  const _options = React.useMemo(
105
390
  () => {
106
391
  return { ...options, from }
@@ -121,30 +406,35 @@ export function useLinkProps<
121
406
  ],
122
407
  )
123
408
 
409
+ // eslint-disable-next-line react-hooks/rules-of-hooks
124
410
  const next = React.useMemo(
125
411
  () => router.buildLocation({ ..._options } as any),
126
412
  [router, _options],
127
413
  )
128
414
 
129
- const hrefOption = React.useMemo(() => {
130
- if (disabled) {
131
- return undefined
132
- }
133
- let href = next.maskedLocation
134
- ? next.maskedLocation.url.href
135
- : next.url.href
136
-
137
- let external = false
138
- if (router.origin) {
139
- if (href.startsWith(router.origin)) {
140
- href = router.history.createHref(href.replace(router.origin, '')) || '/'
141
- } else {
142
- external = true
143
- }
144
- }
145
- return { href, external }
146
- }, [disabled, next.maskedLocation, next.url, router.origin, router.history])
415
+ // Use publicHref - it contains the correct href for display
416
+ // When a rewrite changes the origin, publicHref is the full URL
417
+ // Otherwise it's the origin-stripped path
418
+ // This avoids constructing URL objects in the hot path
419
+ const hrefOptionPublicHref = next.maskedLocation
420
+ ? next.maskedLocation.publicHref
421
+ : next.publicHref
422
+ const hrefOptionExternal = next.maskedLocation
423
+ ? next.maskedLocation.external
424
+ : next.external
425
+ // eslint-disable-next-line react-hooks/rules-of-hooks
426
+ const hrefOption = React.useMemo(
427
+ () =>
428
+ getHrefOption(
429
+ hrefOptionPublicHref,
430
+ hrefOptionExternal,
431
+ router.history,
432
+ disabled,
433
+ ),
434
+ [disabled, hrefOptionExternal, hrefOptionPublicHref, router.history],
435
+ )
147
436
 
437
+ // eslint-disable-next-line react-hooks/rules-of-hooks
148
438
  const externalLink = React.useMemo(() => {
149
439
  if (hrefOption?.external) {
150
440
  // Block dangerous protocols for external links
@@ -158,15 +448,13 @@ export function useLinkProps<
158
448
  }
159
449
  return hrefOption.href
160
450
  }
161
- const isSafeInternal =
162
- typeof to === 'string' &&
163
- to.charCodeAt(0) === 47 && // '/'
164
- to.charCodeAt(1) !== 47 // but not '//'
165
- if (isSafeInternal) return undefined
451
+ const safeInternal = isSafeInternal(to)
452
+ if (safeInternal) return undefined
453
+ if (typeof to !== 'string' || to.indexOf(':') === -1) return undefined
166
454
  try {
167
455
  new URL(to as any)
168
456
  // Block dangerous protocols like javascript:, data:, vbscript:
169
- if (isDangerousProtocol(to as string)) {
457
+ if (isDangerousProtocol(to)) {
170
458
  if (process.env.NODE_ENV !== 'production') {
171
459
  console.warn(`Blocked Link with dangerous protocol: ${to}`)
172
460
  }
@@ -177,13 +465,7 @@ export function useLinkProps<
177
465
  return undefined
178
466
  }, [to, hrefOption])
179
467
 
180
- const preload =
181
- options.reloadDocument || externalLink
182
- ? false
183
- : (userPreload ?? router.options.defaultPreload)
184
- const preloadDelay =
185
- userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0
186
-
468
+ // eslint-disable-next-line react-hooks/rules-of-hooks
187
469
  const isActive = useRouterState({
188
470
  select: (s) => {
189
471
  if (externalLink) return false
@@ -233,6 +515,46 @@ export function useLinkProps<
233
515
  },
234
516
  })
235
517
 
518
+ // Get the active props
519
+ const resolvedActiveProps: React.HTMLAttributes<HTMLAnchorElement> = isActive
520
+ ? (functionalUpdate(activeProps as any, {}) ?? STATIC_ACTIVE_OBJECT)
521
+ : STATIC_EMPTY_OBJECT
522
+
523
+ // Get the inactive props
524
+ const resolvedInactiveProps: React.HTMLAttributes<HTMLAnchorElement> =
525
+ isActive
526
+ ? STATIC_EMPTY_OBJECT
527
+ : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT)
528
+
529
+ const resolvedClassName = [
530
+ className,
531
+ resolvedActiveProps.className,
532
+ resolvedInactiveProps.className,
533
+ ]
534
+ .filter(Boolean)
535
+ .join(' ')
536
+
537
+ const resolvedStyle = (style ||
538
+ resolvedActiveProps.style ||
539
+ resolvedInactiveProps.style) && {
540
+ ...style,
541
+ ...resolvedActiveProps.style,
542
+ ...resolvedInactiveProps.style,
543
+ }
544
+
545
+ // eslint-disable-next-line react-hooks/rules-of-hooks
546
+ const [isTransitioning, setIsTransitioning] = React.useState(false)
547
+ // eslint-disable-next-line react-hooks/rules-of-hooks
548
+ const hasRenderFetched = React.useRef(false)
549
+
550
+ const preload =
551
+ options.reloadDocument || externalLink
552
+ ? false
553
+ : (userPreload ?? router.options.defaultPreload)
554
+ const preloadDelay =
555
+ userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0
556
+
557
+ // eslint-disable-next-line react-hooks/rules-of-hooks
236
558
  const doPreload = React.useCallback(() => {
237
559
  router.preloadRoute({ ..._options } as any).catch((err) => {
238
560
  console.warn(err)
@@ -240,6 +562,7 @@ export function useLinkProps<
240
562
  })
241
563
  }, [router, _options])
242
564
 
565
+ // eslint-disable-next-line react-hooks/rules-of-hooks
243
566
  const preloadViewportIoCallback = React.useCallback(
244
567
  (entry: IntersectionObserverEntry | undefined) => {
245
568
  if (entry?.isIntersecting) {
@@ -249,6 +572,7 @@ export function useLinkProps<
249
572
  [doPreload],
250
573
  )
251
574
 
575
+ // eslint-disable-next-line react-hooks/rules-of-hooks
252
576
  useIntersectionObserver(
253
577
  innerRef,
254
578
  preloadViewportIoCallback,
@@ -256,6 +580,7 @@ export function useLinkProps<
256
580
  { disabled: !!disabled || !(preload === 'viewport') },
257
581
  )
258
582
 
583
+ // eslint-disable-next-line react-hooks/rules-of-hooks
259
584
  React.useEffect(() => {
260
585
  if (hasRenderFetched.current) {
261
586
  return
@@ -324,7 +649,6 @@ export function useLinkProps<
324
649
  }
325
650
  }
326
651
 
327
- // The click handler
328
652
  const handleFocus = (_: React.MouseEvent) => {
329
653
  if (disabled) return
330
654
  if (preload) {
@@ -362,33 +686,6 @@ export function useLinkProps<
362
686
  }
363
687
  }
364
688
 
365
- // Get the active props
366
- const resolvedActiveProps: React.HTMLAttributes<HTMLAnchorElement> = isActive
367
- ? (functionalUpdate(activeProps as any, {}) ?? STATIC_ACTIVE_OBJECT)
368
- : STATIC_EMPTY_OBJECT
369
-
370
- // Get the inactive props
371
- const resolvedInactiveProps: React.HTMLAttributes<HTMLAnchorElement> =
372
- isActive
373
- ? STATIC_EMPTY_OBJECT
374
- : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT)
375
-
376
- const resolvedClassName = [
377
- className,
378
- resolvedActiveProps.className,
379
- resolvedInactiveProps.className,
380
- ]
381
- .filter(Boolean)
382
- .join(' ')
383
-
384
- const resolvedStyle = (style ||
385
- resolvedActiveProps.style ||
386
- resolvedInactiveProps.style) && {
387
- ...style,
388
- ...resolvedActiveProps.style,
389
- ...resolvedInactiveProps.style,
390
- }
391
-
392
689
  return {
393
690
  ...propsSafeToSpread,
394
691
  ...resolvedActiveProps,
@@ -406,7 +703,7 @@ export function useLinkProps<
406
703
  ...(resolvedClassName && { className: resolvedClassName }),
407
704
  ...(disabled && STATIC_DISABLED_PROPS),
408
705
  ...(isActive && STATIC_ACTIVE_PROPS),
409
- ...(isTransitioning && STATIC_TRANSITIONING_PROPS),
706
+ ...(isHydrated && isTransitioning && STATIC_TRANSITIONING_PROPS),
410
707
  }
411
708
  }
412
709
 
@@ -432,6 +729,30 @@ const composeHandlers =
432
729
  }
433
730
  }
434
731
 
732
+ function getHrefOption(
733
+ publicHref: string,
734
+ external: boolean,
735
+ history: AnyRouter['history'],
736
+ disabled: boolean | undefined,
737
+ ) {
738
+ if (disabled) return undefined
739
+ // Full URL means rewrite changed the origin - treat as external-like
740
+ if (external) {
741
+ return { href: publicHref, external: true }
742
+ }
743
+ return {
744
+ href: history.createHref(publicHref) || '/',
745
+ external: false,
746
+ }
747
+ }
748
+
749
+ function isSafeInternal(to: unknown) {
750
+ if (typeof to !== 'string') return false
751
+ const zero = to.charCodeAt(0)
752
+ if (zero === 47) return to.charCodeAt(1) !== 47 // '/' but not '//'
753
+ return zero === 46 // '.', '..', './', '../'
754
+ }
755
+
435
756
  type UseLinkReactProps<TComp> = TComp extends keyof React.JSX.IntrinsicElements
436
757
  ? React.JSX.IntrinsicElements[TComp]
437
758
  : TComp extends React.ComponentType<any>