UNPKG

@tanstack/solid-router

Version:

Modern and scalable routing for Solid applications

400 lines 14.2 kB
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