@tanstack/react-router 1.157.1 → 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,6 +406,7 @@ 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],
@@ -136,20 +422,19 @@ export function useLinkProps<
136
422
  const hrefOptionExternal = next.maskedLocation
137
423
  ? next.maskedLocation.external
138
424
  : next.external
139
- const hrefOption = React.useMemo(() => {
140
- if (disabled) return undefined
141
-
142
- // Full URL means rewrite changed the origin - treat as external-like
143
- if (hrefOptionExternal) {
144
- return { href: hrefOptionPublicHref, external: true }
145
- }
146
-
147
- return {
148
- href: router.history.createHref(hrefOptionPublicHref) || '/',
149
- external: false,
150
- }
151
- }, [disabled, hrefOptionExternal, hrefOptionPublicHref, router.history])
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
+ )
152
436
 
437
+ // eslint-disable-next-line react-hooks/rules-of-hooks
153
438
  const externalLink = React.useMemo(() => {
154
439
  if (hrefOption?.external) {
155
440
  // Block dangerous protocols for external links
@@ -163,15 +448,13 @@ export function useLinkProps<
163
448
  }
164
449
  return hrefOption.href
165
450
  }
166
- const isSafeInternal =
167
- typeof to === 'string' &&
168
- to.charCodeAt(0) === 47 && // '/'
169
- to.charCodeAt(1) !== 47 // but not '//'
170
- if (isSafeInternal) return undefined
451
+ const safeInternal = isSafeInternal(to)
452
+ if (safeInternal) return undefined
453
+ if (typeof to !== 'string' || to.indexOf(':') === -1) return undefined
171
454
  try {
172
455
  new URL(to as any)
173
456
  // Block dangerous protocols like javascript:, data:, vbscript:
174
- if (isDangerousProtocol(to as string)) {
457
+ if (isDangerousProtocol(to)) {
175
458
  if (process.env.NODE_ENV !== 'production') {
176
459
  console.warn(`Blocked Link with dangerous protocol: ${to}`)
177
460
  }
@@ -182,13 +465,7 @@ export function useLinkProps<
182
465
  return undefined
183
466
  }, [to, hrefOption])
184
467
 
185
- const preload =
186
- options.reloadDocument || externalLink
187
- ? false
188
- : (userPreload ?? router.options.defaultPreload)
189
- const preloadDelay =
190
- userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0
191
-
468
+ // eslint-disable-next-line react-hooks/rules-of-hooks
192
469
  const isActive = useRouterState({
193
470
  select: (s) => {
194
471
  if (externalLink) return false
@@ -238,6 +515,46 @@ export function useLinkProps<
238
515
  },
239
516
  })
240
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
241
558
  const doPreload = React.useCallback(() => {
242
559
  router.preloadRoute({ ..._options } as any).catch((err) => {
243
560
  console.warn(err)
@@ -245,6 +562,7 @@ export function useLinkProps<
245
562
  })
246
563
  }, [router, _options])
247
564
 
565
+ // eslint-disable-next-line react-hooks/rules-of-hooks
248
566
  const preloadViewportIoCallback = React.useCallback(
249
567
  (entry: IntersectionObserverEntry | undefined) => {
250
568
  if (entry?.isIntersecting) {
@@ -254,6 +572,7 @@ export function useLinkProps<
254
572
  [doPreload],
255
573
  )
256
574
 
575
+ // eslint-disable-next-line react-hooks/rules-of-hooks
257
576
  useIntersectionObserver(
258
577
  innerRef,
259
578
  preloadViewportIoCallback,
@@ -261,6 +580,7 @@ export function useLinkProps<
261
580
  { disabled: !!disabled || !(preload === 'viewport') },
262
581
  )
263
582
 
583
+ // eslint-disable-next-line react-hooks/rules-of-hooks
264
584
  React.useEffect(() => {
265
585
  if (hasRenderFetched.current) {
266
586
  return
@@ -329,7 +649,6 @@ export function useLinkProps<
329
649
  }
330
650
  }
331
651
 
332
- // The click handler
333
652
  const handleFocus = (_: React.MouseEvent) => {
334
653
  if (disabled) return
335
654
  if (preload) {
@@ -367,33 +686,6 @@ export function useLinkProps<
367
686
  }
368
687
  }
369
688
 
370
- // Get the active props
371
- const resolvedActiveProps: React.HTMLAttributes<HTMLAnchorElement> = isActive
372
- ? (functionalUpdate(activeProps as any, {}) ?? STATIC_ACTIVE_OBJECT)
373
- : STATIC_EMPTY_OBJECT
374
-
375
- // Get the inactive props
376
- const resolvedInactiveProps: React.HTMLAttributes<HTMLAnchorElement> =
377
- isActive
378
- ? STATIC_EMPTY_OBJECT
379
- : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT)
380
-
381
- const resolvedClassName = [
382
- className,
383
- resolvedActiveProps.className,
384
- resolvedInactiveProps.className,
385
- ]
386
- .filter(Boolean)
387
- .join(' ')
388
-
389
- const resolvedStyle = (style ||
390
- resolvedActiveProps.style ||
391
- resolvedInactiveProps.style) && {
392
- ...style,
393
- ...resolvedActiveProps.style,
394
- ...resolvedInactiveProps.style,
395
- }
396
-
397
689
  return {
398
690
  ...propsSafeToSpread,
399
691
  ...resolvedActiveProps,
@@ -411,7 +703,7 @@ export function useLinkProps<
411
703
  ...(resolvedClassName && { className: resolvedClassName }),
412
704
  ...(disabled && STATIC_DISABLED_PROPS),
413
705
  ...(isActive && STATIC_ACTIVE_PROPS),
414
- ...(isTransitioning && STATIC_TRANSITIONING_PROPS),
706
+ ...(isHydrated && isTransitioning && STATIC_TRANSITIONING_PROPS),
415
707
  }
416
708
  }
417
709
 
@@ -437,6 +729,30 @@ const composeHandlers =
437
729
  }
438
730
  }
439
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
+
440
756
  type UseLinkReactProps<TComp> = TComp extends keyof React.JSX.IntrinsicElements
441
757
  ? React.JSX.IntrinsicElements[TComp]
442
758
  : TComp extends React.ComponentType<any>