UNPKG

@tanstack/vue-router

Version:

Modern and scalable routing for Vue applications

489 lines 20.2 kB
import * as Vue from 'vue'; import { deepEqual, getUrlScheme, hasKeys, isDangerousProtocol, preloadWarning, removeTrailingSlash, } from '@tanstack/router-core'; import { isServer } from '@tanstack/router-core/isServer'; import { useSelector } from '@tanstack/vue-store'; import { useRouter } from './useRouter'; import { useIntersectionObserver } from './utils'; const timeoutMap = new WeakMap(); export function useLinkProps(options) { return useLinkPropsImpl(() => options); } function useLinkPropsImpl(getOptions) { const router = useRouter(); let renderFetchedHref; // Ensure router is defined before proceeding if (!router) { console.warn('useRouter must be used inside a <RouterProvider> component!'); return Vue.computed(() => ({})); } const ref = Vue.ref(null); // 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 options = getOptions(); if (getUrlScheme(`${options.to}`)) { return Vue.ref(getExternalLinkProps(options, router, ref)); } const next = router.buildLocation(options); const href = getHref(options, router, next); const isActive = !options.disabled && (href === undefined || !!getUrlScheme(href)) ? false : getIsActive(router.stores.location.get(), next, options.activeOptions, router); const { resolvedProps, resolvedClass, resolvedStyle } = resolveStyleProps(options, isActive); const result = combineResultProps({ href, options, isActive, resolvedProps, resolvedClass, resolvedStyle, }); return Vue.ref(result); } // Determine if the link is external or internal. This is client-only so // server renders do not allocate a computed wrapper for every link. const isExternal = Vue.computed(() => !!getUrlScheme(`${getOptions().to}`)); const currentLocation = isExternal.value ? Vue.shallowRef(router.stores.location.get()) : useSelector(router.stores.location, (l) => l, { compare: (prev, next) => prev.href === next.href, }); // Links that start external skip useSelector above. Subscribe if they later // become internal so active state follows subsequent location changes. if (isExternal.value) { Vue.watchEffect((onCleanup) => { if (isExternal.value) { return; } const store = router.stores.location; // Catch up on navigations while this external link was unsubscribed. currentLocation.value = store.get(); const subscription = store.subscribe((location) => { if (currentLocation.value.href !== location.href) { currentLocation.value = location; } }); onCleanup(() => subscription.unsubscribe()); }); } const next = Vue.computed(() => { // Rebuild when inherited search/hash or the current route context changes. const options = getOptions(); const opts = { _fromLocation: currentLocation.value, ...options }; return router.buildLocation(opts); }); const href = Vue.computed(() => { const options = getOptions(); return getHref(options, router, next.value); }); const preload = Vue.computed(() => { const options = getOptions(); if (isExternal.value || (!options.disabled && (href.value === undefined || !!getUrlScheme(href.value))) || options.reloadDocument || options.disabled) { return false; } return options.preload ?? router.options.defaultPreload; }); const preloadDelay = Vue.computed(() => getOptions().preloadDelay ?? router.options.defaultPreloadDelay ?? 0); const isActive = Vue.computed(() => { const options = getOptions(); if (isExternal.value || (!options.disabled && (href.value === undefined || !!getUrlScheme(href.value)))) { return false; } return getIsActive(currentLocation.value, next.value, options.activeOptions, router); }); const doPreload = () => { const options = getOptions(); return router .preloadRoute(options) .catch((err) => { console.warn(err); console.warn(preloadWarning); }); }; let pendingPreload; const enqueuePreload = (e) => { if (!e) { clearTimeout(timeoutMap.get(ref)); timeoutMap.delete(ref); pendingPreload = undefined; return; } const isIntersecting = e.isIntersecting; const preloadMode = isIntersecting === undefined ? 'intent' : 'viewport'; if (preload.value !== preloadMode || isIntersecting === false) { if (isIntersecting === false && pendingPreload === 'viewport') { clearTimeout(timeoutMap.get(ref)); timeoutMap.delete(ref); pendingPreload = undefined; } return; } if (!preloadDelay.value) { doPreload(); return; } if (!timeoutMap.has(ref)) { const scheduledHref = next.value.href; pendingPreload = preloadMode; timeoutMap.set(ref, setTimeout(() => { timeoutMap.delete(ref); pendingPreload = undefined; if (preload.value === preloadMode && next.value.href === scheduledHref) { doPreload(); } }, preloadDelay.value)); } }; useIntersectionObserver(ref, enqueuePreload, () => preload.value !== 'viewport'); Vue.watchEffect(() => { if (preload.value !== 'render') { return; } const nextHref = next.value.href; if (nextHref && renderFetchedHref !== nextHref) { renderFetchedHref = nextHref; doPreload(); } }); // The click handler const handleClick = (e) => { const options = getOptions(); if (isExternal.value || (!options.disabled && (href.value === undefined || !!getUrlScheme(href.value)))) { return; } // 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 && !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && !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(); // 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 handleTouchStart = () => { if (preload.value === 'intent') { doPreload(); } }; const handleLeave = () => { if (pendingPreload === 'intent') { clearTimeout(timeoutMap.get(ref)); timeoutMap.delete(ref); pendingPreload = undefined; } }; function composeEventHandlers(getUserHandler, handler) { return (event) => { getUserHandler()?.(event); handler(event); }; } // Get the active and inactive props const resolvedStyleProps = Vue.computed(() => { const options = getOptions(); return resolveStyleProps(options, isActive.value); }); // Create static event handlers that don't change between renders const staticEventHandlers = { onClick: composeEventHandlers(() => getOptions().onClick, handleClick), onBlur: composeEventHandlers(() => getOptions().onBlur, handleLeave), onFocus: composeEventHandlers(() => getOptions().onFocus, enqueuePreload), onMouseenter: composeEventHandlers(() => getLinkEventHandlers(getOptions()).onMouseenter, enqueuePreload), onMouseover: composeEventHandlers(() => getLinkEventHandlers(getOptions()).onMouseover, enqueuePreload), onMouseleave: composeEventHandlers(() => getLinkEventHandlers(getOptions()).onMouseleave, handleLeave), onMouseout: composeEventHandlers(() => getLinkEventHandlers(getOptions()).onMouseout, handleLeave), onTouchstart: composeEventHandlers(() => getLinkEventHandlers(getOptions()).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 options = getOptions(); if (isExternal.value) { return getExternalLinkProps(options, router, ref, staticEventHandlers); } const { resolvedProps, resolvedClass, resolvedStyle } = resolvedStyleProps.value; return combineResultProps({ href: href.value, options, ref, staticEventHandlers, isActive: isActive.value, resolvedProps, resolvedClass, resolvedStyle, }); }); // Return the computed ref itself - callers should access .value return computedProps; } function resolveStyleProps(options, isActive) { const props = (isActive ? options.activeProps : options.inactiveProps) || (isActive ? STATIC_ACTIVE_PROPS : EMPTY_OBJECT); const resolvedProps = (typeof props === 'function' ? props() : props) || EMPTY_OBJECT; const baseClass = options.class; const stateClass = resolvedProps.class; const resolvedClass = baseClass ? stateClass ? [baseClass, stateClass] : baseClass : stateClass ? stateClass : undefined; const baseStyle = options.style; const stateStyle = resolvedProps.style; let resolvedStyle; if (baseStyle || stateStyle) { // Keep a snapshot of reactive styles rather than returning their proxy. const style = {}; Object.assign(style, baseStyle, stateStyle); if (hasKeys(style)) { resolvedStyle = style; } } return { resolvedProps, resolvedClass, resolvedStyle, }; } const STATIC_ACTIVE_PROPS = { class: 'active' }; const EMPTY_OBJECT = {}; function combineResultProps({ href, options, isActive, resolvedProps, resolvedClass, resolvedStyle, ref, staticEventHandlers, }) { const disabled = options.disabled || href === undefined; const result = { ...getPropsSafeToSpread(options), ref, ...staticEventHandlers, disabled: options._asChild ? disabled : undefined, target: options.target, }; if (resolvedStyle) { result.style = resolvedStyle; } if (resolvedClass) { result.class = resolvedClass; } if (disabled) { result.role = 'link'; result['aria-disabled'] = true; } if (isActive) { result['data-status'] = 'active'; result['aria-current'] = 'page'; } for (const key of Object.keys(resolvedProps)) { if (key !== 'class' && key !== 'style') { result[key] = resolvedProps[key]; } } result.href = href; return result; } function getExternalLinkProps(options, router, ref, staticEventHandlers) { const dangerous = isDangerousProtocol(options.to, router.protocolAllowlist); const disabled = options.disabled || dangerous; if (process.env.NODE_ENV !== 'production' && dangerous) { console.warn(`Blocked Link with dangerous protocol: ${options.to}`); } const eventHandlers = getLinkEventHandlers(options); const result = { ...getPropsSafeToSpread(options), ref, href: disabled ? undefined : options.to, target: options.target, disabled: options._asChild ? disabled : undefined, style: options.style, class: options.class, onClick: staticEventHandlers?.onClick ?? options.onClick, onBlur: staticEventHandlers?.onBlur ?? options.onBlur, onFocus: staticEventHandlers?.onFocus ?? options.onFocus, onMouseenter: staticEventHandlers?.onMouseenter ?? eventHandlers.onMouseenter, onMouseleave: staticEventHandlers?.onMouseleave ?? eventHandlers.onMouseleave, onMouseover: staticEventHandlers?.onMouseover ?? eventHandlers.onMouseover, onMouseout: staticEventHandlers?.onMouseout ?? eventHandlers.onMouseout, onTouchstart: staticEventHandlers?.onTouchstart ?? eventHandlers.onTouchstart, }; if (disabled) { result.role = 'link'; result['aria-disabled'] = true; } for (const key of Object.keys(result)) { if (result[key] === undefined) { delete result[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(loc, nextLoc, activeOptions, router) { const currentPath = removeTrailingSlash(loc.pathname, router.basepath); const nextPath = removeTrailingSlash(nextLoc.pathname, router.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(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 href = location?.external ? publicHref : router.history.createHref(publicHref) || '/'; if ((location?.external || href !== publicHref) && isDangerousProtocol(href, router.protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${href}`); } return undefined; } return href; } 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 }) { const attrsSnapshot = Vue.shallowRef({ ...attrs }); Vue.onBeforeUpdate(() => { const keys = Object.keys(attrs); const previous = attrsSnapshot.value; if (keys.length !== Object.keys(previous).length || keys.some((key) => !Object.is(attrs[key], previous[key]))) { attrsSnapshot.value = { ...attrs }; } }); // Keep a plain cached snapshot so location-only updates do not repeatedly // cross Vue's props and attrs proxies for every link computation. const allProps = Vue.computed(() => ({ ...props, ...attrsSnapshot.value, })); const linkPropsSource = useLinkPropsImpl(() => allProps.value); return () => { const Component = props._asChild || 'a'; const linkProps = Vue.unref(linkPropsSource); const isActive = linkProps['data-status'] === 'active'; // Create the slot content or empty array if no default slot const slotContent = slots.default ? slots.default({ isActive }) : []; // 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); } // Vue normalizes class bindings in place; preserve the cached bindings. return Vue.h(Component, { ...linkProps }, slotContent); }; }, }); /** * Link component with proper TypeScript generics support */ export const Link = LinkImpl; export const linkOptions = (options) => { return options; }; //# sourceMappingURL=link.jsx.map