Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/tidy-links-wait.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@tanstack/react-router': patch
'@tanstack/solid-router': patch
'@tanstack/vue-router': patch
---

Apply `preloadDelay` to viewport link preloading and cancel pending preloads when links leave the viewport.
2 changes: 1 addition & 1 deletion docs/router/api/router/LinkOptionsType.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,7 +42,7 @@ The `LinkOptions` object accepts/contains the following properties:

- Type: `number`
- Optional
- Delay focus and hover intent preloading by this many milliseconds. Touch intent preloads immediately. If focus or hover exits before the delay, the preload will be cancelled.
- Delay focus, hover, and viewport preloading by this many milliseconds. Touch intent preloads immediately. If focus or hover ends, or the link leaves the viewport before the delay, the preload will be cancelled.

### `disabled`

Expand Down
2 changes: 1 addition & 1 deletion docs/router/api/router/RouterOptionsType.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,7 +59,7 @@ The `RouterOptions` type accepts an object with the following properties and met
- Type: `number`
- Optional
- Defaults to `50`
- The delay in milliseconds that a route must be hovered over or touched before it is preloaded.
- The delay in milliseconds before intent focus/hover and viewport preloading. Touch intent preloads immediately.

### `defaultComponent` property

Expand Down
2 changes: 1 addition & 1 deletion docs/router/guide/navigation.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -731,7 +731,7 @@ What's even better is that by using a cache-first library like `@tanstack/query`

### Link Preloading Delay

For `'intent'` preloading, a configurable delay determines how long a link must remain focused or hovered before preloading begins. If focus or hover ends before the delay, the queued preload is cancelled. Touch intent preloads immediately without waiting for the delay. The default delay is 50 milliseconds, but you can change it by passing a `preloadDelay` prop to the `Link` component:
For `'intent'` and `'viewport'` preloading, a configurable delay determines how long to wait before preloading begins after focus, hover, or viewport entry. If focus or hover ends, or the link leaves the viewport before the delay, the queued preload is cancelled. Touch intent preloads immediately without waiting for the delay. The default delay is 50 milliseconds, but you can change it by passing a `preloadDelay` prop to the `Link` component:

