UNPKG

@tanstack/vue-router

Version:

Modern and scalable routing for Vue applications

512 lines 18.6 kB
import * as Vue from 'vue'; import { deepEqual, exactPathTest, isDangerousProtocol, preloadWarning, removeTrailingSlash, } from '@tanstack/router-core'; import { useRouterState } from './useRouterState'; import { useRouter } from './useRouter'; import { useIntersectionObserver } from './utils'; import { useMatches } from './Matches'; const timeoutMap = new WeakMap(); export function useLinkProps(options) { const router = useRouter(); const isTransitioning = Vue.ref(false); let hasRenderFetched = false; // Ensure router is defined before proceeding if (!router) { console.warn('useRouter must be used inside a <RouterProvider> component!'); return Vue.computed(() => ({})); } // Determine if the link is external or internal const type = Vue.computed(() => { try { new URL(`${options.to}`); return 'external'; } catch { return 'internal'; } }); const buildLocationKey = useRouterState({ select: (s) => { const leaf = s.matches[s.matches.length - 1]; return { search: leaf?.search, hash: s.location.hash, path: leaf?.pathname, // path + params }; }, }); // when `from` is not supplied, use the leaf route of the current matches as the `from` location const from = useMatches({ select: (matches) => options.from ?? matches[matches.length - 1]?.fullPath, }); const _options = Vue.computed(() => ({ ...options, from: from.value, })); const next = Vue.computed(() => { // Depend on search to rebuild when search changes buildLocationKey.value; return router.buildLocation(_options.value); }); const preload = Vue.computed(() => { if (_options.value.reloadDocument) { return false; } return options.preload ?? router.options.defaultPreload; }); const preloadDelay = Vue.computed(() => options.preloadDelay ?? router.options.defaultPreloadDelay ?? 0); const isActive = useRouterState({ select: (s) => { const activeOptions = options.activeOptions; if (activeOptions?.exact) { const testExact = exactPathTest(s.location.pathname, next.value.pathname, router.basepath); if (!testExact) { return false; } } else { const currentPathSplit = removeTrailingSlash(s.location.pathname, router.basepath).split('/'); const nextPathSplit = removeTrailingSlash(next.value?.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.value.search, { partial: !activeOptions?.exact, ignoreUndefined: !activeOptions?.explicitUndefined, }); if (!searchTest) { return false; } } if (activeOptions?.includeHash) { return s.location.hash === next.value.hash; } return true; }, }); const doPreload = () => router.preloadRoute(_options.value).catch((err) => { console.warn(err); console.warn(preloadWarning); }); const preloadViewportIoCallback = (entry) => { if (entry?.isIntersecting) { doPreload(); } }; const ref = Vue.ref(null); useIntersectionObserver(ref, preloadViewportIoCallback, { rootMargin: '100px' }, { disabled: () => !!options.disabled || !(preload.value === 'viewport') }); Vue.effect(() => { if (hasRenderFetched) { return; } if (!options.disabled && preload.value === 'render') { doPreload(); hasRenderFetched = true; } }); // Create safe props that can be spread const getPropsSafeToSpread = () => { const result = {}; const optionRecord = options; for (const key in options) { if (![ 'activeProps', 'inactiveProps', 'activeOptions', 'to', 'preload', 'preloadDelay', 'hashScrollIntoView', 'replace', 'startTransition', 'resetScroll', 'viewTransition', 'children', 'target', 'disabled', 'style', 'class', 'onClick', 'onBlur', 'onFocus', 'onMouseEnter', 'onMouseLeave', 'onMouseOver', 'onMouseOut', 'onTouchStart', 'ignoreBlocker', 'params', 'search', 'hash', 'state', 'mask', 'reloadDocument', '_asChild', 'from', 'additionalProps', ].includes(key)) { result[key] = optionRecord[key]; } } return result; }; if (type.value === 'external') { // Block dangerous protocols like javascript:, blob:, data: if (isDangerousProtocol(options.to, router.protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${options.to}`); } // Return props without href to prevent navigation const safeProps = { ...getPropsSafeToSpread(), ref, // No href attribute - blocks the dangerous protocol target: options.target, disabled: options.disabled, style: options.style, class: options.class, onClick: options.onClick, onBlur: options.onBlur, onFocus: options.onFocus, onMouseEnter: options.onMouseEnter, onMouseLeave: options.onMouseLeave, onMouseOver: options.onMouseOver, onMouseOut: options.onMouseOut, onTouchStart: options.onTouchStart, }; // Remove undefined values Object.keys(safeProps).forEach((key) => { if (safeProps[key] === undefined) { delete safeProps[key]; } }); return Vue.computed(() => safeProps); } // External links just have simple props const externalProps = { ...getPropsSafeToSpread(), ref, href: options.to, target: options.target, disabled: options.disabled, style: options.style, class: options.class, onClick: options.onClick, onBlur: options.onBlur, onFocus: options.onFocus, onMouseEnter: options.onMouseEnter, onMouseLeave: options.onMouseLeave, onMouseOver: options.onMouseOver, onMouseOut: options.onMouseOut, onTouchStart: options.onTouchStart, }; // Remove undefined values Object.keys(externalProps).forEach((key) => { if (externalProps[key] === undefined) { delete externalProps[key]; } }); return Vue.computed(() => externalProps); } // The click handler const handleClick = (e) => { // Check actual element's target attribute as fallback const elementTarget = e.currentTarget?.getAttribute('target'); const effectiveTarget = options.target !== undefined ? options.target : elementTarget; if (!options.disabled && !isCtrlEvent(e) && !e.defaultPrevented && (!effectiveTarget || effectiveTarget === '_self') && e.button === 0) { // Don't prevent default or handle navigation if reloadDocument is true if (_options.value.reloadDocument) { return; } e.preventDefault(); isTransitioning.value = true; const unsub = router.subscribe('onResolved', () => { unsub(); isTransitioning.value = false; }); // All is well? Navigate! router.navigate({ ..._options.value, replace: options.replace, resetScroll: options.resetScroll, hashScrollIntoView: options.hashScrollIntoView, startTransition: options.startTransition, viewTransition: options.viewTransition, ignoreBlocker: options.ignoreBlocker, }); } }; const enqueueIntentPreload = (e) => { if (options.disabled || preload.value !== 'intent') return; if (!preloadDelay.value) { doPreload(); return; } const eventTarget = e.currentTarget || e.target; if (!eventTarget || timeoutMap.has(eventTarget)) return; timeoutMap.set(eventTarget, setTimeout(() => { timeoutMap.delete(eventTarget); doPreload(); }, preloadDelay.value)); }; const handleTouchStart = (_) => { if (options.disabled || preload.value !== 'intent') return; doPreload(); }; const handleLeave = (e) => { if (options.disabled) return; const eventTarget = e.currentTarget || e.target; if (eventTarget) { const id = timeoutMap.get(eventTarget); clearTimeout(id); timeoutMap.delete(eventTarget); } }; // Helper to compose event handlers - with explicit return type and better type handling function composeEventHandlers(handlers) { return (event) => { for (const handler of handlers) { if (handler) { handler(event); } } }; } // Get the active and inactive props const resolvedActiveProps = Vue.computed(() => { const activeProps = options.activeProps || (() => ({ class: 'active' })); const props = isActive.value ? typeof activeProps === 'function' ? activeProps() : activeProps : {}; return props || { class: undefined, style: undefined }; }); const resolvedInactiveProps = Vue.computed(() => { const inactiveProps = options.inactiveProps || (() => ({})); const props = isActive.value ? {} : typeof inactiveProps === 'function' ? inactiveProps() : inactiveProps; return props || { class: undefined, style: undefined }; }); const resolvedClassName = Vue.computed(() => { const classes = [ options.class, resolvedActiveProps.value?.class, resolvedInactiveProps.value?.class, ].filter(Boolean); return classes.length ? classes.join(' ') : undefined; }); const resolvedStyle = Vue.computed(() => { const result = {}; // Merge styles from all sources if (options.style) { Object.assign(result, options.style); } if (resolvedActiveProps.value?.style) { Object.assign(result, resolvedActiveProps.value.style); } if (resolvedInactiveProps.value?.style) { Object.assign(result, resolvedInactiveProps.value.style); } return Object.keys(result).length > 0 ? result : undefined; }); const href = Vue.computed(() => { if (options.disabled) { return undefined; } const nextLocation = next.value; const location = nextLocation?.maskedLocation ?? nextLocation; // 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 publicHref = location?.publicHref; if (!publicHref) return undefined; const external = location?.external; if (external) return publicHref; return router.history.createHref(publicHref) || '/'; }); // Create static event handlers that don't change between renders const staticEventHandlers = { onClick: composeEventHandlers([ options.onClick, handleClick, ]), onBlur: composeEventHandlers([ options.onBlur, handleLeave, ]), onFocus: composeEventHandlers([ options.onFocus, enqueueIntentPreload, ]), onMouseenter: composeEventHandlers([ options.onMouseEnter, enqueueIntentPreload, ]), onMouseover: composeEventHandlers([ options.onMouseOver, enqueueIntentPreload, ]), onMouseleave: composeEventHandlers([ options.onMouseLeave, handleLeave, ]), onMouseout: composeEventHandlers([ options.onMouseOut, handleLeave, ]), onTouchstart: composeEventHandlers([ options.onTouchStart, handleTouchStart, ]), }; // Compute all props synchronously to avoid hydration mismatches // Using Vue.computed ensures props are calculated at render time, not after const computedProps = Vue.computed(() => { const result = { ...getPropsSafeToSpread(), href: href.value, ref, ...staticEventHandlers, disabled: !!options.disabled, target: options.target, }; // Add style if present if (resolvedStyle.value) { result.style = resolvedStyle.value; } // Add class if present if (resolvedClassName.value) { result.class = resolvedClassName.value; } // Add disabled props if (options.disabled) { result.role = 'link'; result['aria-disabled'] = true; } // Add active status if (isActive.value) { result['data-status'] = 'active'; result['aria-current'] = 'page'; } // Add transitioning status if (isTransitioning.value) { result['data-transitioning'] = 'transitioning'; } // Merge active/inactive props (excluding class and style which are handled above) const activeP = resolvedActiveProps.value; const inactiveP = resolvedInactiveProps.value; for (const key of Object.keys(activeP)) { if (key !== 'class' && key !== 'style') { result[key] = activeP[key]; } } for (const key of Object.keys(inactiveP)) { if (key !== 'class' && key !== 'style') { result[key] = inactiveP[key]; } } return result; }); // Return the computed ref itself - callers should access .value return computedProps; } export function createLink(Comp) { return Vue.defineComponent({ name: 'CreatedLink', inheritAttrs: false, setup(_, { attrs, slots }) { return () => Vue.h(LinkImpl, { ...attrs, _asChild: Comp }, slots); }, }); } const LinkImpl = Vue.defineComponent({ name: 'Link', inheritAttrs: false, props: [ '_asChild', 'to', 'preload', 'preloadDelay', 'activeProps', 'inactiveProps', 'activeOptions', 'from', 'search', 'params', 'hash', 'state', 'mask', 'reloadDocument', 'disabled', 'additionalProps', 'viewTransition', 'resetScroll', 'startTransition', 'hashScrollIntoView', 'replace', 'ignoreBlocker', 'target', ], setup(props, { attrs, slots }) { // Call useLinkProps ONCE during setup with combined props and attrs // The returned object is a computed ref that updates reactively const allProps = { ...props, ...attrs }; const linkPropsComputed = useLinkProps(allProps); return () => { const Component = props._asChild || 'a'; // Access the computed value to get fresh props each render const linkProps = linkPropsComputed.value; const isActive = linkProps['data-status'] === 'active'; const isTransitioning = linkProps['data-transitioning'] === 'transitioning'; // Create the slot content or empty array if no default slot const slotContent = slots.default ? slots.default({ isActive, isTransitioning, }) : []; // Special handling for SVG links - wrap an <a> inside the SVG if (Component === 'svg') { // Create props without class for svg link const svgLinkProps = { ...linkProps }; delete svgLinkProps.class; return Vue.h('svg', {}, [Vue.h('a', svgLinkProps, slotContent)]); } // For custom functional components (non-string), pass children as a prop // since they may expect children as a prop like in Solid if (typeof Component !== 'string') { return Vue.h(Component, { ...linkProps, children: slotContent }, slotContent); } // Return the component with props and children return Vue.h(Component, linkProps, slotContent); }; }, }); /** * Link component with proper TypeScript generics support */ export const Link = LinkImpl; function isCtrlEvent(e) { return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey); } export const linkOptions = (options) => { return options; }; //# sourceMappingURL=link.jsx.map