UNPKG

@tanstack/vue-router

Version:

Modern and scalable routing for Vue applications

515 lines 19.6 kB
import * as Vue from 'vue'; import { deepEqual, exactPathTest, hasKeys, isDangerousProtocol, preloadWarning, removeTrailingSlash, } from '@tanstack/router-core'; import { isServer } from '@tanstack/router-core/isServer'; import { useStore } from '@tanstack/vue-store'; import { useRouter } from './useRouter'; import { useIntersectionObserver } from './utils'; 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 ref = Vue.ref(null); const eventHandlers = getLinkEventHandlers(options); 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(options), 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: eventHandlers.onMouseenter, onMouseleave: eventHandlers.onMouseleave, onMouseover: eventHandlers.onMouseover, onMouseout: eventHandlers.onMouseout, onTouchstart: eventHandlers.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(options), 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: eventHandlers.onMouseenter, onMouseleave: eventHandlers.onMouseleave, onMouseover: eventHandlers.onMouseover, onMouseout: eventHandlers.onMouseout, onTouchstart: eventHandlers.onTouchstart, }; // Remove undefined values Object.keys(externalProps).forEach((key) => { if (externalProps[key] === undefined) { delete externalProps[key]; } }); return Vue.computed(() => externalProps); } // During SSR we render exactly once and do not need reactivity. // Avoid store subscriptions, effects and observers on the server. if (isServer ?? router.isServer) { const next = router.buildLocation(options); const href = getHref({ options: options, router, nextLocation: next, }); const isActive = getIsActive({ loc: router.stores.location.get(), nextLoc: next, activeOptions: options.activeOptions, router, }); const { resolvedActiveProps, resolvedInactiveProps, resolvedClassName, resolvedStyle, } = resolveStyleProps({ options: options, isActive, }); const result = combineResultProps({ href, options: options, isActive, isTransitioning: false, resolvedActiveProps, resolvedInactiveProps, resolvedClassName, resolvedStyle, }); return Vue.ref(result); } const currentLocation = useStore(router.stores.location, (l) => l, { equal: (prev, next) => prev.href === next.href, }); const next = Vue.computed(() => { // Rebuild when inherited search/hash or the current route context changes. const opts = { _fromLocation: currentLocation.value, ...options }; return router.buildLocation(opts); }); const preload = Vue.computed(() => { if (options.reloadDocument) { return false; } return options.preload ?? router.options.defaultPreload; }); const preloadDelay = Vue.computed(() => options.preloadDelay ?? router.options.defaultPreloadDelay ?? 0); const isActive = Vue.computed(() => getIsActive({ activeOptions: options.activeOptions, loc: currentLocation.value, nextLoc: next.value, router, })); const doPreload = () => router .preloadRoute({ ...options, _builtLocation: next.value }) .catch((err) => { console.warn(err); console.warn(preloadWarning); }); const preloadViewportIoCallback = (entry) => { if (entry?.isIntersecting) { doPreload(); } }; 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; } }); // 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.reloadDocument) { return; } e.preventDefault(); isTransitioning.value = true; const unsub = router.subscribe('onResolved', () => { unsub(); isTransitioning.value = false; }); // All is well? Navigate! router.navigate({ ...options, 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 resolvedStyleProps = Vue.computed(() => resolveStyleProps({ options: options, isActive: isActive.value, })); const href = Vue.computed(() => getHref({ options: options, router, nextLocation: next.value, })); // 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([ eventHandlers.onMouseenter, enqueueIntentPreload, ]), onMouseover: composeEventHandlers([ eventHandlers.onMouseover, enqueueIntentPreload, ]), onMouseleave: composeEventHandlers([ eventHandlers.onMouseleave, handleLeave, ]), onMouseout: composeEventHandlers([ eventHandlers.onMouseout, handleLeave, ]), onTouchstart: composeEventHandlers([ eventHandlers.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 { resolvedActiveProps, resolvedInactiveProps, resolvedClassName, resolvedStyle, } = resolvedStyleProps.value; return combineResultProps({ href: href.value, options: options, ref, staticEventHandlers, isActive: isActive.value, isTransitioning: isTransitioning.value, resolvedActiveProps, resolvedInactiveProps, resolvedClassName, resolvedStyle, }); }); // Return the computed ref itself - callers should access .value return computedProps; } function resolveStyleProps({ options, isActive, }) { const activeProps = options.activeProps || (() => ({ class: 'active' })); const resolvedActiveProps = (isActive ? typeof activeProps === 'function' ? activeProps() : activeProps : {}) || { class: undefined, style: undefined }; const inactiveProps = options.inactiveProps || (() => ({})); const resolvedInactiveProps = (isActive ? {} : typeof inactiveProps === 'function' ? inactiveProps() : inactiveProps) || { class: undefined, style: undefined }; const classes = [ options.class, resolvedActiveProps?.class, resolvedInactiveProps?.class, ].filter(Boolean); const resolvedClassName = classes.length ? classes.join(' ') : undefined; const result = {}; // Merge styles from all sources if (options.style) { Object.assign(result, options.style); } if (resolvedActiveProps?.style) { Object.assign(result, resolvedActiveProps.style); } if (resolvedInactiveProps?.style) { Object.assign(result, resolvedInactiveProps.style); } const resolvedStyle = hasKeys(result) ? result : undefined; return { resolvedActiveProps, resolvedInactiveProps, resolvedClassName, resolvedStyle, }; } function combineResultProps({ href, options, isActive, isTransitioning, resolvedActiveProps, resolvedInactiveProps, resolvedClassName, resolvedStyle, ref, staticEventHandlers, }) { const result = { ...getPropsSafeToSpread(options), ref, ...staticEventHandlers, href, disabled: !!options.disabled, target: options.target, }; if (resolvedStyle) { result.style = resolvedStyle; } if (resolvedClassName) { result.class = resolvedClassName; } if (options.disabled) { result.role = 'link'; result['aria-disabled'] = true; } if (isActive) { result['data-status'] = 'active'; result['aria-current'] = 'page'; } if (isTransitioning) { result['data-transitioning'] = 'transitioning'; } for (const key of Object.keys(resolvedActiveProps)) { if (key !== 'class' && key !== 'style') { result[key] = resolvedActiveProps[key]; } } for (const key of Object.keys(resolvedInactiveProps)) { if (key !== 'class' && key !== 'style') { result[key] = resolvedInactiveProps[key]; } } return result; } function getLinkEventHandlers(options) { return { onMouseenter: options.onMouseEnter ?? options.onMouseenter, onMouseleave: options.onMouseLeave ?? options.onMouseleave, onMouseover: options.onMouseOver ?? options.onMouseover, onMouseout: options.onMouseOut ?? options.onMouseout, onTouchstart: options.onTouchStart ?? options.onTouchstart, }; } const getPropsSafeToSpread = (options) => { const { activeProps: _activeProps, inactiveProps: _inactiveProps, activeOptions: _activeOptions, to: _to, preload: _preload, preloadDelay: _preloadDelay, preloadIntentProximity: _preloadIntentProximity, hashScrollIntoView: _hashScrollIntoView, replace: _replace, startTransition: _startTransition, resetScroll: _resetScroll, viewTransition: _viewTransition, children: _children, target: _target, disabled: _disabled, style: _style, class: _class, onClick: _onClick, onBlur: _onBlur, onFocus: _onFocus, onMouseEnter: _onMouseEnter, onMouseenter: _onMouseenter, onMouseLeave: _onMouseLeave, onMouseleave: _onMouseleave, onMouseOver: _onMouseOver, onMouseover: _onMouseover, onMouseOut: _onMouseOut, onMouseout: _onMouseout, onTouchStart: _onTouchStart, onTouchstart: _onTouchstart, ignoreBlocker: _ignoreBlocker, params: _params, search: _search, hash: _hash, state: _state, mask: _mask, reloadDocument: _reloadDocument, unsafeRelative: _unsafeRelative, _asChild: __asChild, from: _from, additionalProps: _additionalProps, ...propsSafeToSpread } = options; return propsSafeToSpread; }; function getIsActive({ activeOptions, loc, nextLoc, router, }) { if (activeOptions?.exact) { const testExact = exactPathTest(loc.pathname, nextLoc.pathname, router.basepath); if (!testExact) { return false; } } else { const currentPath = removeTrailingSlash(loc.pathname, router.basepath); const nextPath = removeTrailingSlash(nextLoc.pathname, router.basepath); const pathIsFuzzyEqual = currentPath.startsWith(nextPath) && (currentPath.length === nextPath.length || currentPath[nextPath.length] === '/'); if (!pathIsFuzzyEqual) { return false; } } if (activeOptions?.includeSearch ?? true) { const searchTest = deepEqual(loc.search, nextLoc.search, { partial: !activeOptions?.exact, ignoreUndefined: !activeOptions?.explicitUndefined, }); if (!searchTest) { return false; } } if (activeOptions?.includeHash) { return loc.hash === nextLoc.hash; } return true; } function getHref({ options, router, nextLocation, }) { if (options.disabled) { return undefined; } 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) || '/'; } 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', 'preloadIntentProximity', '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 const allProps = { ...props, ...attrs }; const linkPropsSource = useLinkProps(allProps); return () => { const Component = props._asChild || 'a'; const linkProps = Vue.unref(linkPropsSource); 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