@tanstack/solid-router
Version:
Modern and scalable routing for Solid applications
400 lines • 14.2 kB
JSX
import * as Solid from 'solid-js';
import { mergeRefs } from '@solid-primitives/refs';
import { deepEqual, exactPathTest, functionalUpdate, isDangerousProtocol, preloadWarning, removeTrailingSlash, } from '@tanstack/router-core';
import { isServer } from '@tanstack/router-core/isServer';
import { Dynamic } from 'solid-js/web';
import { useRouterState } from './useRouterState';
import { useRouter } from './useRouter';
import { useIntersectionObserver } from './utils';
import { useHydrated } from './ClientOnly';
const timeoutMap = new WeakMap();
export function useLinkProps(options) {
const router = useRouter();
const [isTransitioning, setIsTransitioning] = Solid.createSignal(false);
const shouldHydrateHash = !isServer && !!router.options.ssr;
const hasHydrated = useHydrated();
let hasRenderFetched = false;
const [local, rest] = Solid.splitProps(Solid.mergeProps({
activeProps: () => ({ class: 'active' }),
inactiveProps: () => ({}),
}, options), [
'activeProps',
'inactiveProps',
'activeOptions',
'to',
'preload',
'preloadDelay',
'hashScrollIntoView',
'replace',
'startTransition',
'resetScroll',
'viewTransition',
'target',
'disabled',
'style',
'class',
'onClick',
'onBlur',
'onFocus',
'onMouseEnter',
'onMouseLeave',
'onMouseOver',
'onMouseOut',
'onTouchStart',
'ignoreBlocker',
]);
// const {
// // custom props
// activeProps = () => ({ class: 'active' }),
// inactiveProps = () => ({}),
// activeOptions,
// to,
// preload: userPreload,
// preloadDelay: userPreloadDelay,
// hashScrollIntoView,
// replace,
// startTransition,
// resetScroll,
// viewTransition,
// // element props
// children,
// target,
// disabled,
// style,
// class,
// onClick,
// onFocus,
// onMouseEnter,
// onMouseLeave,
// onTouchStart,
// ignoreBlocker,
// ...rest
// } = options
const [_, propsSafeToSpread] = Solid.splitProps(rest, [
'params',
'search',
'hash',
'state',
'mask',
'reloadDocument',
'unsafeRelative',
]);
const currentLocation = useRouterState({
select: (s) => s.location,
});
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
};
},
});
const from = options.from;
const _options = () => {
return {
...options,
from,
};
};
const next = Solid.createMemo(() => {
buildLocationKey();
return router.buildLocation(_options());
});
const hrefOption = Solid.createMemo(() => {
if (_options().disabled)
return undefined;
// 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 location = next().maskedLocation ?? next();
const publicHref = location.publicHref;
const external = location.external;
if (external) {
return { href: publicHref, external: true };
}
return {
href: router.history.createHref(publicHref) || '/',
external: false,
};
});
const externalLink = Solid.createMemo(() => {
const _href = hrefOption();
if (_href?.external) {
// Block dangerous protocols for external links
if (isDangerousProtocol(_href.href, router.protocolAllowlist)) {
if (process.env.NODE_ENV !== 'production') {
console.warn(`Blocked Link with dangerous protocol: ${_href.href}`);
}
return undefined;
}
return _href.href;
}
const to = _options().to;
const isSafeInternal = typeof to === 'string' &&
to.charCodeAt(0) === 47 && // '/'
to.charCodeAt(1) !== 47; // but not '//'
if (isSafeInternal)
return undefined;
try {
new URL(to);
// Block dangerous protocols like javascript:, blob:, data:
if (isDangerousProtocol(to, router.protocolAllowlist)) {
if (process.env.NODE_ENV !== 'production') {
console.warn(`Blocked Link with dangerous protocol: ${to}`);
}
return undefined;
}
return to;
}
catch { }
return undefined;
});
const preload = Solid.createMemo(() => {
if (_options().reloadDocument || externalLink()) {
return false;
}
return local.preload ?? router.options.defaultPreload;
});
const preloadDelay = () => local.preloadDelay ?? router.options.defaultPreloadDelay ?? 0;
const isActive = Solid.createMemo(() => {
if (externalLink())
return false;
if (local.activeOptions?.exact) {
const testExact = exactPathTest(currentLocation().pathname, next().pathname, router.basepath);
if (!testExact) {
return false;
}
}
else {
const currentPathSplit = removeTrailingSlash(currentLocation().pathname, router.basepath).split('/');
const nextPathSplit = removeTrailingSlash(next()?.pathname, router.basepath)?.split('/');
const pathIsFuzzyEqual = nextPathSplit?.every((d, i) => d === currentPathSplit[i]);
if (!pathIsFuzzyEqual) {
return false;
}
}
if (local.activeOptions?.includeSearch ?? true) {
const searchTest = deepEqual(currentLocation().search, next().search, {
partial: !local.activeOptions?.exact,
ignoreUndefined: !local.activeOptions?.explicitUndefined,
});
if (!searchTest) {
return false;
}
}
if (local.activeOptions?.includeHash) {
const currentHash = shouldHydrateHash && !hasHydrated() ? '' : currentLocation().hash;
return currentHash === next().hash;
}
return true;
});
const doPreload = () => router.preloadRoute(_options()).catch((err) => {
console.warn(err);
console.warn(preloadWarning);
});
const preloadViewportIoCallback = (entry) => {
if (entry?.isIntersecting) {
doPreload();
}
};
const [ref, setRef] = Solid.createSignal(null);
useIntersectionObserver(ref, preloadViewportIoCallback, { rootMargin: '100px' }, { disabled: !!local.disabled || !(preload() === 'viewport') });
Solid.createEffect(() => {
if (hasRenderFetched) {
return;
}
if (!local.disabled && preload() === 'render') {
doPreload();
hasRenderFetched = true;
}
});
if (externalLink()) {
return Solid.mergeProps(propsSafeToSpread, {
ref: mergeRefs(setRef, _options().ref),
href: externalLink(),
}, Solid.splitProps(local, [
'target',
'disabled',
'style',
'class',
'onClick',
'onBlur',
'onFocus',
'onMouseEnter',
'onMouseLeave',
'onMouseOut',
'onMouseOver',
'onTouchStart',
])[0]);
}
// The click handler
const handleClick = (e) => {
// Check actual element's target attribute as fallback
const elementTarget = e.currentTarget.getAttribute('target');
const effectiveTarget = local.target !== undefined ? local.target : elementTarget;
if (!local.disabled &&
!isCtrlEvent(e) &&
!e.defaultPrevented &&
(!effectiveTarget || effectiveTarget === '_self') &&
e.button === 0) {
e.preventDefault();
setIsTransitioning(true);
const unsub = router.subscribe('onResolved', () => {
unsub();
setIsTransitioning(false);
});
// All is well? Navigate!
// N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing
router.navigate({
..._options(),
replace: local.replace,
resetScroll: local.resetScroll,
hashScrollIntoView: local.hashScrollIntoView,
startTransition: local.startTransition,
viewTransition: local.viewTransition,
ignoreBlocker: local.ignoreBlocker,
});
}
};
const enqueueIntentPreload = (e) => {
if (local.disabled || preload() !== 'intent')
return;
if (!preloadDelay()) {
doPreload();
return;
}
const eventTarget = e.currentTarget || e.target;
if (!eventTarget || timeoutMap.has(eventTarget))
return;
timeoutMap.set(eventTarget, setTimeout(() => {
timeoutMap.delete(eventTarget);
doPreload();
}, preloadDelay()));
};
const handleTouchStart = (_) => {
if (local.disabled || preload() !== 'intent')
return;
doPreload();
};
const handleLeave = (e) => {
if (local.disabled)
return;
const eventTarget = e.currentTarget || e.target;
if (eventTarget) {
const id = timeoutMap.get(eventTarget);
clearTimeout(id);
timeoutMap.delete(eventTarget);
}
};
/** Call a JSX.EventHandlerUnion with the event. */
function callHandler(event, handler) {
if (handler) {
if (typeof handler === 'function') {
handler(event);
}
else {
handler[0](handler[1], event);
}
}
return event.defaultPrevented;
}
function composeEventHandlers(handlers) {
return (event) => {
for (const handler of handlers) {
callHandler(event, handler);
}
};
}
// Get the active props
const resolvedActiveProps = () => isActive() ? (functionalUpdate(local.activeProps, {}) ?? {}) : {};
// Get the inactive props
const resolvedInactiveProps = () => isActive() ? {} : functionalUpdate(local.inactiveProps, {});
const resolvedClassName = () => [local.class, resolvedActiveProps().class, resolvedInactiveProps().class]
.filter(Boolean)
.join(' ');
const resolvedStyle = () => ({
...local.style,
...resolvedActiveProps().style,
...resolvedInactiveProps().style,
});
return Solid.mergeProps(propsSafeToSpread, resolvedActiveProps, resolvedInactiveProps, () => {
return {
href: hrefOption()?.href,
ref: mergeRefs(setRef, _options().ref),
onClick: composeEventHandlers([local.onClick, handleClick]),
onBlur: composeEventHandlers([local.onBlur, handleLeave]),
onFocus: composeEventHandlers([local.onFocus, enqueueIntentPreload]),
onMouseEnter: composeEventHandlers([
local.onMouseEnter,
enqueueIntentPreload,
]),
onMouseOver: composeEventHandlers([
local.onMouseOver,
enqueueIntentPreload,
]),
onMouseLeave: composeEventHandlers([local.onMouseLeave, handleLeave]),
onMouseOut: composeEventHandlers([local.onMouseOut, handleLeave]),
onTouchStart: composeEventHandlers([
local.onTouchStart,
handleTouchStart,
]),
disabled: !!local.disabled,
target: local.target,
...(() => {
const s = resolvedStyle();
return Object.keys(s).length ? { style: s } : {};
})(),
...(() => {
const c = resolvedClassName();
return c ? { class: c } : {};
})(),
...(local.disabled && {
role: 'link',
'aria-disabled': true,
}),
...(isActive() && { 'data-status': 'active', 'aria-current': 'page' }),
...(isTransitioning() && { 'data-transitioning': 'transitioning' }),
};
});
}
export function createLink(Comp) {
return (props) => <Link {...props} _asChild={Comp}/>;
}
export const Link = (props) => {
const [local, rest] = Solid.splitProps(props, ['_asChild', 'children']);
const [_, linkProps] = Solid.splitProps(useLinkProps(rest), ['type']);
const children = Solid.createMemo(() => {
const ch = local.children;
if (typeof ch === 'function') {
return ch({
get isActive() {
return linkProps['data-status'] === 'active';
},
get isTransitioning() {
return linkProps['data-transitioning'] === 'transitioning';
},
});
}
return ch;
});
if (local._asChild === 'svg') {
const [_, svgLinkProps] = Solid.splitProps(linkProps, ['class']);
return (<svg>
<a {...svgLinkProps}>{children()}</a>
</svg>);
}
return (<Dynamic component={local._asChild ? local._asChild : 'a'} {...linkProps}>
{children()}
</Dynamic>);
};
function isCtrlEvent(e) {
return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey);
}
export const linkOptions = (options) => {
return options;
};
//# sourceMappingURL=link.jsx.map