UNPKG

@tanstack/vue-router

Version:

Modern and scalable routing for Vue applications

494 lines 20.1 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) { 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(() => ({})); } // Determine if the link is external or internal const type = Vue.computed(() => { const options = getOptions(); try { new URL(`${options.to}`); return 'external'; } catch { return 'internal'; } }); 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 (type.value === 'external') { return Vue.ref(getExternalLinkProps(options, router, ref)); } const next = router.buildLocation(options); const href = getHref(options, router, next); const isActive = getIsActive(router.stores.location.get(), next, options.activeOptions, router); const { resolvedActiveProps, resolvedInactiveProps, resolvedClassName, resolvedStyle, } = resolveStyleProps(options, isActive); const result = combineResultProps({ href, options, isActive, resolvedActiveProps, resolvedInactiveProps, resolvedClassName, resolvedStyle, }); return Vue.ref(result); } const currentLocation = type.value === 'external' ? Vue.shallowRef(router.stores.location.get()) : useStore(router.stores.location, (l) => l, { equal: (prev, next) => prev.href === next.href, }); // Links that start external skip useStore above. Subscribe if they later // become internal so active state follows subsequent location changes. if (type.value === 'external') { Vue.watchEffect((onCleanup) => { if (type.value === 'external') { return; } const store = router.stores.location; 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 preload = Vue.computed(() => { const options = getOptions(); if (type.value === 'external' || 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(); return getIsActive(currentLocation.value, next.value, options.activeOptions, router); }); const doPreload = () => { const options = getOptions(); return router .preloadRoute({ ...options, _builtLocation: next.value }) .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) => { if (type.value === 'external') { return; } const options = getOptions(); // 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); }); const href = Vue.computed(() => { const options = getOptions(); return getHref(options, router, next.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 (type.value === 'external') { return getExternalLinkProps(options, router, ref, staticEventHandlers); } const { resolvedActiveProps, resolvedInactiveProps, resolvedClassName, resolvedStyle, } = resolvedStyleProps.value; return combineResultProps({ href: href.value, options, ref, staticEventHandlers, isActive: isActive.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, resolvedActiveProps, resolvedInactiveProps, resolvedClassName, resolvedStyle, ref, staticEventHandlers, }) { const result = { ...getPropsSafeToSpread(options), ref, ...staticEventHandlers, href, disabled: options._asChild ? !!options.disabled : undefined, 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'; } 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 getExternalLinkProps(options, router, ref, staticEventHandlers) { const dangerous = isDangerousProtocol(options.to, router.protocolAllowlist); if (dangerous && process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${options.to}`); } const eventHandlers = getLinkEventHandlers(options); const result = { ...getPropsSafeToSpread(options), ref, href: dangerous || options.disabled ? undefined : options.to, target: options.target, disabled: options._asChild ? !!options.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 (options.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) { 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 }) { 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); } // Return the component with props and children 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