Repository: TanStack/router
Pull request #2062 (2024-07-31 04:31:13)

feat(react-router): support for `preload='viewport'`
This adds support for `preload='viewport'`.

Similar to how NextJS does preloading on anchor elements that enter the viewport, this change would make this possible for the `useLinkProps` hook, `Link` component, and the `createLink` utility function.

Testing required the mocking of the IntersectionObserver API (can't be helped 🤷🏼).

Tasklist:
- [x] Implementation
- [x] Testing
- [x] Docs on the preloading guide
- [x] Docs on the API reference

nx-cloud[bot]: # ☁️ Nx Cloud Report

CI is running for commit ec75f764c9aaccd505400f0fca23c0a285631916.

[📂 Click to track the progress, see the status, the terminal output, and the build insights.](https://cloud.nx.app/cipes/66a9be2d88e47350f0348712?utm_source=pull-request&utm_medium=comment)


---

Sent with 💌 from [NxCloud.](https://nx.app?utm_source=pull-request&utm_medium=comment)
pkg-pr-new[bot]: 
_commit: <a href="https://github.com/TanStack/router/runs/28139322942"><code>ec75f76</code></a>_


```
pnpm add https://pkg.pr.new/@tanstack/history@2062
```


```
pnpm add https://pkg.pr.new/@tanstack/react-cross-context@2062
```


```
pnpm add https://pkg.pr.new/@tanstack/react-router@2062
```


```
pnpm add https://pkg.pr.new/@tanstack/react-router-with-query@2062
```


```
pnpm add https://pkg.pr.new/@tanstack/router-cli@2062
```


```
pnpm add https://pkg.pr.new/@tanstack/router-devtools@2062
```


```
pnpm add https://pkg.pr.new/@tanstack/router-generator@2062
```


```
pnpm add https://pkg.pr.new/@tanstack/router-plugin@2062
```


```
pnpm add https://pkg.pr.new/@tanstack/router-vite-plugin@2062
```


```
pnpm add https://pkg.pr.new/@tanstack/start@2062
```


```
pnpm add https://pkg.pr.new/@tanstack/start-vite-plugin@2062
```


[Open in Stackblitz](https://pkg.pr.new/template/26248185-b49e-4ecf-ac07-6ea9652aca4d)
<details><summary><b>More templates</b></summary><p>

- [tanstack-router-react-example-authenticated-routes](https://pkg.pr.new/template/063f69a5-255a-4bba-9ca7-2babd17971a2)
- [tanstack-router-react-example-basic](https://pkg.pr.new/template/a19a0cb7-e6d9-4a1d-b1f4-e5c442810479)
- [tanstack-router-react-example-basic-default-search-params](https://pkg.pr.new/template/11bca916-f4a8-4154-aae4-acf868ef2f6e)
- [tanstack-router-react-example-basic-file-based](https://pkg.pr.new/template/1c4811d3-14f5-4eef-b052-87d45d4551af)
- [tanstack-router-react-example-basic-file-based-codesplitting](https://pkg.pr.new/template/3b99d38f-f2aa-430f-bca1-55a20537a952)
- [tanstack-router-react-example-react-query](https://pkg.pr.new/template/490ba724-24a8-4625-b87f-90d5089fc045)
- [tanstack-router-react-example-basic-react-query-file-based](https://pkg.pr.new/template/05e8a8be-d2ac-4002-9885-66039f1ed971)
- [tanstack-router-react-example-basic-ssr-file-based](https://pkg.pr.new/template/f821e52e-5ae0-4f35-ace7-f0af46a3aac3)
- [tanstack-router-react-example-basic-ssr-streaming-file-based](https://pkg.pr.new/template/6ba65af8-effa-4248-88b2-a4eb2f2506e7)
- [tanstack-router-react-example-deferred-data](https://pkg.pr.new/template/a1472fcb-7651-4dfa-9b6d-bc75f7e6b810)
- [tanstack-router-react-example-kitchen-sink](https://pkg.pr.new/template/ddd9744b-b420-42db-9f64-eaa0e06d7472)
- [tanstack-router-react-example-kitchen-sink-file-based](https://pkg.pr.new/template/9491c85c-23e8-4c38-8392-b5feb3e1a0c1)
- [tanstack-router-react-example-kitchen-sink-react-query](https://pkg.pr.new/template/a840de85-155a-49c6-a4c0-e0857358d97f)
- [tanstack-router-react-example-kitchen-sink-react-query-file-based](https://pkg.pr.new/template/654d5bf1-57fb-4de8-a4e7-ad52abe688e6)
- [tanstack-router-react-example-large-file-based](https://pkg.pr.new/template/73d33aba-3f66-4b66-bb97-409662c582cd)
- [tanstack-router-react-example-location-masking](https://pkg.pr.new/template/8828bf2e-ebae-4025-9757-51fc18b7ad62)
- [tanstack-router-react-example-navigation-blocking](https://pkg.pr.new/template/eaabbfcd-4a4b-4f84-a863-3d8b94402dd2)
- [tanstack-router-react-example-quickstart](https://pkg.pr.new/template/89ffc7cf-6982-4533-8bb8-ef75a7c2a9e8)
- [tanstack-router-react-example-quickstart-file-based](https://pkg.pr.new/template/1ace759d-4cbf-4714-a712-fd683b41416a)
- [tanstack-router-react-example-quickstart-rspack-file-based](https://pkg.pr.new/template/0f0b3086-aa9f-41a5-9112-07585043c48c)
- [tanstack-router-react-example-quickstart-webpack-file-based](https://pkg.pr.new/template/82d74519-4ebc-48f8-8739-34d65136a605)
- [tanstack-router-react-example-scroll-restoration](https://pkg.pr.new/template/5ece6ebe-e395-4ce7-b888-432a3f1685fa)
- [tanstack-router-example-react-start-basic](https://pkg.pr.new/template/51c3c307-f19e-4cc0-8251-11cf8503ce6b)
- [tanstack-router-example-react-start-basic-counter](https://pkg.pr.new/template/e3859920-237d-412a-9e55-20cd5adedf0a)
- [tanstack-router-example-react-start-basic-react-query](https://pkg.pr.new/template/c6ce2073-05f1-4bc8-8da5-2f0652aa7727)
- [tanstack-router-example-react-start-basic-rsc](https://pkg.pr.new/template/4a897861-7839-47a0-b9db-b30d8d91765b)
- [tanstack-router-example-react-start-basic-trellaux](https://pkg.pr.new/template/b8dd4dbf-ece3-42e8-a124-d1f011d2f51b)
- [tanstack-router-react-example-with-trpc-react-query](https://pkg.pr.new/template/35a0512a-05cb-4d3e-b4d1-7844301c34e4)
- [tanstack-router-react-example-waku-rsc](https://pkg.pr.new/template/022339e4-b280-4f8c-941b-dc870272b5fe)
- [tanstack-router-react-example-with-bling](https://pkg.pr.new/template/d70e966e-27f5-4650-ba67-02f51883d036)
- [tanstack-router-react-example-with-framer-motion](https://pkg.pr.new/template/3bd62541-eb79-41be-87c8-d0061fd6d343)
- [tanstack-router-react-example-with-trpc](https://pkg.pr.new/template/f2a8f51c-8c9f-48f9-bc22-b924a05a611b)

</p></details>
      
    


Patch:
diff --git a/docs/framework/react/api/router/LinkOptionsType.md b/docs/framework/react/api/router/LinkOptionsType.md
index c7a06c644..eb34eca80 100644
--- a/docs/framework/react/api/router/LinkOptionsType.md
+++ b/docs/framework/react/api/router/LinkOptionsType.md
@@ -33,9 +33,10 @@ The `LinkOptions` object accepts/contains the following properties:
 
 ### `preload`
 
-- Type: `false | 'intent'`
+- Type: `false | 'intent' | 'viewport'`
 - Optional
-- If set, will preload the linked route on hover and cache it for this many milliseconds in hopes that the user will eventually navigate there.
+- If set, the link's preloading strategy will be set to this value.
+- See the [Preloading guide](../../guide/preloading.md) for more information.
 
 ### `preloadDelay`
 
diff --git a/docs/framework/react/api/router/RouterOptionsType.md b/docs/framework/react/api/router/RouterOptionsType.md
index ebeee74eb..251de6813 100644
--- a/docs/framework/react/api/router/RouterOptionsType.md
+++ b/docs/framework/react/api/router/RouterOptionsType.md
@@ -37,11 +37,12 @@ The `RouterOptions` type accepts an object with the following properties and met
 
 ### `defaultPreload` property
 
-- Type: `undefined | false | 'intent'`
+- Type: `undefined | false | 'intent' | 'viewport'`
 - Optional
 - Defaults to `false`
 - If `false`, routes will not be preloaded by default in any way.
 - If `'intent'`, routes will be preloaded by default when the user hovers over a link or a `touchstart` event is detected on a `<Link>`.
+- If `'viewport'`, routes will be preloaded by default when they are within the viewport of the browser.
 
 ### `defaultPreloadDelay` property
 
diff --git a/docs/framework/react/guide/preloading.md b/docs/framework/react/guide/preloading.md
index 92736bc83..bd773d508 100644
--- a/docs/framework/react/guide/preloading.md
+++ b/docs/framework/react/guide/preloading.md
@@ -7,10 +7,12 @@ Preloading in TanStack Router is a way to load a route before the user actually
 ## Supported Preloading Strategies
 
 - Intent
-  - **Preloading by "intent" works by using hover and touch start events on `<Link>` components to preload the dependencies for the destination route.**
-- Render
-  - **Coming soon!**
+  - Preloading by **"intent"** works by using hover and touch start events on `<Link>` components to preload the dependencies for the destination route.
+  - This strategy is useful for preloading routes that the user is likely to visit next.
 - Viewport Visibility
+  - Preloading by **"viewport**" works by using the Intersection Observer API to preload the dependencies for the destination route when the `<Link>` component is in the viewport.
+  - This strategy is useful for preloading routes that are below the fold or off-screen.
+- Render
   - **Coming soon!**
 
 ## How long does preloaded data stay in memory?
diff --git a/docs/framework/react/guide/tanstack-start.md b/docs/framework/react/guide/tanstack-start.md
index 9486bbe1f..737afcd37 100644
--- a/docs/framework/react/guide/tanstack-start.md
+++ b/docs/framework/react/guide/tanstack-start.md
@@ -67,15 +67,15 @@ npm i -D typescript @types/react @types/react-dom
 
 We'll then update our `package.json` to reference the new Vinxi entry point and set `"type": "module"`:
 
-```json
+```jsonc
 {
-  "//": "...",
-  "type": "module"
+  // ...
+  "type": "module",
   "scripts": {
     "dev": "vinxi dev",
     "build": "vinxi build",
-    "start": "vinxi start"
-  }
+    "start": "vinxi start",
+  },
 }
 ```
 
diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx
index c1624a6f6..91ad5d5ea 100644
--- a/packages/react-router/src/link.tsx
+++ b/packages/react-router/src/link.tsx
@@ -4,7 +4,12 @@ import * as React from 'react'
 import { flushSync } from 'react-dom'
 import { useRouterState } from './useRouterState'
 import { useRouter } from './useRouter'
-import { deepEqual, functionalUpdate } from './utils'
+import {
+  deepEqual,
+  functionalUpdate,
+  useForwardedRef,
+  useIntersectionObserver,
+} from './utils'
 import { exactPathTest, removeTrailingSlash } from './path'
 import type { AnyRouter, ParsedLocation } from '.'
 import type { HistoryState } from '@tanstack/history'
@@ -460,15 +465,32 @@ export type LinkOptions<
 > = NavigateOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo> & LinkOptionsProps
 
 export interface LinkOptionsProps {
-  // The standard anchor tag target attribute
+  /**
+   * The standard anchor tag target attribute
+   */
   target?: HTMLAnchorElement['target']
-  // Defaults to `{ exact: false, includeHash: false }`
+  /**
+   * Configurable options to determine if the link should be considered active or not
+   * @default {exact:true,includeHash:true}
+   */
   activeOptions?: ActiveOptions
-  // If set, will preload the linked route on hover and cache it for this many milliseconds in hopes that the user will eventually navigate there.
-  preload?: false | 'intent'
-  // Delay intent preloading by this many milliseconds. If the intent exits before this delay, the preload will be cancelled.
+  /**
+   * The preloading strategy for this link
+   * - `false` - No preloading
+   * - `'intent'` - Preload the linked route on hover and cache it for this many milliseconds in hopes that the user will eventually navigate there.
+   * - `'viewport'` - Preload the linked route when it enters the viewport
+   */
+  preload?: false | 'intent' | 'viewport'
+  /**
+   * When a preload strategy is set, this delays the preload by this many milliseconds.
+   * If the user exits the link before this delay, the preload will be cancelled.
+   */
   preloadDelay?: number
-  // If true, will render the link without the href attribute
+  /**
+   * Control whether the link should be disabled or not
+   * If set to `true`, the link will be rendered without an `href` attribute
+   * @default false
+   */
   disabled?: boolean
 }
 
@@ -542,9 +564,11 @@ export function useLinkProps<
   TMaskTo extends string = '',
 >(
   options: UseLinkPropsOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo>,
-): React.AnchorHTMLAttributes<HTMLAnchorElement> {
+  forwardedRef?: React.ForwardedRef<Element>,
+): React.ComponentPropsWithRef<'a'> {
   const router = useRouter()
   const [isTransitioning, setIsTransitioning] = React.useState(false)
+  const innerRef = useForwardedRef(forwardedRef)
 
   const {
     // custom props
@@ -584,15 +608,22 @@ export function useLinkProps<
   // If this `to` is a valid external URL, return
   // null for LinkUtils
 
-  let type: 'internal' | 'external' = 'internal'
-
-  try {
-    new URL(`${to}`)
-    type = 'external'
-  } catch {}
-
-  const next = router.buildLocation(options as any)
-  const preload = userPreload ?? router.options.defaultPreload
+  const type: 'internal' | 'external' = React.useMemo(() => {
+    try {
+      new URL(`${to}`)
+      return 'external'
+    } catch {}
+    return 'internal'
+  }, [to])
+
+  const next = React.useMemo(
+    () => router.buildLocation(options as any),
+    [router, options],
+  )
+  const preload = React.useMemo(
+    () => userPreload ?? router.options.defaultPreload,
+    [router.options.defaultPreload, userPreload],
+  )
   const preloadDelay =
     userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0
 
@@ -627,9 +658,37 @@ export function useLinkProps<
     },
   })
 
+  const doPreload = React.useCallback(() => {
+    router.preloadRoute(options as any).catch((err) => {
+      console.warn(err)
+      console.warn(preloadWarning)
+    })
+  }, [options, router])
+
+  const viewportIntersectionCallback = React.useCallback(
+    (entry: IntersectionObserverEntry | undefined) => {
+      if (entry?.isIntersecting) {
+        doPreload()
+      }
+    },
+    [doPreload],
+  )
+
+  useIntersectionObserver(
+    innerRef,
+    {
+      rootMargin: '100px',
+    },
+    {
+      disabled: !!disabled || preload !== 'viewport',
+    },
+    viewportIntersectionCallback,
+  )
+
   if (type === 'external') {
     return {
       ...rest,
+      ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'],
       type,
       href: to,
       ...(children && { children }),
@@ -677,15 +736,8 @@ export function useLinkProps<
     }
   }
 
-  const doPreload = () => {
-    router.preloadRoute(options as any).catch((err) => {
-      console.warn(err)
-      console.warn(preloadWarning)
-    })
-  }
-
   // The click handler
-  const handleFocus = (e: MouseEvent) => {
+  const handleFocus = (_: MouseEvent) => {
     if (disabled) return
     if (preload) {
       doPreload()
@@ -762,6 +814,7 @@ export function useLinkProps<
       : next.maskedLocation
         ? router.history.createHref(next.maskedLocation.href)
         : router.history.createHref(next.href),
+    ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'],
     onClick: composeHandlers([onClick, handleClick]),
     onFocus: composeHandlers([onFocus, handleFocus]),
     onMouseEnter: composeHandlers([onMouseEnter, handleEnter]),
@@ -876,32 +929,34 @@ export function createLink<const TComp>(Comp: TComp): LinkComponent<TComp> {
   }) as any
 }
 
-export const Link: LinkComponent<'a'> = React.forwardRef((props: any, ref) => {
-  const { _asChild, ...rest } = props
-  const { type, ...linkProps } = useLinkProps(rest)
-
-  const children =
-    typeof rest.children === 'function'
-      ? rest.children({
-          isActive: (linkProps as any)['data-status'] === 'active',
-        })
-      : rest.children
-
-  if (typeof _asChild === 'undefined') {
-    // the ReturnType of useLinkProps returns the correct type for a <a> element, not a general component that has a delete prop
-    // @ts-expect-error
-    delete linkProps.disabled
-  }
+export const Link: LinkComponent<'a'> = React.forwardRef<Element, any>(
+  (props, ref) => {
+    const { _asChild, ...rest } = props
+    const { type, ref: innerRef, ...linkProps } = useLinkProps(rest, ref)
+
+    const children =
+      typeof rest.children === 'function'
+        ? rest.children({
+            isActive: (linkProps as any)['data-status'] === 'active',
+          })
+        : rest.children
+
+    if (typeof _asChild === 'undefined') {
+      // the ReturnType of useLinkProps returns the correct type for a <a> element, not a general component that has a delete prop
+      // @ts-expect-error
+      delete linkProps.disabled
+    }
 
-  return React.createElement(
-    _asChild ? _asChild : 'a',
-    {
-      ...linkProps,
-      ref,
-    },
-    children,
-  )
-}) as any
+    return React.createElement(
+      _asChild ? _asChild : 'a',
+      {
+        ...linkProps,
+        ref: innerRef,
+      },
+      children,
+    )
+  },
+) as any
 
 function isCtrlEvent(e: MouseEvent) {
   return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)
diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts
index 58c807e04..591c9b5a2 100644
--- a/packages/react-router/src/router.ts
+++ b/packages/react-router/src/router.ts
@@ -153,218 +153,271 @@ export interface RouterOptions<
 > {
   /**
    * The history object that will be used to manage the browser history.
+   *
    * If not provided, a new createBrowserHistory instance will be created and used.
+   *
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#history-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/history-types)
    */
   history?: RouterHistory
   /**
    * A function that will be used to stringify search params when generating links.
-   * Defaults to `defaultStringifySearch`.
+   *
+   * @default defaultStringifySearch
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#stringifysearch-method)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/custom-search-param-serialization)
    */
   stringifySearch?: SearchSerializer
   /**
    * A function that will be used to parse search params when parsing the current location.
-   * Defaults to `defaultParseSearch`.
+   *
+   * @default defaultParseSearch
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#parsesearch-method)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/custom-search-param-serialization)
    */
   parseSearch?: SearchParser
   /**
-   * Defaults to `false`
    * If `false`, routes will not be preloaded by default in any way.
+   *
    * If `'intent'`, routes will be preloaded by default when the user hovers over a link or a `touchstart` event is detected on a `<Link>`.
+   *
+   * If `'viewport'`, routes will be preloaded by default when they are within the viewport.
+   *
+   * @default false
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpreload-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/preloading)
    */
-  defaultPreload?: false | 'intent'
+  defaultPreload?: false | 'intent' | 'viewport'
   /**
-   * Defaults to 50
    * The delay in milliseconds that a route must be hovered over or touched before it is preloaded.
+   *
+   * @default 50
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpreloaddelay-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/preloading#preload-delay)
    */
   defaultPreloadDelay?: number
   /**
-   * Defaults to `Outlet`
    * The default `component` a route should use if no component is provided.
+   *
+   * @default Outlet
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultcomponent-property)
    */
   defaultComponent?: RouteComponent
   /**
-   * Defaults to `ErrorComponent`
    * The default `errorComponent` a route should use if no error component is provided.
+   *
+   * @default ErrorComponent
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaulterrorcomponent-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#handling-errors-with-routeoptionserrorcomponent)
    */
   defaultErrorComponent?: ErrorRouteComponent
   /**
    * The default `pendingComponent` a route should use if no pending component is provided.
+   *
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpendingcomponent-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#showing-a-pending-component)
    */
   defaultPendingComponent?: RouteComponent
   /**
-   * Defaults to `1000`
    * The default `pendingMs` a route should use if no pendingMs is provided.
+   *
+   * @default 1000
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpendingms-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#avoiding-pending-component-flash)
    */
   defaultPendingMs?: number
   /**
-   * Defaults to `500`
    * The default `pendingMinMs` a route should use if no pendingMinMs is provided.
+   *
+   * @default 500
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpendingminms-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#avoiding-pending-component-flash)
    */
   defaultPendingMinMs?: number
   /**
-   * Defaults to `0`
-   * The default `staleTime` a route should use if no staleTime is
+   * The default `staleTime` a route should use if no staleTime is provided. This is the time in milliseconds that a route will be considered fresh.
+   *
+   * @default 0
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultstaletime-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#key-options)
    */
   defaultStaleTime?: number
   /**
-   * Defaults to `30_000` ms (30 seconds)
    * The default `preloadStaleTime` a route should use if no preloadStaleTime is provided.
+   *
+   * @default 30_000 `(30 seconds)`
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpreloadstaletime-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/preloading)
    */
   defaultPreloadStaleTime?: number
   /**
    * Defaults to `routerOptions.defaultGcTime`, which defaults to 30 minutes.
+   *
    * The default `defaultPreloadGcTime` a route should use if no preloadGcTime is provided.
+   *
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpreloadgctime-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/preloading)
    */
   defaultPreloadGcTime?: number
   /**
    * The default `onCatch` handler for errors caught by the Router ErrorBoundary
+   *
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultoncatch-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#handling-errors-with-routeoptionsoncatch)
    */
   defaultOnCatch?: (error: Error, errorInfo: React.ErrorInfo) => void
+  /**
+   * If set to `true`, all navigation actions will wrapped in `document.startViewTransition()`.
+   */
   defaultViewTransition?: boolean
   /**
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/not-found-errors#the-notfoundmode-option)
    */
   notFoundMode?: 'root' | 'fuzzy'
   /**
-   * Defaults to 30 minutes.
    * The default `gcTime` a route should use if no
+   *
+   * @default 1_800_000 `(30 minutes)`
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultgctime-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#key-options)
    */
   defaultGcTime?: number
   /**
-   * Defaults to `false`
    * If `true`, all routes will be matched as case-sensitive.
+   *
+   * @default false
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#casesensitive-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/route-trees#case-sensitivity)
    */
   caseSensitive?: boolean
   /**
-   * Required
+   * __Required*__
+   *
    * The route tree that will be used to configure the router instance.
+   *
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#routetree-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/route-trees)
    */
   routeTree?: TRouteTree
   /**
-   * Defaults to `/`
    * The basepath for then entire router. This is useful for mounting a router instance at a subpath.
+   *
+   * @default '/'
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#basepath-property)
    */
   basepath?: string
   /**
-   * Optional or required if the root route was created with [`createRootRouteWithContext()`](https://tanstack.com/router/latest/docs/framework/react/api/router/createRootRouteWithContextFunction).
    * The root context that will be provided to all routes in the route tree.
+   *
    * This can be used to provide a context to all routes in the tree without having to provide it to each route individually.
+   *
+   * Optional or required if the root route was created with [`createRootRouteWithContext()`](https://tanstack.com/router/latest/docs/framework/react/api/router/createRootRouteWithContextFunction).
+   *
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#context-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/router-context)
    */
   context?: InferRouterContext<TRouteTree>
   /**
    * A function that will be called when the router is dehydrated.
+   *
    * The return value of this function will be serialized and stored in the router's dehydrated state.
+   *
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#dehydrate-method)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/external-data-loading#critical-dehydrationhydration)
    */
   dehydrate?: () => TDehydrated
   /**
    * A function that will be called when the router is hydrated.
+   *
    * The return value of this function will be serialized and stored in the router's dehydrated state.
+   *
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#hydrate-method)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/external-data-loading#critical-dehydrationhydration)
    */
   hydrate?: (dehydrated: TDehydrated) => void
   /**
    * An array of route masks that will be used to mask routes in the route tree.
+   *
    * Route masking is when you display a route at a different path than the one it is configured to match, like a modal popup that when shared will unmask to the modal's content instead of the modal's context.
+   *
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#routemasks-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/route-masking)
    */
   routeMasks?: Array<RouteMask<TRouteTree>>
   /**
-   * Defaults to `false`
    * If `true`, route masks will, by default, be removed when the page is reloaded.
+   *
    * This can be overridden on a per-mask basis by setting the `unmaskOnReload` option on the mask, or on a per-navigation basis by setting the `unmaskOnReload` option in the `Navigate` options.
+   *
+   * @default false
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#unmaskonreload-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/route-masking#unmasking-on-page-reload)
    */
   unmaskOnReload?: boolean
   /**
    * A component that will be used to wrap the entire router.
+   *
    * This is useful for providing a context to the entire router.
+   *
    * Only non-DOM-rendering components like providers should be used, anything else will cause a hydration error.
+   *
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#wrap-property)
    */
   Wrap?: (props: { children: any }) => React.JSX.Element
   /**
    * A component that will be used to wrap the inner contents of the router.
+   *
    * This is useful for providing a context to the inner contents of the router where you also need access to the router context and hooks.
+   *
    * Only non-DOM-rendering components like providers should be used, anything else will cause a hydration error.
+   *
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#innerwrap-property)
    */
   InnerWrap?: (props: { children: any }) => React.JSX.Element
   /**
-   * @deprecated
    * Use `notFoundComponent` instead.
+   *
+   * @deprecated
    * See https://tanstack.com/router/v1/docs/guide/not-found-errors#migrating-from-notfoundroute for more info.
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#notfoundroute-property)
    */
   notFoundRoute?: AnyRoute
   /**
-   * Defaults to `NotFound`
    * The default `notFoundComponent` a route should use if no notFound component is provided.
+   *
+   * @default NotFound
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultnotfoundcomponent-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/not-found-errors#default-router-wide-not-found-handling)
    */
   defaultNotFoundComponent?: NotFoundRouteComponent
   /**
    * The transformer that will be used when sending data between the server and the client during SSR.
+   *
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#transformer-property)
    * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/ssr#data-transformers)
    */
   transformer?: RouterTransformer
   /**
    * The serializer object that will be used to determine how errors are serialized and deserialized between the server and the client.
+   *
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#errorserializer-property)
    */
   errorSerializer?: RouterErrorSerializer<TSerializedError>
   /**
-   * Defaults to `never`
    * Configures how trailing slashes are treated.
-   * `'always'` will add a trailing slash if not present, `'never'` will remove the trailing slash if present and `'preserve'` will not modify the trailing slash.
+   *
+   * - `'always'` will add a trailing slash if not present
+   * - `'never'` will remove the trailing slash if present
+   * - `'preserve'` will not modify the trailing slash.
+   *
+   * @default 'never'
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#trailingslash-property)
    */
   trailingSlash?: TTrailingSlashOption
   /**
-   * Defaults to `typeof document !== 'undefined'`
    * While usually automatic, sometimes it can be useful to force the router into a server-side state, e.g. when using the router in a non-browser environment that has access to a global.document object.
+   *
+   * @default typeof document !== 'undefined'
    * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#isserver property)
    */
   isServer?: boolean
diff --git a/packages/react-router/src/utils.ts b/packages/react-router/src/utils.ts
index 2fb126577..aa5ec8f71 100644
--- a/packages/react-router/src/utils.ts
+++ b/packages/react-router/src/utils.ts
@@ -372,3 +372,87 @@ export function usePrevious<T>(value: T): T | null {
   // return the previous value only
   return ref.current.prev
 }
+
+/**
+ * React hook to wrap `IntersectionObserver`.
+ *
+ * This hook will create an `IntersectionObserver` and observe the ref passed to it.
+ *
+ * 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 options - The options to pass to the hook
+ * @param callback - The callback to call when the intersection changes
+ * @returns The IntersectionObserver instance
+ * @example
+ * ```tsx
+ * const MyComponent = () => {
+ * const ref = React.useRef<HTMLDivElement>(null)
+ * useIntersectionObserver(ref, { rootMargin: '10px' }, {}, (entry) => {
+ * return <div ref={ref} />
+ * })
+ * ```
+ */
+export function useIntersectionObserver<T extends Element>(
+  ref: React.RefObject<T>,
+  intersectionObserverOptions: IntersectionObserverInit = {},
+  options: { disabled?: boolean } = {},
+  callback: (entry: IntersectionObserverEntry | undefined) => void,
+): IntersectionObserver | null {
+  const isIntersectionObserverAvailable = React.useRef(
+    typeof IntersectionObserver === 'function',
+  )
+
+  const observerRef = React.useRef<IntersectionObserver | null>(null)
+
+  React.useEffect(() => {
+    if (
+      !ref.current ||
+      !isIntersectionObserverAvailable.current ||
+      options.disabled
+    ) {
+      return
+    }
+
+    observerRef.current = new IntersectionObserver(([entry]) => {
+      callback(entry)
+    }, intersectionObserverOptions)
+
+    observerRef.current.observe(ref.current)
+
+    return () => {
+      observerRef.current?.disconnect()
+    }
+  }, [intersectionObserverOptions, options, ref, callback])
+
+  return observerRef.current
+}
+
+/**
+ * React hook to take a `React.ForwardedRef` and returns a `ref` that can be used on a DOM element.
+ *
+ * @param ref - The forwarded ref
+ * @returns The inner ref returned by `useRef`
+ * @example
+ * ```tsx
+ * const MyComponent = React.forwardRef((props, ref) => {
+ *  const innerRef = useForwardedRef(ref)
+ *  return <div ref={innerRef} />
+ * })
+ * ```
+ */
+export function useForwardedRef<T>(ref?: React.ForwardedRef<T>) {
+  const innerRef = React.useRef<T>(null)
+
+  React.useEffect(() => {
+    if (!ref) return
+    if (typeof ref === 'function') {
+      ref(innerRef.current)
+    } else {
+      ref.current = innerRef.current
+    }
+  })
+
+  return innerRef
+}


Test patch:
diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx
index 8e3604ebb..9ccf2c608 100644
--- a/packages/react-router/tests/link.test.tsx
+++ b/packages/react-router/tests/link.test.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import { afterEach, describe, expect, it, test, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'
 import {
   cleanup,
   configure,
@@ -26,6 +26,18 @@ import {
   useRouteContext,
   useSearch,
 } from '../src'
+import { getIntersectionObserverMock } from './utils'
+
+const ioObserveMock = vi.fn()
+const ioDisconnectMock = vi.fn()
+
+beforeEach(() => {
+  const io = getIntersectionObserverMock({
+    observe: ioObserveMock,
+    disconnect: ioDisconnectMock,
+  })
+  vi.stubGlobal('IntersectionObserver', io)
+})
 
 afterEach(() => {
   vi.resetAllMocks()
@@ -3360,6 +3372,94 @@ describe('Link', () => {
     expect(stringifyParamsMock).toHaveBeenCalledWith({ postId: 2 })
     expect(stringifyParamsMock).toHaveBeenCalledWith({ postId: 0 })
   })
+
+  test.each([false, 'intent'] as const)(
+    'Router.preload="%s", should not trigger the IntersectionObserver\'s observe and disconnect methods',
+    async (preload) => {
+      const rootRoute = createRootRoute()
+      const indexRoute = createRoute({
+        getParentRoute: () => rootRoute,
+        path: '/',
+        component: () => (
+          <>
+            <h1>Index Heading</h1>
+            <Link to="/">Index Link</Link>
+          </>
+        ),
+      })
+
+      const router = createRouter({
+        routeTree: rootRoute.addChildren([indexRoute]),
+        defaultPreload: preload,
+      })
+
+      render(<RouterProvider router={router} />)
+
+      const indexLink = await screen.findByRole('link', { name: 'Index Link' })
+      expect(indexLink).toBeInTheDocument()
+
+      expect(ioObserveMock).not.toBeCalled()
+      expect(ioDisconnectMock).not.toBeCalled()
+    },
+  )
+
+  test('Router.preload="viewport", should trigger the IntersectionObserver\'s observe and disconnect methods', async () => {
+    const rootRoute = createRootRoute()
+    const indexRoute = createRoute({
+      getParentRoute: () => rootRoute,
+      path: '/',
+      component: () => (
+        <>
+          <h1>Index Heading</h1>
+          <Link to="/">Index Link</Link>
+        </>
+      ),
+    })
+
+    const router = createRouter({
+      routeTree: rootRoute.addChildren([indexRoute]),
+      defaultPreload: 'viewport',
+    })
+
+    render(<RouterProvider router={router} />)
+
+    const indexLink = await screen.findByRole('link', { name: 'Index Link' })
+    expect(indexLink).toBeInTheDocument()
+
+    expect(ioObserveMock).toBeCalled()
+    expect(ioObserveMock).toBeCalledTimes(2) // since React.StrictMode is enabled it double renders
+
+    expect(ioDisconnectMock).toBeCalled()
+    expect(ioDisconnectMock).toBeCalledTimes(1) // since React.StrictMode is enabled it should have disconnected
+  })
+
+  test('Router.preload="viewport" with Link.preload="false", should not trigger the IntersectionObserver\'s observe method', async () => {
+    const rootRoute = createRootRoute()
+    const indexRoute = createRoute({
+      getParentRoute: () => rootRoute,
+      path: '/',
+      component: () => (
+        <>
+          <h1>Index Heading</h1>
+          <Link to="/" preload={false}>
+            Index Link
+          </Link>
+        </>
+      ),
+    })
+
+    const router = createRouter({
+      routeTree: rootRoute.addChildren([indexRoute]),
+      defaultPreload: 'viewport',
+    })
+
+    render(<RouterProvider router={router} />)
+
+    const indexLink = await screen.findByRole('link', { name: 'Index Link' })
+    expect(indexLink).toBeInTheDocument()
+
+    expect(ioObserveMock).not.toBeCalled()
+  })
 })
 
 describe('createLink', () => {
diff --git a/packages/react-router/tests/utils.ts b/packages/react-router/tests/utils.ts
index adf8b7660..0b03fa844 100644
--- a/packages/react-router/tests/utils.ts
+++ b/packages/react-router/tests/utils.ts
@@ -1,3 +1,5 @@
+import type { Mock } from 'vitest'
+
 export function sleep(ms: number) {
   return new Promise((resolve) => setTimeout(resolve, ms))
 }
@@ -14,3 +16,36 @@ export function createTimer() {
     },
   }
 }
+
+export const getIntersectionObserverMock = ({
+  observe,
+  disconnect,
+}: {
+  observe: Mock
+  disconnect: Mock
+}) => {
+  return class IO implements IntersectionObserver {
+    root: Document | Element | null
+    rootMargin: string
+    thresholds: Array<number>
+    constructor(
+      _cb: IntersectionObserverCallback,
+      options?: IntersectionObserverInit,
+    ) {
+      this.root = options?.root ?? null
+      this.rootMargin = options?.rootMargin ?? '0px'
+      this.thresholds = options?.threshold ?? ([0] as any)
+    }
+
+    takeRecords(): Array<IntersectionObserverEntry> {
+      return []
+    }
+    unobserve(): void {}
+    observe(): void {
+      observe()
+    }
+    disconnect(): void {
+      disconnect()
+    }
+  }
+}