@tanstack/solid-router
Version:
Modern and scalable routing for Solid applications
431 lines • 17.1 kB
JSX
import * as Solid from 'solid-js';
import { deepEqual, exactPathTest, functionalUpdate, hasKeys, isDangerousProtocol, preloadWarning, removeTrailingSlash, } from '@tanstack/router-core';
import { isServer } from '@tanstack/router-core/isServer';
import { Dynamic } from '@solidjs/web';
import { useRouter } from './useRouter';
import { useIntersectionObserver } from './utils';
import { useHydrated } from './ClientOnly';
function mergeRefs(...refs) {
const setRef = (ref, el) => {
if (typeof ref === 'function') {
ref(el);
}
else if (Array.isArray(ref)) {
for (const nestedRef of ref) {
setRef(nestedRef, el);
}
}
};
return (el) => {
for (const ref of refs) {
setRef(ref, el);
}
};
}
function splitProps(props, keys) {
const _local = {};
const _rest = {};
// A safe way to polyfill splitProps if native getter copy is too complex
// is just to return [props, Solid.omit(props, keys)] but it modifies typing.
// Actually, Solid.omit exists!
// Note: Solid.omit uses rest params (...keys), so we must spread the array.
return [props, Solid.omit(props, ...keys)];
}
const timeoutMap = new WeakMap();
const cancelPreload = (eventTarget) => {
clearTimeout(timeoutMap.get(eventTarget));
timeoutMap.delete(eventTarget);
};
export function useLinkProps(options) {
const router = useRouter();
const shouldHydrateHash = !isServer && !!router.options.ssr;
const hasHydrated = useHydrated();
let hasRenderFetched = false;
// Defaults are resolved through accessors at the use sites instead of
// merging them into the props. Every merge/omit proxy layered here gets
// re-enumerated by spread() on each navigation, and V8 dispatches proxy
// traps in native runtime code — keeping this path proxy-free is what
// keeps Link updates cheap.
const local = options;
const activeProps = () => local.activeProps ?? STATIC_ACTIVE_PROPS_GET;
const inactiveProps = () => local.inactiveProps ?? STATIC_INACTIVE_PROPS_GET;
const propsSafeToSpread = Solid.omit(options, 'activeProps', 'inactiveProps', 'activeOptions', 'to', 'preload', 'preloadDelay', 'preloadIntentProximity', 'hashScrollIntoView', 'replace', 'startTransition', 'resetScroll', 'viewTransition', 'target', 'disabled', 'style', 'class', 'onClick', 'onBlur', 'onFocus', 'onMouseEnter', 'onMouseLeave', 'onMouseOver', 'onMouseOut', 'onTouchStart', 'ignoreBlocker', 'params', 'search', 'hash', 'state', 'mask', 'reloadDocument', 'unsafeRelative', 'from');
const currentLocation = Solid.createMemo(() => router.stores.location.get(), {
equals: (prev, next) => prev.href === next.href,
});
const _options = () => options;
const next = Solid.createMemo(() => {
// Rebuild when inherited search/hash or the current route context changes.
const _fromLocation = currentLocation();
const options = { _fromLocation, ..._options() };
// untrack because router-core will also access stores, which are signals in solid
return Solid.untrack(() => router.buildLocation(options));
}, {
lazy: true,
// Navigations usually leave most links' built locations unchanged;
// comparing hrefs lets downstream memos (href, isActive) skip work.
equals: (prev, next) => prev.href === next.href &&
prev.external === next.external &&
prev.maskedLocation?.href === next.maskedLocation?.href,
});
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,
};
}, { lazy: true });
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 safeInternal = isSafeInternal(to);
if (safeInternal)
return undefined;
if (typeof to !== 'string' || to.indexOf(':') === -1)
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;
}, { lazy: true });
const preload = Solid.createMemo(() => {
if (_options().reloadDocument || externalLink() || local.disabled) {
return false;
}
return local.preload ?? router.options.defaultPreload;
}, { lazy: true });
const preloadDelay = () => local.preloadDelay ?? router.options.defaultPreloadDelay ?? 0;
const isActive = Solid.createMemo(() => {
if (externalLink())
return false;
const activeOptions = local.activeOptions;
const current = currentLocation();
const nextLocation = next();
if (activeOptions?.exact) {
const testExact = exactPathTest(current.pathname, nextLocation.pathname, router.basepath);
if (!testExact) {
return false;
}
}
else {
const currentPath = removeTrailingSlash(current.pathname, router.basepath);
const nextPath = removeTrailingSlash(nextLocation.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(current.search, nextLocation.search, {
partial: !activeOptions?.exact,
ignoreUndefined: !activeOptions?.explicitUndefined,
});
if (!searchTest) {
return false;
}
}
if (activeOptions?.includeHash) {
const currentHash = shouldHydrateHash && !hasHydrated() ? '' : current.hash;
return currentHash === nextLocation.hash;
}
return true;
}, { lazy: true });
const doPreload = () => router
.preloadRoute({ ...options, _builtLocation: next() })
.catch((err) => {
console.warn(err);
console.warn(preloadWarning);
});
const [ref, setRefSignal] = Solid.createSignal(null);
const setRef = (el) => {
Solid.runWithOwner(null, () => {
setRefSignal(el);
});
};
const enqueuePreload = (e) => {
if (!e) {
cancelPreload(ref);
return;
}
if (!(e.isIntersecting ??
preload() === 'intent')) {
if (e.isIntersecting === false) {
cancelPreload(ref);
}
return;
}
if (!preloadDelay()) {
doPreload();
return;
}
if (!timeoutMap.has(ref)) {
timeoutMap.set(ref, setTimeout(() => {
timeoutMap.delete(ref);
doPreload();
}, preloadDelay()));
}
};
useIntersectionObserver(ref, enqueuePreload, () => preload() !== 'viewport');
Solid.createEffect(preload, (preloadValue) => {
if (hasRenderFetched) {
return;
}
if (preloadValue === 'render') {
Solid.untrack(() => doPreload());
hasRenderFetched = true;
}
});
if (Solid.untrack(externalLink)) {
const externalHref = Solid.untrack(externalLink);
return Solid.merge(propsSafeToSpread, {
ref: mergeRefs(setRef, options.ref),
href: externalHref,
}, 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();
// 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 handleTouchStart = () => {
if (preload() !== 'intent')
return;
doPreload();
};
const handleLeave = () => {
if (preload() === 'intent') {
cancelPreload(ref);
}
};
const simpleStyling = Solid.createMemo(() => activeProps() === STATIC_ACTIVE_PROPS_GET &&
inactiveProps() === STATIC_INACTIVE_PROPS_GET &&
local.class === undefined &&
local.style === undefined, { lazy: true });
const onClick = createComposedHandler(() => local.onClick, handleClick);
const onBlur = createComposedHandler(() => local.onBlur, handleLeave);
const onFocus = createComposedHandler(() => local.onFocus, enqueuePreload);
const onMouseEnter = createComposedHandler(() => local.onMouseEnter, enqueuePreload);
const onMouseOver = createComposedHandler(() => local.onMouseOver, enqueuePreload);
const onMouseLeave = createComposedHandler(() => local.onMouseLeave, handleLeave);
const onMouseOut = createComposedHandler(() => local.onMouseOut, handleLeave);
const onTouchStart = createComposedHandler(() => local.onTouchStart, handleTouchStart);
const resolvedStateProps = Solid.createMemo(() => (isActive()
? functionalUpdate(activeProps(), {})
: functionalUpdate(inactiveProps(), {})) ?? EMPTY_OBJECT, { lazy: true });
const resolvedClass = Solid.createMemo(() => {
if (simpleStyling())
return isActive() ? 'active' : undefined;
return ([local.class, resolvedStateProps().class].filter(Boolean).join(' ') ||
undefined);
}, { lazy: true });
const resolvedStyle = Solid.createMemo(() => {
if (simpleStyling())
return local.style;
const style = { ...local.style, ...resolvedStateProps().style };
return hasKeys(style) ? style : undefined;
}, { lazy: true });
// The returned object must be a plain object with a stable key set so the
// consuming spread() never enumerates through proxy traps. Reactivity lives
// in the property getters; values that no longer apply resolve to undefined,
// which spread()/assign() treats as attribute removal. Keys returned by
// activeProps/inactiveProps are discovered once at setup.
const extraStateKeys = new Set();
Solid.untrack(() => {
for (const stateProps of [
functionalUpdate(activeProps(), {}),
functionalUpdate(inactiveProps(), {}),
]) {
if (stateProps) {
for (const key of Object.keys(stateProps)) {
if (key !== 'class' && key !== 'style')
extraStateKeys.add(key);
}
}
}
});
const composedRef = mergeRefs(setRef, (el) => {
const r = _options().ref;
if (typeof r === 'function')
r(el);
});
const linkProps = {};
for (const key of Object.keys(propsSafeToSpread)) {
Object.defineProperty(linkProps, key, Object.getOwnPropertyDescriptor(propsSafeToSpread, key));
}
for (const key of extraStateKeys) {
Object.defineProperty(linkProps, key, {
get: () => resolvedStateProps()[key],
enumerable: true,
configurable: true,
});
}
const defineGetters = (getters) => {
for (const key of Object.keys(getters)) {
Object.defineProperty(linkProps, key, {
get: getters[key],
enumerable: true,
configurable: true,
});
}
};
linkProps.ref = composedRef;
linkProps.onClick = onClick;
linkProps.onBlur = onBlur;
linkProps.onFocus = onFocus;
linkProps.onMouseEnter = onMouseEnter;
linkProps.onMouseOver = onMouseOver;
linkProps.onMouseLeave = onMouseLeave;
linkProps.onMouseOut = onMouseOut;
linkProps.onTouchStart = onTouchStart;
defineGetters({
href: () => hrefOption()?.href,
disabled: () => !!local.disabled,
target: () => local.target,
role: () => (local.disabled ? 'link' : undefined),
'aria-disabled': () => (local.disabled ? 'true' : undefined),
'data-status': () => (isActive() ? 'active' : undefined),
'aria-current': () => (isActive() ? 'page' : undefined),
class: resolvedClass,
style: resolvedStyle,
});
return linkProps;
}
const STATIC_ACTIVE_PROPS = { class: 'active' };
const STATIC_ACTIVE_PROPS_GET = () => STATIC_ACTIVE_PROPS;
const EMPTY_OBJECT = {};
const STATIC_INACTIVE_PROPS_GET = () => EMPTY_OBJECT;
/** Call a JSX.EventHandlerUnion with the event. */
function callHandler(event, handler) {
if (typeof handler === 'function') {
handler(event);
}
else {
handler[0](handler[1], event);
}
return event.defaultPrevented;
}
function createComposedHandler(getHandler, fallback) {
return (event) => {
const handler = getHandler();
if (!handler || !callHandler(event, handler))
fallback(event);
};
}
export function createLink(Comp) {
return (props) => <Link {...props} _asChild={Comp}/>;
}
export const Link = (props) => {
const [local, rest] = splitProps(props, [
'_asChild',
'children',
]);
const [_, linkProps] = splitProps(useLinkProps(rest), [
'type',
]);
// Resolve children once using Solid.children to avoid
// re-accessing the children getter (which in Solid 2.0 would
// re-invoke createComponent each time for JSX children).
const resolvedChildren = Solid.children(() => local.children);
const children = () => {
const ch = resolvedChildren();
if (typeof ch === 'function') {
return ch({
get isActive() {
return linkProps['data-status'] === 'active';
},
});
}
return ch;
};
if (local._asChild === 'svg') {
const [_, svgLinkProps] = splitProps(linkProps, ['class']);
return (<svg>
<a {...svgLinkProps}>{children()}</a>
</svg>);
}
if (!local._asChild) {
return <a {...linkProps}>{children()}</a>;
}
return (<Dynamic component={local._asChild} {...linkProps}>
{children()}
</Dynamic>);
};
function isCtrlEvent(e) {
return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey);
}
function isSafeInternal(to) {
if (typeof to !== 'string')
return false;
const zero = to.charCodeAt(0);
if (zero === 47)
return to.charCodeAt(1) !== 47; // '/' but not '//'
return zero === 46; // '.', '..', './', '../'
}
export const linkOptions = (options) => {
return options;
};
//# sourceMappingURL=link.jsx.map