import * as React from 'react' import { flushSync } from 'react-dom' import { deepEqual, exactPathTest, functionalUpdate, preloadWarning, removeTrailingSlash, } from '@tanstack/router-core' import { useRouterState } from './useRouterState' import { useRouter } from './useRouter' import { useForwardedRef, useIntersectionObserver, useLayoutEffect, } from './utils' import { useMatches } from './Matches' import type { AnyRouter, Constrain, LinkCurrentTargetElement, LinkOptions, RegisteredRouter, RoutePaths, } from '@tanstack/router-core' import type { ReactNode } from 'react' import type { ValidateLinkOptions, ValidateLinkOptionsArray, } from './typePrimitives' 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'> { const router = useRouter() const [isTransitioning, setIsTransitioning] = React.useState(false) const hasRenderFetched = React.useRef(false) const innerRef = useForwardedRef(forwardedRef) const { // custom props activeProps = () => ({ className: 'active' }), inactiveProps = () => ({}), activeOptions, to, preload: userPreload, preloadDelay: userPreloadDelay, hashScrollIntoView, replace, startTransition, resetScroll, viewTransition, // element props children, target, disabled, style, className, onClick, onFocus, onMouseEnter, onMouseLeave, onTouchStart, ignoreBlocker, ...rest } = options const { // prevent these from being returned params: _params, search: _search, hash: _hash, state: _state, mask: _mask, reloadDocument: _reloadDocument, ...propsSafeToSpread } = rest // If this link simply reloads the current route, // make sure it has a new key so it will trigger a data refresh // If this `to` is a valid external URL, return // null for LinkUtils const type: 'internal' | 'external' = React.useMemo(() => { try { new URL(`${to}`) return 'external' } catch {} return 'internal' }, [to]) // subscribe to search params to re-build location if it changes const currentSearch = useRouterState({ select: (s) => s.location.search, structuralSharing: true as any, }) // when `from` is not supplied, use the leaf route of the current matches as the `from` location // so relative routing works as expected const from = useMatches({ select: (matches) => options.from ?? matches[matches.length - 1]?.fullPath, }) // Use it as the default `from` location const _options = React.useMemo(() => ({ ...options, from }), [options, from]) const next = React.useMemo( () => router.buildLocation(_options as any), // eslint-disable-next-line react-hooks/exhaustive-deps [router, _options, currentSearch], ) const preload = React.useMemo(() => { if (_options.reloadDocument) { return false } return userPreload ?? router.options.defaultPreload }, [router.options.defaultPreload, userPreload, _options.reloadDocument]) const preloadDelay = userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0 const isActive = useRouterState({ select: (s) => { if (activeOptions?.exact) { const testExact = exactPathTest( s.location.pathname, next.pathname, router.basepath, ) if (!testExact) { return false } } else { const currentPathSplit = removeTrailingSlash( s.location.pathname, router.basepath, ).split('/') const nextPathSplit = removeTrailingSlash( next.pathname, router.basepath, ).split('/') const pathIsFuzzyEqual = nextPathSplit.every( (d, i) => d === currentPathSplit[i], ) if (!pathIsFuzzyEqual) { return false } } if (activeOptions?.includeSearch ?? true) { const searchTest = deepEqual(s.location.search, next.search, { partial: !activeOptions?.exact, ignoreUndefined: !activeOptions?.explicitUndefined, }) if (!searchTest) { return false } } if (activeOptions?.includeHash) { return s.location.hash === next.hash } return true }, }) const doPreload = React.useCallback(() => { router.preloadRoute(_options as any).catch((err) => { console.warn(err) console.warn(preloadWarning) }) }, [_options, router]) const preloadViewportIoCallback = React.useCallback( (entry: IntersectionObserverEntry | undefined) => { if (entry?.isIntersecting) { doPreload() } }, [doPreload], ) useIntersectionObserver( innerRef, preloadViewportIoCallback, { rootMargin: '100px' }, { disabled: !!disabled || !(preload === 'viewport') }, ) useLayoutEffect(() => { if (hasRenderFetched.current) { return } if (!disabled && preload === 'render') { doPreload() hasRenderFetched.current = true } }, [disabled, doPreload, preload]) if (type === 'external') { return { ...propsSafeToSpread, ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], type, href: to, ...(children && { children }), ...(target && { target }), ...(disabled && { disabled }), ...(style && { style }), ...(className && { className }), ...(onClick && { onClick }), ...(onFocus && { onFocus }), ...(onMouseEnter && { onMouseEnter }), ...(onMouseLeave && { onMouseLeave }), ...(onTouchStart && { onTouchStart }), } } // The click handler const handleClick = (e: MouseEvent) => { if ( !disabled && !isCtrlEvent(e) && !e.defaultPrevented && (!target || target === '_self') && e.button === 0 ) { e.preventDefault() flushSync(() => { setIsTransitioning(true) }) const unsub = router.subscribe('onResolved', () => { unsub() setIsTransitioning(false) }) // All is well? Navigate! // N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing return router.navigate({ ..._options, replace, resetScroll, hashScrollIntoView, startTransition, viewTransition, ignoreBlocker, } as any) } } // The click handler const handleFocus = (_: MouseEvent) => { if (disabled) return if (preload) { doPreload() } } const handleTouchStart = handleFocus const handleEnter = (e: MouseEvent) => { if (disabled) return const eventTarget = (e.target || {}) as LinkCurrentTargetElement if (preload) { if (eventTarget.preloadTimeout) { return } eventTarget.preloadTimeout = setTimeout(() => { eventTarget.preloadTimeout = null doPreload() }, preloadDelay) } } const handleLeave = (e: MouseEvent) => { if (disabled) return const eventTarget = (e.target || {}) as LinkCurrentTargetElement if (eventTarget.preloadTimeout) { clearTimeout(eventTarget.preloadTimeout) eventTarget.preloadTimeout = null } } const composeHandlers = (handlers: Array void)>) => (e: { persist?: () => void; defaultPrevented: boolean }) => { e.persist?.() handlers.filter(Boolean).forEach((handler) => { if (e.defaultPrevented) return handler!(e) }) } // Get the active props const resolvedActiveProps: React.HTMLAttributes = isActive ? (functionalUpdate(activeProps as any, {}) ?? {}) : {} // Get the inactive props const resolvedInactiveProps: React.HTMLAttributes = isActive ? {} : functionalUpdate(inactiveProps, {}) const resolvedClassName = [ className, resolvedActiveProps.className, resolvedInactiveProps.className, ] .filter(Boolean) .join(' ') const resolvedStyle = { ...style, ...resolvedActiveProps.style, ...resolvedInactiveProps.style, } return { ...propsSafeToSpread, ...resolvedActiveProps, ...resolvedInactiveProps, href: disabled ? undefined : 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]), onMouseLeave: composeHandlers([onMouseLeave, handleLeave]), onTouchStart: composeHandlers([onTouchStart, handleTouchStart]), disabled: !!disabled, target, ...(Object.keys(resolvedStyle).length && { style: resolvedStyle }), ...(resolvedClassName && { className: resolvedClassName }), ...(disabled && { role: 'link', 'aria-disabled': true, }), ...(isActive && { 'data-status': 'active', 'aria-current': 'page' }), ...(isTransitioning && { 'data-transitioning': 'transitioning' }), } } type UseLinkReactProps = TComp extends keyof React.JSX.IntrinsicElements ? React.JSX.IntrinsicElements[TComp] : React.PropsWithoutRef< TComp extends React.ComponentType ? TProps : never > & React.RefAttributes< TComp extends | React.FC<{ ref: infer TRef }> | React.Component<{ ref: infer TRef }> ? TRef : 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 isTransitioning: 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 = < TRouter extends AnyRouter = RegisteredRouter, const TFrom extends string = string, const TTo extends string | undefined = undefined, const TMaskFrom extends string = TFrom, const TMaskTo extends string = '', >( props: LinkComponentProps, ) => React.ReactElement export function createLink( Comp: Constrain ReactNode>, ): LinkComponent { return React.forwardRef(function CreatedLink(props, ref) { return }) as any } export const Link: LinkComponent<'a'> = React.forwardRef( (props, ref) => { const { _asChild, ...rest } = props const { type: _type, ref: innerRef, ...linkProps } = useLinkProps(rest as any, 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 element, not a general component that has a disabled prop // @ts-expect-error delete linkProps.disabled } 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) } 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 export const linkOptions: LinkOptionsFn<'a'> = (options) => { return options as any }