// useLinkProps / createLink / linkOptions — port of react-router's link.tsx // (client path; SSR link rendering arrives with the SSR entries). All of Link's // behavior lives here so both `` and custom `createLink` components share // it: href building (mask-aware via publicHref), external-URL detection with // dangerous-protocol blocking, active-state detection (exactPathTest / // trailing-slash-aware fuzzy prefix + deepEqual partial search match), // preloading ('intent' with delay + touchstart/focus, 'viewport' via // IntersectionObserver, 'render' once on mount), the click handler (navigate // with replace/resetScroll/hashScrollIntoView/viewTransition/startTransition/ // ignoreBlocker forwarded), and data-status/data-transitioning reflection. import { useRef, useState, useEffect, useCallback, flushSync, createElement } from 'octane'; import type { ComponentBody } from 'octane'; import { deepEqual, exactPathTest, functionalUpdate, hasKeys, isDangerousProtocol, preloadWarning, removeTrailingSlash, } from '@tanstack/router-core'; import { useRouter } from './context'; import { useStore } from './useStore'; import { splitSlot, subSlot } from './internal'; import { Link } from './Link.tsrx'; import type { AnyRouter, RegisteredRouter } from '@tanstack/router-core'; import type { LinkComponent, OctaneAnchorProps, UseLinkPropsOptions } from './linkTypes'; import type { ValidateLinkOptions, ValidateLinkOptionsArray } from './typePrimitives'; const STATIC_EMPTY_OBJECT = {}; const STATIC_ACTIVE_OBJECT = { class: 'active' }; 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>(); const composeHandlers = (handlers: Array void)>) => (e: Event) => { for (const handler of handlers) { if (!handler) continue; if (e.defaultPrevented) return; handler(e); } }; function getHrefOption( publicHref: string, external: boolean, history: any, disabled: boolean | undefined, ): { href: string; external: boolean } | undefined { if (disabled) return undefined; // Full URL means a rewrite changed the origin — treat as external-like. if (external) return { href: publicHref, external: true }; return { href: history.createHref(publicHref) || '/', external: false }; } function isSafeInternal(to: unknown): boolean { if (typeof to !== 'string') return false; const zero = to.charCodeAt(0); if (zero === 47) return to.charCodeAt(1) !== 47; // '/' but not '//' return zero === 46; // '.', '..', './', '../' } function isCtrlEvent(e: MouseEvent): boolean { return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey); } // Merge base/active/inactive styles. Objects merge like upstream; if any is a // string the parts join with ';' (octane host styles accept both forms). function mergeStyles(base: any, active: any, inactive: any): any { if (!base && !active && !inactive) return undefined; const parts = [base, active, inactive].filter(Boolean); if (parts.length === 1) return parts[0]; if (parts.some((p) => typeof p === 'string')) { return parts .map((p) => typeof p === 'string' ? p.replace(/;\s*$/, '') : Object.entries(p) .map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}:${v}`) .join(';'), ) .join(';'); } return Object.assign({}, ...parts); } 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): OctaneAnchorProps; export function useLinkProps(...args: any[]): Record { const [user, slot] = splitSlot(args); const options = (user[0] ?? {}) as Record; const router = useRouter(); const { // custom props activeProps, inactiveProps, activeOptions, to, preload: userPreload, preloadDelay: userPreloadDelay, preloadIntentProximity: _preloadIntentProximity, hashScrollIntoView, replace, startTransition, resetScroll, viewTransition, // element props children: _children, target, disabled, style, class: klass, className, onClick, onBlur, onFocus, onMouseEnter, onMouseLeave, onTouchStart, ignoreBlocker, ref: userRef, // consumed by buildLocation — not spread onto the element params: _params, search: _search, hash: _hash, state: _state, mask: _mask, reloadDocument: _reloadDocument, unsafeRelative: _unsafeRelative, from: _from, _fromLocation, ...propsSafeToSpread } = options; // Subscribe to the location (by href) — active state re-derives per commit. const currentLocation: any = useStore( router.stores.location, (l: any) => l, (prev: any, next: any) => prev.href === next.href, subSlot(slot, 'lp:loc'), ); const next = router.buildLocation({ _fromLocation: currentLocation, ...options } as any); const hrefOptionPublicHref = next.maskedLocation ? next.maskedLocation.publicHref : next.publicHref; const hrefOptionExternal = next.maskedLocation ? next.maskedLocation.external : next.external; const hrefOption = getHrefOption( hrefOptionPublicHref, hrefOptionExternal, router.history, disabled, ); // External URL detection + dangerous-protocol blocking (javascript:, data:…). const externalLink = (() => { if (hrefOption?.external) { if (isDangerousProtocol(hrefOption.href, router.protocolAllowlist)) return undefined; return hrefOption.href; } if (isSafeInternal(to)) return undefined; if (typeof to !== 'string' || to.indexOf(':') === -1) return undefined; try { new URL(to); if (isDangerousProtocol(to, router.protocolAllowlist)) return undefined; return to; } catch { /* not an absolute URL */ } return undefined; })(); const isActive = (() => { if (externalLink) return false; if (activeOptions?.exact) { if (!exactPathTest(currentLocation.pathname, next.pathname, router.basepath)) { return false; } } else { const currentPathSplit = removeTrailingSlash(currentLocation.pathname, router.basepath); const nextPathSplit = removeTrailingSlash(next.pathname, router.basepath); const pathIsFuzzyEqual = currentPathSplit.startsWith(nextPathSplit) && (currentPathSplit.length === nextPathSplit.length || currentPathSplit[nextPathSplit.length] === '/'); if (!pathIsFuzzyEqual) return false; } if (activeOptions?.includeSearch ?? true) { const searchTest = deepEqual(currentLocation.search, next.search, { partial: !activeOptions?.exact, ignoreUndefined: !activeOptions?.explicitUndefined, }); if (!searchTest) return false; } if (activeOptions?.includeHash) return currentLocation.hash === next.hash; return true; })(); const resolvedActiveProps: Record = isActive ? (functionalUpdate(activeProps, {}) ?? STATIC_ACTIVE_OBJECT) : STATIC_EMPTY_OBJECT; const resolvedInactiveProps: Record = isActive ? STATIC_EMPTY_OBJECT : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT); // Class composes clsx-style (octane normalizeClass folds arrays + falsy). const resolvedClass = [ klass ?? className, resolvedActiveProps.class ?? resolvedActiveProps.className, resolvedInactiveProps.class ?? resolvedInactiveProps.className, ].filter(Boolean); const resolvedStyle = mergeStyles(style, resolvedActiveProps.style, resolvedInactiveProps.style); const [isTransitioning, setIsTransitioning] = useState(false, subSlot(slot, 'lp:t')); const hasRenderFetched = useRef(false, subSlot(slot, 'lp:rf')); const elRef = useRef(null, subSlot(slot, 'lp:el')); const preload = options.reloadDocument || externalLink ? false : (userPreload ?? router.options.defaultPreload); const preloadDelay = userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0; const doPreload = useCallback( () => { router.preloadRoute({ ...options, _builtLocation: next } as any).catch((err: unknown) => { console.warn(err); console.warn(preloadWarning); }); }, [router, next.href], subSlot(slot, 'lp:dp'), ); // preload="viewport": preload when the anchor scrolls into view (100px margin). useEffect( () => { if (disabled || preload !== 'viewport') return; const el = elRef.current; if (!el || typeof IntersectionObserver === 'undefined') return; const io = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) doPreload(); } }, { rootMargin: '100px' }, ); io.observe(el); return () => io.disconnect(); }, [disabled, preload, doPreload], subSlot(slot, 'lp:io'), ); // preload="render": preload once on mount. useEffect( () => { if (hasRenderFetched.current) return; if (!disabled && preload === 'render') { doPreload(); hasRenderFetched.current = true; } }, [disabled, doPreload, preload], subSlot(slot, 'lp:pr'), ); const handleClick = (e: MouseEvent) => { const elementTarget = (e.currentTarget as Element | null)?.getAttribute?.('target'); const effectiveTarget = target !== undefined ? target : elementTarget; if ( !disabled && !isCtrlEvent(e) && !e.defaultPrevented && (!effectiveTarget || effectiveTarget === '_self') && e.button === 0 ) { e.preventDefault(); flushSync(() => { setIsTransitioning(true); }); const unsub = router.subscribe('onResolved', () => { unsub(); setIsTransitioning(false); }); router.navigate({ ...options, replace, resetScroll, hashScrollIntoView, startTransition, viewTransition, ignoreBlocker, }); } }; const captureRef = (el: Element | null) => { elRef.current = el; }; const composedRef = userRef ? [captureRef, userRef] : captureRef; if (externalLink) { return { ...propsSafeToSpread, ref: composedRef, href: externalLink, ...(target !== undefined && { target }), ...(disabled !== undefined && { disabled }), ...(resolvedStyle !== undefined && { style: resolvedStyle }), ...(resolvedClass.length > 0 && { class: resolvedClass }), ...(onClick && { onClick }), ...(onBlur && { onBlur }), ...(onFocus && { onFocus }), ...(onMouseEnter && { onMouseEnter }), ...(onMouseLeave && { onMouseLeave }), ...(onTouchStart && { onTouchStart }), }; } const enqueueIntentPreload = (e: MouseEvent | FocusEvent) => { if (disabled || preload !== 'intent') return; if (!preloadDelay) { doPreload(); return; } const eventTarget = e.currentTarget as EventTarget; if (timeoutMap.has(eventTarget)) return; const id = setTimeout(() => { timeoutMap.delete(eventTarget); doPreload(); }, preloadDelay); timeoutMap.set(eventTarget, id); }; const handleTouchStart = () => { if (disabled || preload !== 'intent') return; doPreload(); }; const handleLeave = (e: MouseEvent | FocusEvent) => { if (disabled || !preload || !preloadDelay) return; const eventTarget = e.currentTarget as EventTarget; const id = timeoutMap.get(eventTarget); if (id) { clearTimeout(id); timeoutMap.delete(eventTarget); } }; return { ...propsSafeToSpread, ...resolvedActiveProps, ...resolvedInactiveProps, href: hrefOption?.href, ref: composedRef, onClick: composeHandlers([onClick, handleClick]), onBlur: composeHandlers([onBlur, handleLeave]), onFocus: composeHandlers([onFocus, enqueueIntentPreload]), onMouseEnter: composeHandlers([onMouseEnter, enqueueIntentPreload]), onMouseLeave: composeHandlers([onMouseLeave, handleLeave]), onTouchStart: composeHandlers([onTouchStart, handleTouchStart]), disabled: !!disabled, ...(target !== undefined && { target }), ...(resolvedStyle !== undefined && { style: resolvedStyle }), ...(resolvedClass.length > 0 && { class: resolvedClass }), ...(disabled && STATIC_DISABLED_PROPS), ...(isActive && STATIC_ACTIVE_PROPS), ...(isTransitioning && STATIC_TRANSITIONING_PROPS), }; } // Wrap a design-system component so it navigates like — the component // receives the fully-built link props (href, handlers, data-status, …). export function createLink>( Comp: TComp, ): LinkComponent; export function createLink(Comp: any): any { return function CreatedLink(props: any) { return createElement(Link as any, { ...props, _asChild: Comp }); }; } export type LinkOptionsFnOptions = TOptions extends ReadonlyArray ? ValidateLinkOptionsArray : ValidateLinkOptions; export type LinkOptionsFn = ( options: LinkOptionsFnOptions, ) => TOptions; // Identity helper for pre-validating and reusing navigation options. export const linkOptions: LinkOptionsFn<'a'> = (options) => options as any;