```tsx
const link = (
Expand Down
2 changes: 1 addition & 1 deletion docs/router/guide/preloading.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,7 @@ This will turn on `intent` preloading by default for all `<Link>` components in

## Preload Delay

By default, preloading will start after **50ms** of the user hovering or touching a `<Link>` component. You can change this delay by setting the `defaultPreloadDelay` option on your router:
By default, intent focus/hover and viewport preloading start after **50ms**. Pending preloads are cancelled if focus or hover ends, or the link leaves the viewport. Touch intent preloads immediately. You can change this delay by setting the `defaultPreloadDelay` option on your router:

<!-- ::start:framework -->

Expand Down
100 changes: 50 additions & 50 deletions packages/react-router/src/link.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -598,7 +598,7 @@ export function useLinkProps<
const hasRenderFetched = React.useRef(false)

const preload =
options.reloadDocument || externalLink
options.reloadDocument || externalLink || disabled
? false
: (userPreload ?? router.options.defaultPreload)
const preloadDelay =
Expand All@@ -615,33 +615,58 @@ export function useLinkProps<
}, [router, _options])

// eslint-disable-next-line react-hooks/rules-of-hooks
const preloadViewportIoCallback = React.useCallback(
(entry: IntersectionObserverEntry | undefined) => {
if (entry?.isIntersecting) {
const enqueuePreload = React.useCallback(
(e?: React.MouseEvent | React.FocusEvent | IntersectionObserverEntry) => {
if (!e) {
cancelPreload(innerRef)
return
}

if (
!(
(e as IntersectionObserverEntry).isIntersecting ??
preload === 'intent'
)
) {
if ((e as IntersectionObserverEntry).isIntersecting === false) {
cancelPreload(innerRef)
}
return
}

if (!preloadDelay) {
doPreload()
return
}

if (timeoutMap.has(innerRef)) {
return
}

timeoutMap.set(
innerRef,
setTimeout(() => {
timeoutMap.delete(innerRef)
doPreload()
}, preloadDelay),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
},
[doPreload],
[doPreload, innerRef, preload, preloadDelay],
)

// eslint-disable-next-line react-hooks/rules-of-hooks
useIntersectionObserver(
innerRef,
preloadViewportIoCallback,
intersectionObserverOptions,
!!disabled || preload !== 'viewport',
)
useIntersectionObserver(innerRef, enqueuePreload, preload !== 'viewport')

// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
if (hasRenderFetched.current) {
return
}
if (!disabled && preload === 'render') {
if (preload === 'render') {
doPreload()
hasRenderFetched.current = true
}
}, [disabled, doPreload, preload])
}, [doPreload, preload])

// The click handler
const handleClick = (e: React.MouseEvent) => {
Expand DownExpand Up@@ -702,39 +727,14 @@ export function useLinkProps<
}
}

const enqueueIntentPreload = (e: React.MouseEvent | React.FocusEvent) => {
if (disabled || preload !== 'intent') return

if (!preloadDelay) {
doPreload()
return
}

const eventTarget = e.currentTarget

if (timeoutMap.has(eventTarget)) {
return
}

const id = setTimeout(() => {
timeoutMap.delete(eventTarget)
doPreload()
}, preloadDelay)
timeoutMap.set(eventTarget, id)
}

const handleTouchStart = (_: React.TouchEvent) => {
if (disabled || preload !== 'intent') return
const handleTouchStart = () => {
if (preload !== 'intent') return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
doPreload()
}

const handleLeave = (e: React.MouseEvent | React.FocusEvent) => {
if (disabled || !preload || !preloadDelay) return
const eventTarget = e.currentTarget
const id = timeoutMap.get(eventTarget)
if (id) {
clearTimeout(id)
timeoutMap.delete(eventTarget)
const handleLeave = () => {
if (preload === 'intent') {
cancelPreload(innerRef)
}
}

Expand All@@ -746,8 +746,8 @@ export function useLinkProps<
ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'],
onClick: composeHandlers([onClick, handleClick]),
onBlur: composeHandlers([onBlur, handleLeave]),
onFocus: composeHandlers([onFocus, enqueueIntentPreload]),
onMouseEnter: composeHandlers([onMouseEnter, enqueueIntentPreload]),
onFocus: composeHandlers([onFocus, enqueuePreload]),
onMouseEnter: composeHandlers([onMouseEnter, enqueuePreload]),
onMouseLeave: composeHandlers([onMouseLeave, handleLeave]),
onTouchStart: composeHandlers([onTouchStart, handleTouchStart]),
disabled: !!disabled,
Expand All@@ -766,10 +766,10 @@ const STATIC_DISABLED_PROPS = { role: 'link', 'aria-disabled': true }
const STATIC_ACTIVE_PROPS = { 'data-status': 'active', 'aria-current': 'page' }
const STATIC_TRANSITIONING_PROPS = { 'data-transitioning': 'transitioning' }

const timeoutMap = new WeakMap<EventTarget, ReturnType<typeof setTimeout>>()

const intersectionObserverOptions: IntersectionObserverInit = {
rootMargin: '100px',
const timeoutMap = new WeakMap<object, ReturnType<typeof setTimeout>>()
const cancelPreload = (eventTarget: object) => {
clearTimeout(timeoutMap.get(eventTarget))
timeoutMap.delete(eventTarget)
}

const composeHandlers =
Expand DownExpand Up@@ -955,7 +955,7 @@ export function createLink<const TComp>(
*
* Props:
* - `preload`: Controls route preloading (eg. 'intent', 'render', 'viewport', true/false)
* - `preloadDelay`: Delay in ms before preloading on hover
* - `preloadDelay`: Delay in ms before preloading on focus, hover, or viewport entry
* - `activeProps`/`inactiveProps`: Additional props merged when link is active/inactive
* - `resetScroll`/`hashScrollIntoView`: Control scroll behavior on navigation
* - `viewTransition`/`startTransition`: Use View Transitions/React transitions for navigation
Expand Down
21 changes: 11 additions & 10 deletions packages/react-router/src/utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,9 +66,8 @@ export function usePrevious<T>(value: T): T | null {
* When the intersection changes, the callback will be called with the `IntersectionObserverEntry`.
*
* @param ref - The ref to observe
* @param intersectionObserverOptions - The options to pass to the IntersectionObserver
* @param disabled - Whether observation is disabled
* @param callback - The callback to call when the intersection changes
* @param disabled - Whether observation is disabled
* @returns The IntersectionObserver instance
* @example
* ```tsx
Expand All@@ -77,16 +76,14 @@ export function usePrevious<T>(value: T): T | null {
* useIntersectionObserver(
* ref,
* (entry) => { doSomething(entry) },
* { rootMargin: '10px' },
* false
* )
* return <div ref={ref} />
* ```
*/
export function useIntersectionObserver<T extends Element>(
ref: React.RefObject<T | null>,
callback: (entry: IntersectionObserverEntry | undefined) => void,
intersectionObserverOptions: IntersectionObserverInit = {},
callback: (entry?: IntersectionObserverEntry) => void,
disabled?: boolean,
) {
React.useEffect(() => {
Expand All@@ -95,19 +92,23 @@ export function useIntersectionObserver<T extends Element>(
disabled ||
typeof IntersectionObserver !== 'function'
) {
return
return () => callback()
}

const observer = new IntersectionObserver(([entry]) => {
callback(entry)
}, intersectionObserverOptions)
const observer = new IntersectionObserver(
(entries) => {
callback(entries.pop())
},
{ rootMargin: '100px' },
)

observer.observe(ref.current)

return () => {
observer.disconnect()
callback()
}
}, [callback, disabled, intersectionObserverOptions, ref])
}, [callback, disabled, ref])
}

/**
Expand Down
Loading
Loading