@tanstack/react-router 1.127.8 → 1.128.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/link.tsx CHANGED
@@ -10,17 +10,12 @@ import {
10
10
  import { useRouterState } from './useRouterState'
11
11
  import { useRouter } from './useRouter'
12
12
 
13
- import {
14
- useForwardedRef,
15
- useIntersectionObserver,
16
- useLayoutEffect,
17
- } from './utils'
13
+ import { useForwardedRef, useIntersectionObserver } from './utils'
18
14
 
19
15
  import { useMatch } from './useMatch'
20
16
  import type {
21
17
  AnyRouter,
22
18
  Constrain,
23
- LinkCurrentTargetElement,
24
19
  LinkOptions,
25
20
  RegisteredRouter,
26
21
  RoutePaths,
@@ -48,8 +43,8 @@ export function useLinkProps<
48
43
 
49
44
  const {
50
45
  // custom props
51
- activeProps = () => ({ className: 'active' }),
52
- inactiveProps = () => ({}),
46
+ activeProps,
47
+ inactiveProps,
53
48
  activeOptions,
54
49
  to,
55
50
  preload: userPreload,
@@ -71,10 +66,6 @@ export function useLinkProps<
71
66
  onMouseLeave,
72
67
  onTouchStart,
73
68
  ignoreBlocker,
74
- ...rest
75
- } = options
76
-
77
- const {
78
69
  // prevent these from being returned
79
70
  params: _params,
80
71
  search: _search,
@@ -83,8 +74,10 @@ export function useLinkProps<
83
74
  mask: _mask,
84
75
  reloadDocument: _reloadDocument,
85
76
  unsafeRelative: _unsafeRelative,
77
+ from: _from,
78
+ _fromLocation,
86
79
  ...propsSafeToSpread
87
- } = rest
80
+ } = options
88
81
 
89
82
  // If this link simply reloads the current route,
90
83
  // make sure it has a new key so it will trigger a data refresh
@@ -94,7 +87,7 @@ export function useLinkProps<
94
87
 
95
88
  const type: 'internal' | 'external' = React.useMemo(() => {
96
89
  try {
97
- new URL(`${to}`)
90
+ new URL(to as any)
98
91
  return 'external'
99
92
  } catch {}
100
93
  return 'internal'
@@ -106,33 +99,41 @@ export function useLinkProps<
106
99
  structuralSharing: true as any,
107
100
  })
108
101
 
109
- const nearestFrom = useMatch({
102
+ const from = useMatch({
110
103
  strict: false,
111
- select: (match) => match.fullPath,
104
+ select: (match) => options.from ?? match.fullPath,
112
105
  })
113
106
 
114
- const from = options.from ?? nearestFrom
115
-
116
- // Use it as the default `from` location
117
- options = { ...options, from }
118
-
119
107
  const next = React.useMemo(
120
- () => router.buildLocation(options as any),
108
+ () => router.buildLocation({ ...options, from } as any),
121
109
  // eslint-disable-next-line react-hooks/exhaustive-deps
122
- [router, options, currentSearch],
110
+ [
111
+ router,
112
+ currentSearch,
113
+ options._fromLocation,
114
+ from,
115
+ options.hash,
116
+ options.to,
117
+ options.search,
118
+ options.params,
119
+ options.state,
120
+ options.mask,
121
+ options.unsafeRelative,
122
+ ],
123
123
  )
124
124
 
125
- const preload = React.useMemo(() => {
126
- if (options.reloadDocument) {
127
- return false
128
- }
129
- return userPreload ?? router.options.defaultPreload
130
- }, [router.options.defaultPreload, userPreload, options.reloadDocument])
125
+ const isExternal = type === 'external'
126
+
127
+ const preload =
128
+ options.reloadDocument || isExternal
129
+ ? false
130
+ : (userPreload ?? router.options.defaultPreload)
131
131
  const preloadDelay =
132
132
  userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0
133
133
 
134
134
  const isActive = useRouterState({
135
135
  select: (s) => {
136
+ if (isExternal) return false
136
137
  if (activeOptions?.exact) {
137
138
  const testExact = exactPathTest(
138
139
  s.location.pathname,
@@ -146,15 +147,17 @@ export function useLinkProps<
146
147
  const currentPathSplit = removeTrailingSlash(
147
148
  s.location.pathname,
148
149
  router.basepath,
149
- ).split('/')
150
+ )
150
151
  const nextPathSplit = removeTrailingSlash(
151
152
  next.pathname,
152
153
  router.basepath,
153
- ).split('/')
154
-
155
- const pathIsFuzzyEqual = nextPathSplit.every(
156
- (d, i) => d === currentPathSplit[i],
157
154
  )
155
+
156
+ const pathIsFuzzyEqual =
157
+ currentPathSplit.startsWith(nextPathSplit) &&
158
+ (currentPathSplit.length === nextPathSplit.length ||
159
+ currentPathSplit[nextPathSplit.length] === '/')
160
+
158
161
  if (!pathIsFuzzyEqual) {
159
162
  return false
160
163
  }
@@ -177,12 +180,34 @@ export function useLinkProps<
177
180
  },
178
181
  })
179
182
 
180
- const doPreload = React.useCallback(() => {
181
- router.preloadRoute(options as any).catch((err) => {
182
- console.warn(err)
183
- console.warn(preloadWarning)
184
- })
185
- }, [options, router])
183
+ const doPreload = React.useCallback(
184
+ () => {
185
+ router.preloadRoute({ ...options, from } as any).catch((err) => {
186
+ console.warn(err)
187
+ console.warn(preloadWarning)
188
+ })
189
+ },
190
+ // eslint-disable-next-line react-hooks/exhaustive-deps
191
+ [
192
+ router,
193
+ options.to,
194
+ options._fromLocation,
195
+ from,
196
+ options.search,
197
+ options.hash,
198
+ options.params,
199
+ options.state,
200
+ options.mask,
201
+ options.unsafeRelative,
202
+ options.hashScrollIntoView,
203
+ options.href,
204
+ options.ignoreBlocker,
205
+ options.reloadDocument,
206
+ options.replace,
207
+ options.resetScroll,
208
+ options.viewTransition,
209
+ ],
210
+ )
186
211
 
187
212
  const preloadViewportIoCallback = React.useCallback(
188
213
  (entry: IntersectionObserverEntry | undefined) => {
@@ -196,11 +221,11 @@ export function useLinkProps<
196
221
  useIntersectionObserver(
197
222
  innerRef,
198
223
  preloadViewportIoCallback,
199
- { rootMargin: '100px' },
224
+ intersectionObserverOptions,
200
225
  { disabled: !!disabled || !(preload === 'viewport') },
201
226
  )
202
227
 
203
- useLayoutEffect(() => {
228
+ React.useEffect(() => {
204
229
  if (hasRenderFetched.current) {
205
230
  return
206
231
  }
@@ -210,7 +235,7 @@ export function useLinkProps<
210
235
  }
211
236
  }, [disabled, doPreload, preload])
212
237
 
213
- if (type === 'external') {
238
+ if (isExternal) {
214
239
  return {
215
240
  ...propsSafeToSpread,
216
241
  ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'],
@@ -230,7 +255,7 @@ export function useLinkProps<
230
255
  }
231
256
 
232
257
  // The click handler
233
- const handleClick = (e: MouseEvent) => {
258
+ const handleClick = (e: React.MouseEvent) => {
234
259
  if (
235
260
  !disabled &&
236
261
  !isCtrlEvent(e) &&
@@ -253,6 +278,7 @@ export function useLinkProps<
253
278
  // N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing
254
279
  return router.navigate({
255
280
  ...options,
281
+ from,
256
282
  replace,
257
283
  resetScroll,
258
284
  hashScrollIntoView,
@@ -264,7 +290,7 @@ export function useLinkProps<
264
290
  }
265
291
 
266
292
  // The click handler
267
- const handleFocus = (_: MouseEvent) => {
293
+ const handleFocus = (_: React.MouseEvent) => {
268
294
  if (disabled) return
269
295
  if (preload) {
270
296
  doPreload()
@@ -273,54 +299,44 @@ export function useLinkProps<
273
299
 
274
300
  const handleTouchStart = handleFocus
275
301
 
276
- const handleEnter = (e: MouseEvent) => {
277
- if (disabled) return
278
- const eventTarget = (e.target || {}) as LinkCurrentTargetElement
302
+ const handleEnter = (e: React.MouseEvent) => {
303
+ if (disabled || !preload) return
279
304
 
280
- if (preload) {
281
- if (eventTarget.preloadTimeout) {
305
+ if (!preloadDelay) {
306
+ doPreload()
307
+ } else {
308
+ const eventTarget = e.target
309
+ if (timeoutMap.has(eventTarget)) {
282
310
  return
283
311
  }
284
-
285
- if (!preloadDelay) {
312
+ const id = setTimeout(() => {
313
+ timeoutMap.delete(eventTarget)
286
314
  doPreload()
287
- } else {
288
- eventTarget.preloadTimeout = setTimeout(() => {
289
- eventTarget.preloadTimeout = null
290
- doPreload()
291
- }, preloadDelay)
292
- }
315
+ }, preloadDelay)
316
+ timeoutMap.set(eventTarget, id)
293
317
  }
294
318
  }
295
319
 
296
- const handleLeave = (e: MouseEvent) => {
297
- if (disabled) return
298
- const eventTarget = (e.target || {}) as LinkCurrentTargetElement
299
-
300
- if (eventTarget.preloadTimeout) {
301
- clearTimeout(eventTarget.preloadTimeout)
302
- eventTarget.preloadTimeout = null
320
+ const handleLeave = (e: React.MouseEvent) => {
321
+ if (disabled || !preload || !preloadDelay) return
322
+ const eventTarget = e.target
323
+ const id = timeoutMap.get(eventTarget)
324
+ if (id) {
325
+ clearTimeout(id)
326
+ timeoutMap.delete(eventTarget)
303
327
  }
304
328
  }
305
329
 
306
- const composeHandlers =
307
- (handlers: Array<undefined | ((e: any) => void)>) =>
308
- (e: { persist?: () => void; defaultPrevented: boolean }) => {
309
- e.persist?.()
310
- handlers.filter(Boolean).forEach((handler) => {
311
- if (e.defaultPrevented) return
312
- handler!(e)
313
- })
314
- }
315
-
316
330
  // Get the active props
317
331
  const resolvedActiveProps: React.HTMLAttributes<HTMLAnchorElement> = isActive
318
- ? (functionalUpdate(activeProps as any, {}) ?? {})
319
- : {}
332
+ ? (functionalUpdate(activeProps as any, {}) ?? STATIC_ACTIVE_OBJECT)
333
+ : STATIC_EMPTY_OBJECT
320
334
 
321
335
  // Get the inactive props
322
336
  const resolvedInactiveProps: React.HTMLAttributes<HTMLAnchorElement> =
323
- isActive ? {} : functionalUpdate(inactiveProps, {})
337
+ isActive
338
+ ? STATIC_EMPTY_OBJECT
339
+ : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT)
324
340
 
325
341
  const resolvedClassName = [
326
342
  className,
@@ -330,7 +346,9 @@ export function useLinkProps<
330
346
  .filter(Boolean)
331
347
  .join(' ')
332
348
 
333
- const resolvedStyle = {
349
+ const resolvedStyle = (style ||
350
+ resolvedActiveProps.style ||
351
+ resolvedInactiveProps.style) && {
334
352
  ...style,
335
353
  ...resolvedActiveProps.style,
336
354
  ...resolvedInactiveProps.style,
@@ -353,17 +371,35 @@ export function useLinkProps<
353
371
  onTouchStart: composeHandlers([onTouchStart, handleTouchStart]),
354
372
  disabled: !!disabled,
355
373
  target,
356
- ...(Object.keys(resolvedStyle).length && { style: resolvedStyle }),
374
+ ...(resolvedStyle && { style: resolvedStyle }),
357
375
  ...(resolvedClassName && { className: resolvedClassName }),
358
- ...(disabled && {
359
- role: 'link',
360
- 'aria-disabled': true,
361
- }),
362
- ...(isActive && { 'data-status': 'active', 'aria-current': 'page' }),
363
- ...(isTransitioning && { 'data-transitioning': 'transitioning' }),
376
+ ...(disabled && STATIC_DISABLED_PROPS),
377
+ ...(isActive && STATIC_ACTIVE_PROPS),
378
+ ...(isTransitioning && STATIC_TRANSITIONING_PROPS),
364
379
  }
365
380
  }
366
381
 
382
+ const STATIC_EMPTY_OBJECT = {}
383
+ const STATIC_ACTIVE_OBJECT = { className: 'active' }
384
+ const STATIC_DISABLED_PROPS = { role: 'link', 'aria-disabled': true }
385
+ const STATIC_ACTIVE_PROPS = { 'data-status': 'active', 'aria-current': 'page' }
386
+ const STATIC_TRANSITIONING_PROPS = { 'data-transitioning': 'transitioning' }
387
+
388
+ const timeoutMap = new WeakMap<EventTarget, ReturnType<typeof setTimeout>>()
389
+
390
+ const intersectionObserverOptions: IntersectionObserverInit = {
391
+ rootMargin: '100px',
392
+ }
393
+
394
+ const composeHandlers =
395
+ (handlers: Array<undefined | React.EventHandler<any>>) =>
396
+ (e: React.SyntheticEvent) => {
397
+ handlers.filter(Boolean).forEach((handler) => {
398
+ if (e.defaultPrevented) return
399
+ handler!(e)
400
+ })
401
+ }
402
+
367
403
  type UseLinkReactProps<TComp> = TComp extends keyof React.JSX.IntrinsicElements
368
404
  ? React.JSX.IntrinsicElements[TComp]
369
405
  : React.PropsWithoutRef<
@@ -516,7 +552,7 @@ export const Link: LinkComponent<'a'> = React.forwardRef<Element, any>(
516
552
  })
517
553
  : rest.children
518
554
 
519
- if (typeof _asChild === 'undefined') {
555
+ if (_asChild === undefined) {
520
556
  // the ReturnType of useLinkProps returns the correct type for a <a> element, not a general component that has a disabled prop
521
557
  // @ts-expect-error
522
558
  delete linkProps.disabled
@@ -533,7 +569,7 @@ export const Link: LinkComponent<'a'> = React.forwardRef<Element, any>(
533
569
  },
534
570
  ) as any
535
571
 
536
- function isCtrlEvent(e: MouseEvent) {
572
+ function isCtrlEvent(e: React.MouseEvent) {
537
573
  return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)
538
574
  }
539
575
 
package/src/utils.ts CHANGED
@@ -69,34 +69,26 @@ export function useIntersectionObserver<T extends Element>(
69
69
  callback: (entry: IntersectionObserverEntry | undefined) => void,
70
70
  intersectionObserverOptions: IntersectionObserverInit = {},
71
71
  options: { disabled?: boolean } = {},
72
- ): IntersectionObserver | null {
73
- const isIntersectionObserverAvailable = React.useRef(
74
- typeof IntersectionObserver === 'function',
75
- )
76
-
77
- const observerRef = React.useRef<IntersectionObserver | null>(null)
78
-
72
+ ) {
79
73
  React.useEffect(() => {
80
74
  if (
81
75
  !ref.current ||
82
- !isIntersectionObserverAvailable.current ||
83
- options.disabled
76
+ options.disabled ||
77
+ typeof IntersectionObserver !== 'function'
84
78
  ) {
85
79
  return
86
80
  }
87
81
 
88
- observerRef.current = new IntersectionObserver(([entry]) => {
82
+ const observer = new IntersectionObserver(([entry]) => {
89
83
  callback(entry)
90
84
  }, intersectionObserverOptions)
91
85
 
92
- observerRef.current.observe(ref.current)
86
+ observer.observe(ref.current)
93
87
 
94
88
  return () => {
95
- observerRef.current?.disconnect()
89
+ observer.disconnect()
96
90
  }
97
91
  }, [callback, intersectionObserverOptions, options.disabled, ref])
98
-
99
- return observerRef.current
100
92
  }
101
93
 
102
94
  /**