UNPKG

@tanstack/vue-router

Version:

Modern and scalable routing for Vue applications

540 lines • 20.5 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 { useRouter } from './useRouter'; import { linkLocationContext } from './matchContext'; import { useIntersectionObserver } from './utils'; 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 {}; } // 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.shallowRef(getExternalLinkProps(options, router)); } 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.shallowRef(result); } const ref = Vue.ref(null); const isExternal = () => !!getUrlScheme(`${getOptions().to}`); // Links build from the location their match presents, or the router's live // one outside every match. External links never read it. const source = Vue.inject(linkLocationContext); // Rebuild only when the href moves. const currentLocation = Vue.computed((prev) => prev?.href === source.value.href ? prev : source.value); // The Link's own destination options, replaced only when a navigation // option changes identity: the router reuses a location built from the same // object without reading the current location. A reactive value changes in // place, so a reactive option value (checked one level deep, not nested) // gets a fresh object per location instead, re-read at each navigation. A // mask holds destination options of its own, so its values are checked too. const dest = Vue.computed((prev) => { const options = getOptions(); const dest = {}; let same = !!prev; for (const key of NAVIGATION_KEYS) { const value = (dest[key] = options[key]); if (Vue.isProxy(value) || (key === 'mask' && value && Object.values(value).some(Vue.isProxy))) { // Track the location: a reactive value is re-read at each one. same = !currentLocation.value; } same && (same = prev[key] === value); } return same ? prev : dest; }); // Clicks and preloads build from the latest location the Link's match // presents. const navigateOptions = () => { const options = dest.value; options._fromLocation = source.value; return options; }; // [href, active] of an internal Link, derived in one computation. An // unchanged result keeps its identity, so a navigation that leaves the Link // as it was notifies nothing downstream. const state = Vue.computed((prev) => { const options = getOptions(); const destOptions = dest.value; // Rebuild when inherited search/hash or the current route context changes. destOptions._fromLocation = currentLocation.value; const next = router.buildLocation(destOptions); const href = getHref(options, router, next); const isActive = !options.disabled && (href === undefined || !!getUrlScheme(href)) ? false : getIsActive(currentLocation.value, next, options.activeOptions, router); return prev && prev[0 /* href */] === href && prev[1 /* isActive */] === isActive ? prev : [href, isActive]; }); // The requested mode does not depend on the location, so the preload // effects do not run on navigations. const preloadMode = Vue.computed(() => { const options = getOptions(); return (!isExternal() && !options.reloadDocument && !options.disabled && (options.preload ?? router.options.defaultPreload)); }); const preload = () => { const href = !isExternal() && state.value[0 /* href */]; return !!href && !getUrlScheme(href) && preloadMode.value; }; const preloadDelay = () => getOptions().preloadDelay ?? router.options.defaultPreloadDelay ?? 0; const doPreload = () => { return router.preloadRoute(navigateOptions()).catch((err) => { console.warn(err); console.warn(preloadWarning); }); }; let pendingPreload; let preloadTimeout; const cancelPreload = () => { clearTimeout(preloadTimeout); preloadTimeout = pendingPreload = undefined; }; const enqueuePreload = (e) => { if (!e) { cancelPreload(); return; } const isIntersecting = e.isIntersecting; const preloadMode = isIntersecting === undefined ? 'intent' : 'viewport'; if (preload() !== preloadMode || isIntersecting === false) { if (isIntersecting === false && pendingPreload === 'viewport') { cancelPreload(); } return; } if (!preloadDelay()) { doPreload(); return; } if (!preloadTimeout) { const scheduledHref = state.value[0 /* href */]; pendingPreload = preloadMode; preloadTimeout = setTimeout(() => { preloadTimeout = pendingPreload = undefined; if (preload() === preloadMode && state.value[0 /* href */] === scheduledHref) { doPreload(); } }, preloadDelay()); } }; useIntersectionObserver(ref, enqueuePreload, () => preloadMode.value !== 'viewport', // Intent preloading still needs timer cleanup without an observer. () => !!preloadMode.value); Vue.watchEffect(() => { if (preloadMode.value !== 'render' || !preload()) { return; } const nextHref = state.value[0 /* href */]; if (nextHref && renderFetchedHref !== nextHref) { renderFetchedHref = nextHref; doPreload(); } }); // The click handler const handleClick = (e) => { const options = getOptions(); const href = !isExternal() && state.value[0 /* href */]; if (!options.disabled && (!href || getUrlScheme(href))) { 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(); enqueuePreload(); // All is well? Navigate! router.navigate(navigateOptions()); } }; const handleTouchStart = () => { if (preload() === 'intent') { doPreload(); } }; const handleLeave = () => { if (pendingPreload === 'intent') { cancelPreload(); } }; function composeEventHandlers(getUserHandler, handler) { return (event) => { getUserHandler()?.(event); handler(event); }; } // 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()) { return getExternalLinkProps(options, router, ref, staticEventHandlers); } const [href, isActive] = state.value; const { resolvedProps, resolvedClass, resolvedStyle } = resolveStyleProps(options, isActive); return combineResultProps({ href, options, ref, staticEventHandlers, isActive, resolvedProps, resolvedClass, resolvedStyle, }); }); // Return the computed ref itself - callers should access .value return computedProps; } // Options that decide where and how a Link navigates. const NAVIGATION_KEYS = [ 'to', 'replace', 'resetScroll', 'hashScrollIntoView', 'startTransition', 'viewTransition', 'ignoreBlocker', 'params', 'search', 'hash', 'state', 'mask', 'unsafeRelative', 'from', 'href', ]; 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, }; } // Options the Link consumes; every other option is an element prop. const LINK_OPTION_KEYS = new Set([ ...NAVIGATION_KEYS, 'activeProps', 'inactiveProps', 'activeOptions', 'preload', 'preloadDelay', 'preloadIntentProximity', 'children', 'target', 'disabled', 'style', 'class', 'onClick', 'onBlur', 'onFocus', 'onMouseEnter', 'onMouseenter', 'onMouseLeave', 'onMouseleave', 'onMouseOver', 'onMouseover', 'onMouseOut', 'onMouseout', 'onTouchStart', 'onTouchstart', 'reloadDocument', '_asChild', 'additionalProps', ]); const getPropsSafeToSpread = (options) => { const propsSafeToSpread = {}; for (const key in options) { if (!LINK_OPTION_KEYS.has(key)) { propsSafeToSpread[key] = options[key]; } } 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, !activeOptions?.exact, 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); }, }); } function useLinkPropsSnapshot(props, attrs) { 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, })); return () => allProps.value; } 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 }) { // The server renders once: read the props directly. const linkPropsSource = useLinkPropsImpl((isServer ?? useRouter()?.isServer) ? () => ({ ...props, ...attrs }) : useLinkPropsSnapshot(props, attrs)); 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