@tanstack/vue-router
Version:
Modern and scalable routing for Vue applications
512 lines • 18.6 kB
JSX
import * as Vue from 'vue';
import { deepEqual, exactPathTest, isDangerousProtocol, preloadWarning, removeTrailingSlash, } from '@tanstack/router-core';
import { useRouterState } from './useRouterState';
import { useRouter } from './useRouter';
import { useIntersectionObserver } from './utils';
import { useMatches } from './Matches';
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 buildLocationKey = useRouterState({
select: (s) => {
const leaf = s.matches[s.matches.length - 1];
return {
search: leaf?.search,
hash: s.location.hash,
path: leaf?.pathname, // path + params
};
},
});
// when `from` is not supplied, use the leaf route of the current matches as the `from` location
const from = useMatches({
select: (matches) => options.from ?? matches[matches.length - 1]?.fullPath,
});
const _options = Vue.computed(() => ({
...options,
from: from.value,
}));
const next = Vue.computed(() => {
// Depend on search to rebuild when search changes
buildLocationKey.value;
return router.buildLocation(_options.value);
});
const preload = Vue.computed(() => {
if (_options.value.reloadDocument) {
return false;
}
return options.preload ?? router.options.defaultPreload;
});
const preloadDelay = Vue.computed(() => options.preloadDelay ?? router.options.defaultPreloadDelay ?? 0);
const isActive = useRouterState({
select: (s) => {
const activeOptions = options.activeOptions;
if (activeOptions?.exact) {
const testExact = exactPathTest(s.location.pathname, next.value.pathname, router.basepath);
if (!testExact) {
return false;
}
}
else {
const currentPathSplit = removeTrailingSlash(s.location.pathname, router.basepath).split('/');
const nextPathSplit = removeTrailingSlash(next.value?.pathname, router.basepath)?.split('/');
const pathIsFuzzyEqual = nextPathSplit?.every((d, i) => d === currentPathSplit[i]);
if (!pathIsFuzzyEqual) {
return false;
}
}
if (activeOptions?.includeSearch ?? true) {
const searchTest = deepEqual(s.location.search, next.value.search, {
partial: !activeOptions?.exact,
ignoreUndefined: !activeOptions?.explicitUndefined,
});
if (!searchTest) {
return false;
}
}
if (activeOptions?.includeHash) {
return s.location.hash === next.value.hash;
}
return true;
},
});
const doPreload = () => router.preloadRoute(_options.value).catch((err) => {
console.warn(err);
console.warn(preloadWarning);
});
const preloadViewportIoCallback = (entry) => {
if (entry?.isIntersecting) {
doPreload();
}
};
const ref = Vue.ref(null);
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;
}
});
// Create safe props that can be spread
const getPropsSafeToSpread = () => {
const result = {};
const optionRecord = options;
for (const key in options) {
if (![
'activeProps',
'inactiveProps',
'activeOptions',
'to',
'preload',
'preloadDelay',
'hashScrollIntoView',
'replace',
'startTransition',
'resetScroll',
'viewTransition',
'children',
'target',
'disabled',
'style',
'class',
'onClick',
'onBlur',
'onFocus',
'onMouseEnter',
'onMouseLeave',
'onMouseOver',
'onMouseOut',
'onTouchStart',
'ignoreBlocker',
'params',
'search',
'hash',
'state',
'mask',
'reloadDocument',
'_asChild',
'from',
'additionalProps',
].includes(key)) {
result[key] = optionRecord[key];
}
}
return result;
};
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(),
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: options.onMouseEnter,
onMouseLeave: options.onMouseLeave,
onMouseOver: options.onMouseOver,
onMouseOut: options.onMouseOut,
onTouchStart: options.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(),
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: options.onMouseEnter,
onMouseLeave: options.onMouseLeave,
onMouseOver: options.onMouseOver,
onMouseOut: options.onMouseOut,
onTouchStart: options.onTouchStart,
};
// Remove undefined values
Object.keys(externalProps).forEach((key) => {
if (externalProps[key] === undefined) {
delete externalProps[key];
}
});
return Vue.computed(() => externalProps);
}
// 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.value.reloadDocument) {
return;
}
e.preventDefault();
isTransitioning.value = true;
const unsub = router.subscribe('onResolved', () => {
unsub();
isTransitioning.value = false;
});
// All is well? Navigate!
router.navigate({
..._options.value,
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 resolvedActiveProps = Vue.computed(() => {
const activeProps = options.activeProps || (() => ({ class: 'active' }));
const props = isActive.value
? typeof activeProps === 'function'
? activeProps()
: activeProps
: {};
return props || { class: undefined, style: undefined };
});
const resolvedInactiveProps = Vue.computed(() => {
const inactiveProps = options.inactiveProps || (() => ({}));
const props = isActive.value
? {}
: typeof inactiveProps === 'function'
? inactiveProps()
: inactiveProps;
return props || { class: undefined, style: undefined };
});
const resolvedClassName = Vue.computed(() => {
const classes = [
options.class,
resolvedActiveProps.value?.class,
resolvedInactiveProps.value?.class,
].filter(Boolean);
return classes.length ? classes.join(' ') : undefined;
});
const resolvedStyle = Vue.computed(() => {
const result = {};
// Merge styles from all sources
if (options.style) {
Object.assign(result, options.style);
}
if (resolvedActiveProps.value?.style) {
Object.assign(result, resolvedActiveProps.value.style);
}
if (resolvedInactiveProps.value?.style) {
Object.assign(result, resolvedInactiveProps.value.style);
}
return Object.keys(result).length > 0 ? result : undefined;
});
const href = Vue.computed(() => {
if (options.disabled) {
return undefined;
}
const nextLocation = next.value;
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) || '/';
});
// 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([
options.onMouseEnter,
enqueueIntentPreload,
]),
onMouseover: composeEventHandlers([
options.onMouseOver,
enqueueIntentPreload,
]),
onMouseleave: composeEventHandlers([
options.onMouseLeave,
handleLeave,
]),
onMouseout: composeEventHandlers([
options.onMouseOut,
handleLeave,
]),
onTouchstart: composeEventHandlers([
options.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 result = {
...getPropsSafeToSpread(),
href: href.value,
ref,
...staticEventHandlers,
disabled: !!options.disabled,
target: options.target,
};
// Add style if present
if (resolvedStyle.value) {
result.style = resolvedStyle.value;
}
// Add class if present
if (resolvedClassName.value) {
result.class = resolvedClassName.value;
}
// Add disabled props
if (options.disabled) {
result.role = 'link';
result['aria-disabled'] = true;
}
// Add active status
if (isActive.value) {
result['data-status'] = 'active';
result['aria-current'] = 'page';
}
// Add transitioning status
if (isTransitioning.value) {
result['data-transitioning'] = 'transitioning';
}
// Merge active/inactive props (excluding class and style which are handled above)
const activeP = resolvedActiveProps.value;
const inactiveP = resolvedInactiveProps.value;
for (const key of Object.keys(activeP)) {
if (key !== 'class' && key !== 'style') {
result[key] = activeP[key];
}
}
for (const key of Object.keys(inactiveP)) {
if (key !== 'class' && key !== 'style') {
result[key] = inactiveP[key];
}
}
return result;
});
// Return the computed ref itself - callers should access .value
return computedProps;
}
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',
'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
// The returned object is a computed ref that updates reactively
const allProps = { ...props, ...attrs };
const linkPropsComputed = useLinkProps(allProps);
return () => {
const Component = props._asChild || 'a';
// Access the computed value to get fresh props each render
const linkProps = linkPropsComputed.value;
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