UNPKG

@tanstack/solid-router

Version:

Modern and scalable routing for Solid applications

402 lines 14.5 kB
import * as Solid from 'solid-js'; import { mergeRefs } from '@solid-primitives/refs'; import { deepEqual, functionalUpdate, getUrlScheme, hasKeys, isDangerousProtocol, preloadWarning, removeTrailingSlash, } from '@tanstack/router-core'; import { isServer } from '@tanstack/router-core/isServer'; import { Dynamic } from 'solid-js/web'; import { useRouter } from './useRouter'; import { useIntersectionObserver } from './utils'; import { useHydrated } from './ClientOnly'; 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; const [local, rest] = Solid.splitProps(Solid.mergeProps({ activeProps: STATIC_ACTIVE_PROPS_GET, inactiveProps: STATIC_INACTIVE_PROPS_GET, }, 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', ]); // 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', 'from', 'href', ]); const currentLocation = Solid.createMemo(() => router.stores.location.get(), undefined, { equals: (prev, next) => prev.href === next.href }); const next = Solid.createMemo(() => { // Rebuild when inherited search/hash or the current route context changes. const _fromLocation = currentLocation(); const nextOptions = { _fromLocation, ...options }; // untrack because router-core will also access stores, which are signals in solid return Solid.untrack(() => router.buildLocation(nextOptions)); }); 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; const href = external ? publicHref : router.history.createHref(publicHref) || '/'; if ((external || href !== publicHref) && isDangerousProtocol(href, router.protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${href}`); } return undefined; } return href; }); const externalLink = Solid.createMemo(() => { const to = options.to; const scheme = typeof to === 'string' && getUrlScheme(to); if (scheme) { if (!router.protocolAllowlist.has(scheme)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${to}`); } return null; } return to; } const _href = hrefOption(); if (!_href && !options.disabled) { return null; } return _href && getUrlScheme(_href) ? _href : undefined; }); const preload = Solid.createMemo(() => { if (options.reloadDocument || externalLink() !== undefined || local.disabled) { return false; } return local.preload ?? router.options.defaultPreload; }); const preloadDelay = () => local.preloadDelay ?? router.options.defaultPreloadDelay ?? 0; const isActive = Solid.createMemo(() => { if (externalLink() !== undefined) { return false; } const activeOptions = local.activeOptions; const current = currentLocation(); const nextLocation = next(); const currentPath = removeTrailingSlash(current.pathname, router.basepath); const nextPath = removeTrailingSlash(nextLocation.pathname, router.basepath); // Both modes compare normalized paths; fuzzy matches need a segment boundary. if (activeOptions?.exact ? currentPath !== nextPath : !(currentPath.startsWith(nextPath) && (currentPath.length === nextPath.length || currentPath[nextPath.length] === '/'))) { 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; }); const doPreload = () => router .preloadRoute(options) .catch((err) => { console.warn(err); console.warn(preloadWarning); }); const [ref, setRef] = Solid.createSignal(null); 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(() => { if (hasRenderFetched) { return; } if (preload() === 'render') { doPreload(); hasRenderFetched = true; } }); // SSR has no reactive destination changes or internal event handlers. // Keep this guard inline so browser builds drop the entire shortcut. if (isServer ?? router.isServer) { const external = externalLink(); if (external !== undefined && local.activeProps === STATIC_ACTIVE_PROPS_GET && local.inactiveProps === STATIC_INACTIVE_PROPS_GET && local.class === undefined && local.style === undefined) { const disabled = local.disabled || external === null; return Solid.mergeProps(propsSafeToSpread, Solid.splitProps(local, [ 'target', 'onClick', 'onBlur', 'onFocus', 'onMouseEnter', 'onMouseLeave', 'onMouseOut', 'onMouseOver', 'onTouchStart', ])[0], { ref: mergeRefs(setRef, options.ref), href: external ?? undefined, disabled, ...(disabled && STATIC_DISABLED_PROPS), }); } } // 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 && externalLink() === undefined && !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && !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(() => local.activeProps === STATIC_ACTIVE_PROPS_GET && local.inactiveProps === STATIC_INACTIVE_PROPS_GET && local.class === undefined && local.style === undefined); 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 resolvedProps = Solid.createMemo(() => { const active = isActive(); const external = externalLink(); const disabled = local.disabled || external === null; const base = { href: external === null ? undefined : external || hrefOption(), ref: mergeRefs(setRef, options.ref), onClick, onBlur, onFocus, onMouseEnter, onMouseOver, onMouseLeave, onMouseOut, onTouchStart, disabled, target: local.target, ...(disabled && STATIC_DISABLED_PROPS), }; if (simpleStyling()) { return { ...base, ...(active && STATIC_DEFAULT_ACTIVE_ATTRIBUTES), }; } // Active and inactive props are mutually exclusive. const stateProps = active ? (functionalUpdate(local.activeProps, {}) ?? EMPTY_OBJECT) : functionalUpdate(local.inactiveProps, {}); const style = { ...local.style, ...stateProps.style, }; const className = [local.class, stateProps.class].filter(Boolean).join(' '); return { ...stateProps, ...base, ...(hasKeys(style) ? { style } : undefined), ...(className ? { class: className } : undefined), ...(active && STATIC_ACTIVE_ATTRIBUTES), }; }); return Solid.mergeProps(propsSafeToSpread, resolvedProps); } const STATIC_ACTIVE_PROPS = { class: 'active' }; const STATIC_ACTIVE_PROPS_GET = () => STATIC_ACTIVE_PROPS; const EMPTY_OBJECT = {}; const STATIC_INACTIVE_PROPS_GET = () => EMPTY_OBJECT; const STATIC_DEFAULT_ACTIVE_ATTRIBUTES = { class: 'active', 'data-status': 'active', 'aria-current': 'page', }; const STATIC_DISABLED_PROPS = { role: 'link', 'aria-disabled': true, }; const STATIC_ACTIVE_ATTRIBUTES = { 'data-status': 'active', 'aria-current': 'page', }; /** 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] = 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'; }, }); } return ch; }); if (local._asChild === 'svg') { const [_, svgLinkProps] = Solid.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>); }; export const linkOptions = (options) => { return options; }; //# sourceMappingURL=link.jsx.map