'use client' import * as React from 'react' import { useSelector } from '@tanstack/react-store' import { deepEqual, functionalUpdate, getUrlScheme, isDangerousProtocol, preloadWarning, removeTrailingSlash, } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' import { useHydrated } from './ClientOnly' import type { ActiveOptions, AnyRouter, Constrain, LinkOptions, ParsedLocation, RegisteredRouter, RoutePaths, } from '@tanstack/router-core' import type { ReactNode } from 'react' import type { ValidateLinkOptions, ValidateLinkOptionsArray, } from './typePrimitives' // Undefined active state marks an external or blocked link. // Keep that classification with the href instead of parsing it again on render. type LinkState = [href: string | undefined, isActive?: boolean] // Keep referentially stable values while their contents are equal. Links // routinely pass inline `params` / `search` object literals, which would // otherwise change `_options` identity on every parent render, rebuild the // store selector, and discard its memoized selection. One ref holds all of // them; each entry is replaced only when its own contents change. // // The router reuses a built location for as long as it sees the same options // object, so the references returned here are its invalidation signal: pass a // new object to change a destination. Like every other React prop, an object // mutated in place is not re-read. `deepEqual` short-circuits on reference // equality, so an unchanged reference costs nothing. // // `explicitUndefined` is required: an explicit `undefined` clears an // inherited param or search key, so `{}` and `{ category: undefined }` build // different locations and must not be treated as equal here. function useStableValues>(...values: T): T { const ref = React.useRef>(values) const stable = ref.current as Array values.forEach((value, index) => { if (!deepEqual(stable[index], value, false, true)) { stable[index] = value } }) return ref.current as T } function preloadLink(router: AnyRouter, options: unknown) { router.preloadRoute(options as any).catch((err) => { console.warn(err) console.warn(preloadWarning) }) } const LINK_SELECTOR_OPTIONS = { compare: (a: LinkState, b: LinkState) => a[0] === b[0] && a[1] === b[1], } function resolveExternalLink( to: string | undefined, protocolAllowlist: AnyRouter['protocolAllowlist'], ): string | null | undefined { const scheme = typeof to === 'string' && getUrlScheme(to) if (!scheme) { return undefined } if (!protocolAllowlist.has(scheme)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${to}`) } return null } return to } function resolveIsActive( location: ParsedLocation, next: ParsedLocation, activeOptions: ActiveOptions | undefined, basepath: string, isHydrated: boolean, ): boolean { const currentPath = removeTrailingSlash(location.pathname, basepath) const nextPath = removeTrailingSlash(next.pathname, basepath) // Both modes compare normalized paths; fuzzy matches need a segment boundary. if ( activeOptions?.exact ? currentPath !== nextPath : !( currentPath.startsWith(nextPath) && (currentPath.length === nextPath.length || currentPath[nextPath.length] === '/') ) ) { return false } if (activeOptions?.includeSearch ?? true) { const searchTest = deepEqual( location.search, next.search, !activeOptions?.exact, activeOptions?.explicitUndefined, ) if (!searchTest) { return false } } if (activeOptions?.includeHash) { return isHydrated && location.hash === next.hash } return true } /** * Build anchor-like props for declarative navigation and preloading. * * Returns stable `href`, event handlers and accessibility props derived from * router options and active state. Used internally by `Link` and custom links. * * Options cover `to`, `params`, `search`, `hash`, `state`, `preload`, * `activeProps`, `inactiveProps`, and more. * * @returns React anchor props suitable for `` or custom components. * @link https://tanstack.com/router/latest/docs/framework/react/api/router/useLinkPropsHook */ export function useLinkProps< TRouter extends AnyRouter = RegisteredRouter, const TFrom extends string = string, const TTo extends string | undefined = undefined, const TMaskFrom extends string = TFrom, const TMaskTo extends string = '', >( options: UseLinkPropsOptions, forwardedRef?: React.ForwardedRef, ): React.ComponentPropsWithRef<'a'> /** * `host` is what the props are rendered on: `'a'` for `Link` or the component * given to `createLink`. `Link` never renders `type` and an anchor never * receives `disabled`, so those are left out here rather than copied away * from the result in the component. Stripped from the public declarations. * * @internal */ export function useLinkProps< TRouter extends AnyRouter = RegisteredRouter, const TFrom extends string = string, const TTo extends string | undefined = undefined, const TMaskFrom extends string = TFrom, const TMaskTo extends string = '', >( options: UseLinkPropsOptions, forwardedRef: React.ForwardedRef | undefined, host: 'a' | React.ElementType, ): React.ComponentPropsWithRef<'a'> export function useLinkProps< TRouter extends AnyRouter = RegisteredRouter, const TFrom extends string = string, const TTo extends string | undefined = undefined, const TMaskFrom extends string = TFrom, const TMaskTo extends string = '', >( options: UseLinkPropsOptions, forwardedRef?: React.ForwardedRef, host?: 'a' | React.ElementType, ): React.ComponentPropsWithRef<'a'> { const router = useRouter() // ========================================================================== // SERVER EARLY RETURN // On the server, we return static props without any event handlers, // effects, or client-side interactivity. // // For SSR parity (to avoid hydration errors), we still compute the link's // active status on the server, but we avoid creating any router-state // subscriptions by reading from the location store directly. // // Note: `location.hash` is not available on the server. // ========================================================================== // The expression must stay inlined in the `if` so bundlers fold the // browser-build constant `isServer = false` and drop this server block, // together with `getServerLinkProps` and the key sets only it references. if (isServer ?? router.isServer) { return getServerLinkProps(router, options, forwardedRef, host) } // ========================================================================== // CLIENT-ONLY CODE // Everything below this point only runs on the client. The `isServer` check // above is a compile-time constant that bundlers use for dead code elimination, // so this entire section is removed from server bundles. // // We disable the rules-of-hooks lint rule because these hooks appear after // an early return. This is safe because: // 1. `isServer` is a compile-time constant from conditional exports // 2. In server bundles, this code is completely eliminated by the bundler // 3. In client bundles, `isServer` is `false`, so the early return never executes // ========================================================================== // The link's own ref: the element for the viewport observer and the key // of a pending intent timer. A forwarded ref is filled alongside it by one // callback, memoized on the forwarded ref so React re-attaches it (and // notifies the consumer) only when their ref changes, not on every render. // A cleanup returned by a consumer callback is passed through to React. // eslint-disable-next-line react-hooks/rules-of-hooks const innerRef = React.useRef(null) // eslint-disable-next-line react-hooks/rules-of-hooks const mergedRef = React.useCallback( (element: Element | null) => { innerRef.current = element if (typeof forwardedRef === 'function') { return forwardedRef(element) } if (forwardedRef) { forwardedRef.current = element } return undefined }, [forwardedRef], ) const { activeOptions, to, preload: userPreload, preloadDelay: userPreloadDelay, hashScrollIntoView, replace, startTransition, resetScroll, viewTransition, ignoreBlocker, disabled, target, onClick, onBlur, onFocus, onMouseEnter, onMouseLeave, onTouchStart, } = options as typeof options & { to?: string } // eslint-disable-next-line react-hooks/rules-of-hooks const isHydrated = useHydrated(!!activeOptions?.includeHash) // eslint-disable-next-line react-hooks/rules-of-hooks const [stableSearch, stableParams, stableActiveOptions] = useStableValues( options.search, options.params, activeOptions, ) // `_options` is the options object from the render that last changed the // destination. `dest` is its copy that the link owns: one stable object per // link lets the router reuse location-independent results. // eslint-disable-next-line react-hooks/rules-of-hooks const [_options, dest] = React.useMemo( () => [options, { ...options } as any] as const, // eslint-disable-next-line react-hooks/exhaustive-deps [ router, options.from, options._fromLocation, options.hash, options.to, stableSearch, stableParams, options.state, options.mask, options.unsafeRelative, ], ) // Derive inside the selector so `compareLinkState` can bail out. Deriving after // the subscription instead re-renders every link on every navigation, because // the comparator only sees the location, not whether this link's output moved. // eslint-disable-next-line react-hooks/rules-of-hooks const selectLinkState = React.useCallback( (location: ParsedLocation): LinkState => { const directExternalLink = resolveExternalLink( to, router.protocolAllowlist, ) if (directExternalLink !== undefined) { return [directExternalLink ?? undefined] } if (!_options._fromLocation) { dest._fromLocation = location } const next = router.buildLocation(dest) // Use publicHref - it contains the correct href for display // When a rewrite changes the origin, publicHref is the full URL // Otherwise it's the origin-stripped path // This avoids constructing URL objects in the hot path const hrefOption = getHrefOption(next, router, disabled) return [ hrefOption, !disabled && (!hrefOption || getUrlScheme(hrefOption)) ? undefined : resolveIsActive( location, next, stableActiveOptions, router.basepath, isHydrated, ), ] }, [stableActiveOptions, disabled, isHydrated, _options, dest, router, to], ) // eslint-disable-next-line react-hooks/rules-of-hooks const [href, isActive] = useSelector( router.stores.location, selectLinkState, LINK_SELECTOR_OPTIONS, ) const externalLink = isActive === undefined ? href : undefined const linkDisabled = disabled || href === undefined // eslint-disable-next-line react-hooks/rules-of-hooks const hasRenderFetched = React.useRef(false) const preload = options.reloadDocument || externalLink || linkDisabled ? false : (userPreload ?? router.options.defaultPreload) const preloadDelay = userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0 // `preloadRoute` builds the location itself and only reads the options, so // `_options` goes through as-is. // eslint-disable-next-line react-hooks/rules-of-hooks const enqueuePreload = React.useCallback( (e?: React.MouseEvent | React.FocusEvent | IntersectionObserverEntry) => { const isIntersecting = (e as IntersectionObserverEntry | undefined) ?.isIntersecting if (!(isIntersecting ?? preload === 'intent')) { if (isIntersecting === false) { cancelPreload(innerRef) } return } if (!preloadDelay) { preloadLink(router, _options) return } if (timeoutMap.has(innerRef)) { return } timeoutMap.set( innerRef, setTimeout(() => { timeoutMap.delete(innerRef) preloadLink(router, _options) }, preloadDelay), ) }, [router, _options, innerRef, preload, preloadDelay], ) // Preload side effects: `render` preloads once per link, `viewport` watches // the element. The cleanup also cancels a pending intent timer. // eslint-disable-next-line react-hooks/rules-of-hooks React.useEffect(() => { if (preload === 'render' && !hasRenderFetched.current) { hasRenderFetched.current = true preloadLink(router, _options) } let observer: IntersectionObserver | undefined if ( preload === 'viewport' && innerRef.current && typeof IntersectionObserver === 'function' ) { observer = new IntersectionObserver( (entries) => enqueuePreload(entries.pop()), { rootMargin: '100px' }, ) observer.observe(innerRef.current) } return () => { observer?.disconnect() cancelPreload(innerRef) } }, [router, _options, preload, enqueuePreload, innerRef]) const props = collectElementProps(options, host) props.ref = forwardedRef ? mergedRef : innerRef // External links get no router behavior: element props pass through as given. if (externalLink) { props.href = externalLink return props } // The click handler const handleClick = (e: React.MouseEvent) => { // The element's own target attribute is the fallback. const effectiveTarget = target ?? (e.currentTarget as HTMLAnchorElement | SVGAElement).getAttribute( 'target', ) if ( !linkDisabled && !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && !e.defaultPrevented && (!effectiveTarget || effectiveTarget === '_self') && e.button === 0 ) { e.preventDefault() // All is well? Navigate! // N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing router.navigate({ ..._options, replace, resetScroll, hashScrollIntoView, startTransition, viewTransition, ignoreBlocker, }) } } const handleTouchStart = () => { if (preload === 'intent') { preloadLink(router, _options) } } const handleLeave = () => { if (preload === 'intent') { cancelPreload(innerRef) } } props.onClick = composeHandlers(onClick, handleClick) props.onBlur = composeHandlers(onBlur, handleLeave) props.onFocus = composeHandlers(onFocus, enqueuePreload) props.onMouseEnter = composeHandlers(onMouseEnter, enqueuePreload) props.onMouseLeave = composeHandlers(onMouseLeave, handleLeave) props.onTouchStart = composeHandlers(onTouchStart, handleTouchStart) return applyLinkState(props, options, isActive, href, linkDisabled, host) } const STATIC_EMPTY_OBJECT = {} const STATIC_ACTIVE_OBJECT = { className: 'active' } // Options the router consumes; they never reach the element. Every other // option is an element prop and passes through. const ROUTER_OPTION_KEYS = /* @__PURE__ */ new Set([ 'to', 'params', 'search', 'hash', 'state', 'mask', 'from', 'unsafeRelative', '_fromLocation', 'reloadDocument', 'preload', 'preloadDelay', 'preloadIntentProximity', 'hashScrollIntoView', 'replace', 'startTransition', 'resetScroll', 'viewTransition', 'ignoreBlocker', 'activeProps', 'inactiveProps', 'activeOptions', '_asChild', ]) // Copies the element props. An object rest would test every key against the // whole exclusion list; the key set is much cheaper. `Link` hosts never render // `type`, and an anchor has no `disabled` attribute. function collectElementProps( options: object, host: 'a' | React.ElementType | undefined, ): Record { const props: Record = {} for (const key in options) { if ( ROUTER_OPTION_KEYS.has(key) || (key === 'type' && host !== undefined) || (key === 'disabled' && host === 'a') ) { continue } props[key] = (options as Record)[key] } return props } // Finishes a router-controlled link: the selected state props, then the // routing attributes. This is the one place that defines precedence: state // props override element props, `ref` and handlers; `href`, `disabled`, // `target` and the merged class and style always win. function applyLinkState( props: Record, options: { activeProps?: unknown inactiveProps?: unknown className?: string style?: React.CSSProperties target?: string }, isActive: boolean | undefined, href: string | undefined, linkDisabled: boolean, host: 'a' | React.ElementType | undefined, ): React.ComponentPropsWithRef<'a'> { const { activeProps, inactiveProps, className, style, target } = options const stateProps: React.HTMLAttributes = functionalUpdate((isActive ? activeProps : inactiveProps) as any, {}) ?? (isActive ? STATIC_ACTIVE_OBJECT : STATIC_EMPTY_OBJECT) Object.assign(props, stateProps) props.href = href if (host !== 'a') { props.disabled = linkDisabled } props.target = target // Merge class and style with the state's. Links without either keep their // props as given and carry no `undefined` keys. const stateStyle = stateProps.style if (style || stateStyle) { props.style = style && stateStyle ? { ...style, ...stateStyle } : style || stateStyle } const stateClassName = stateProps.className if (className || stateClassName) { props.className = className ? stateClassName ? `${className} ${stateClassName}` : className : stateClassName } if (linkDisabled) { props.role = 'link' props['aria-disabled'] = true } if (isActive) { props['data-status'] = 'active' props['aria-current'] = 'page' } return props } // Server render of a Link: static props only, no hooks. Only server bundles // keep this function; the `isServer` check that calls it folds away on the client. function getServerLinkProps( router: AnyRouter, options: any, forwardedRef: React.ForwardedRef | undefined, host: 'a' | React.ElementType | undefined, ): React.ComponentPropsWithRef<'a'> { const { to, disabled, activeOptions } = options as { to: string | undefined disabled: boolean | undefined activeOptions: ActiveOptions | undefined } const directExternalLink = resolveExternalLink(to, router.protocolAllowlist) // Direct-scheme links need no route resolution. Blocked links still use // the shared inactive-prop merge so their server and client markup agree. const next = directExternalLink === undefined ? router.buildLocation(options) : undefined const hrefOption = next ? getHrefOption(next, router, disabled) : (directExternalLink ?? undefined) const linkDisabled = disabled || !hrefOption const externalLink = directExternalLink ?? (hrefOption && getUrlScheme(hrefOption) ? hrefOption : undefined) const props = collectElementProps(options, host) props.ref = forwardedRef if (externalLink) { props.href = externalLink return props } const blockedLink = !disabled && !hrefOption // Hash is not available on the server, so it never counts as hydrated. const isActive = !!next && !blockedLink && resolveIsActive( router.stores.location.get(), next, activeOptions, router.basepath, false, ) return applyLinkState( props, options, isActive, hrefOption, linkDisabled, host, ) } const timeoutMap = new WeakMap>() const cancelPreload = (eventTarget: object) => { clearTimeout(timeoutMap.get(eventTarget)) timeoutMap.delete(eventTarget) } export const composeHandlers = ( first: React.EventHandler | undefined, second: React.EventHandler, ) => { if (!first) { return second } // The first guard skips user handlers for already-prevented events; the second // lets user handlers prevent the internal handler from running. return (event: React.SyntheticEvent) => event.defaultPrevented || (first(event), event.defaultPrevented || second(event)) } function getHrefOption( next: ParsedLocation, router: AnyRouter, disabled: boolean | undefined, ) { if (disabled) { return undefined } const location = next.maskedLocation ?? next // A rewritten external URL must bypass history's relative-path formatting. const href = location.external ? location.publicHref : router.history.createHref(location.publicHref) || '/' if ( (location.external || href !== location.publicHref) && isDangerousProtocol(href, router.protocolAllowlist) ) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${href}`) } return undefined } return href } type UseLinkReactProps = TComp extends keyof React.JSX.IntrinsicElements ? React.JSX.IntrinsicElements[TComp] : TComp extends React.ComponentType ? React.ComponentPropsWithoutRef & React.RefAttributes> : never export type UseLinkPropsOptions< TRouter extends AnyRouter = RegisteredRouter, TFrom extends RoutePaths | string = string, TTo extends string | undefined = '.', TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '.', > = ActiveLinkOptions<'a', TRouter, TFrom, TTo, TMaskFrom, TMaskTo> & UseLinkReactProps<'a'> export type ActiveLinkOptions< TComp = 'a', TRouter extends AnyRouter = RegisteredRouter, TFrom extends string = string, TTo extends string | undefined = '.', TMaskFrom extends string = TFrom, TMaskTo extends string = '.', > = LinkOptions & ActiveLinkOptionProps type ActiveLinkProps = Partial< LinkComponentReactProps & { [key: `data-${string}`]: unknown } > export interface ActiveLinkOptionProps { /** * A function that returns additional props for the `active` state of this link. * These props override other props passed to the link (`style`'s are merged, `className`'s are concatenated) */ activeProps?: ActiveLinkProps | (() => ActiveLinkProps) /** * A function that returns additional props for the `inactive` state of this link. * These props override other props passed to the link (`style`'s are merged, `className`'s are concatenated) */ inactiveProps?: ActiveLinkProps | (() => ActiveLinkProps) } export type LinkProps< TComp = 'a', TRouter extends AnyRouter = RegisteredRouter, TFrom extends string = string, TTo extends string | undefined = '.', TMaskFrom extends string = TFrom, TMaskTo extends string = '.', > = ActiveLinkOptions & LinkPropsChildren export interface LinkPropsChildren { // If a function is passed as a child, it will be given the `isActive` boolean to aid in further styling on the element it returns children?: | React.ReactNode | ((state: { isActive: boolean }) => React.ReactNode) } type LinkComponentReactProps = Omit< UseLinkReactProps, keyof CreateLinkProps > export type LinkComponentProps< TComp = 'a', TRouter extends AnyRouter = RegisteredRouter, TFrom extends string = string, TTo extends string | undefined = '.', TMaskFrom extends string = TFrom, TMaskTo extends string = '.', > = LinkComponentReactProps & LinkProps export type CreateLinkProps = LinkProps< any, any, string, string, string, string > export type LinkComponent< in out TComp, in out TDefaultFrom extends string = string, > = < TRouter extends AnyRouter = RegisteredRouter, const TFrom extends string = TDefaultFrom, const TTo extends string | undefined = undefined, const TMaskFrom extends string = TFrom, const TMaskTo extends string = '', >( props: LinkComponentProps, ) => React.ReactElement export interface LinkComponentRoute< in out TDefaultFrom extends string = string, > { defaultFrom: TDefaultFrom; < TRouter extends AnyRouter = RegisteredRouter, const TTo extends string | undefined = undefined, const TMaskTo extends string = '', >( props: LinkComponentProps< 'a', TRouter, this['defaultFrom'], TTo, this['defaultFrom'], TMaskTo >, ): React.ReactElement } /** * Creates a typed Link-like component that preserves TanStack Router's * navigation semantics and type-safety while delegating rendering to the * provided host component. * * Useful for integrating design system anchors/buttons while keeping * router-aware props (eg. `to`, `params`, `search`, `preload`). * * @param Comp The host component to render (eg. a design-system Link/Button) * @returns A router-aware component with the same API as `Link`. * @link https://tanstack.com/router/latest/docs/framework/react/guide/custom-link */ export function createLink( Comp: Constrain ReactNode>, ): LinkComponent { return React.forwardRef(function CreatedLink(props, ref) { return }) as any } /** * A strongly-typed anchor component for declarative navigation. * Handles path, search, hash and state updates with optional route preloading * and active-state styling. * * Props: * - `preload`: Controls route preloading (eg. 'intent', 'render', 'viewport', true/false) * - `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 * - `ignoreBlocker`: Bypass registered blockers * * @returns An anchor-like element that navigates without full page reloads. * @link https://tanstack.com/router/latest/docs/framework/react/api/router/linkComponent */ export const Link: LinkComponent<'a'> = React.memo( React.forwardRef((props, ref) => { const host = props._asChild || 'a' const linkProps = useLinkProps(props as any, ref, host) const children = typeof props.children === 'function' ? props.children({ isActive: (linkProps as any)['data-status'] === 'active', }) : props.children return React.createElement(host, linkProps, children) }), areLinkPropsEqual, ) as any // A Link's output depends only on its props, the router context and the // location store, which React tracks for memoized components, so a parent // re-render with equal props can skip it. Router options are compared by // value: destinations are usually inline object literals. Element props // (`children`, handlers, `style`, ...) are compared by reference only, since // they may hold arbitrary (even cyclic) data. function areLinkPropsEqual( prev: Record, next: Record, ): boolean { let extraKeys = 0 for (const key in next) { extraKeys++ if (prev[key] === next[key]) { continue } if ( !ROUTER_OPTION_KEYS.has(key) || !deepEqual(prev[key], next[key], false, true) ) { return false } } for (const _key in prev) { extraKeys-- } return extraKeys === 0 } export type LinkOptionsFnOptions< TOptions, TComp, TRouter extends AnyRouter = RegisteredRouter, > = TOptions extends ReadonlyArray ? ValidateLinkOptionsArray : ValidateLinkOptions export type LinkOptionsFn = < const TOptions, TRouter extends AnyRouter = RegisteredRouter, >( options: LinkOptionsFnOptions, ) => TOptions /** * Validate and reuse navigation options for `Link`, `navigate` or `redirect`. * Accepts a literal options object and returns it typed for later spreading. * @example * const opts = linkOptions({ to: '/dashboard', search: { tab: 'home' } }) * @link https://tanstack.com/router/latest/docs/framework/react/api/router/linkOptions */ export const linkOptions: LinkOptionsFn<'a'> = (options) => { return options as any } /** * Type-check a literal object for use with `Link`, `navigate` or `redirect`. * Use to validate and reuse navigation options across your app. * @example * const opts = linkOptions({ to: '/dashboard', search: { tab: 'home' } }) * @link https://tanstack.com/router/latest/docs/framework/react/api/router/linkOptions */