@tanstack/vue-router
Version:
Modern and scalable routing for Vue applications
494 lines • 20.1 kB
JSX
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