UNPKG

@tanstack/solid-router

Version:

Modern and scalable routing for Solid applications

432 lines • 16.8 kB
import * as Solid from 'solid-js'; import { _isRouteDeparting, 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 { nearestMatchContext } from './matchContext'; import { useIntersectionObserver } from './utils'; import { useHydrated } from './ClientOnly'; export function useLinkProps(options) { return createLinkProps(options, LINK_OPTION_KEYS); } // `ownKeys` are the options that never reach the element. function createLinkProps(options, ownKeys) { const router = useRouter(); // Options are read directly: one props proxy, no merged-default getters. const [, propsSafeToSpread] = Solid.splitProps(options, ownKeys); // A live match never changes route, so read it once without tracking. const routeId = Solid.useContext(nearestMatchContext)[0 /* routeId */](); // While a publishing navigation leaves the Link's route, keep the location // the route still presents so the Link does no work. The route unmounts // when that navigation commits, and any other outcome publishes a newer // location. Compare by identity: a same-href navigation can change state. // The server renders one location: no reactivity there. const currentLocation = (isServer ?? router.isServer) ? () => router.stores.location.get() : Solid.createMemo((prev) => { const location = router.stores.location.get(); return prev && _isRouteDeparting(router, routeId, location) ? prev : location; }); // The Link's own destination options, replaced only when a destination prop // changes: the router reuses a location built from the same object without // reading the current location. A Solid store changes in place, so a store // prop value (checked one level deep, not nested) gets a fresh object per // location instead, re-reading the store at each navigation as before. A // mask holds destination options of its own, so its values are checked too. const getDest = () => { const dest = {}; for (const key of NAVIGATION_KEYS) { const value = (dest[key] = options[key]); if (isStore(value) || (key === 'mask' && value && Object.values(value).some(isStore))) { currentLocation(); } } return dest; }; const dest = (isServer ?? router.isServer) ? getDest : Solid.createMemo(getDest); // Only a hydrating client renders the server's hash-less active state first. const hasHydrated = !(isServer ?? router.isServer) && router.options.ssr ? useHydrated() : undefined; // Everything the Link derives from its destination and the location, in // one computation: [element href, external href (null when blocked), // active]. An unchanged result keeps its identity, so a navigation that // leaves the Link as it was notifies nothing downstream. const getLinkState = (prev) => { const location = currentLocation(); const to = options.to; const disabled = options.disabled; const scheme = typeof to === 'string' && getUrlScheme(to); let href; let external; let active = false; if (scheme) { if (router.protocolAllowlist.has(scheme)) { external = to; } else { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${to}`); } external = null; } } else { const options_ = dest(); // Clicks and preloads go where the href points. options_._fromLocation = location; // untrack because router-core will also access stores, which are signals in solid const next = Solid.untrack(() => router.buildLocation(options_)); if (!disabled) { // 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 shown = next.maskedLocation ?? next; const publicHref = shown.publicHref; const shownHref = shown.external ? publicHref : router.history.createHref(publicHref) || '/'; if ((shown.external || shownHref !== publicHref) && isDangerousProtocol(shownHref, router.protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${shownHref}`); } } else { href = shownHref; } } external = !href && !disabled ? null : href && getUrlScheme(href) ? href : undefined; if (external === undefined) { const activeOptions = options.activeOptions; const currentPath = removeTrailingSlash(location.pathname, router.basepath); const nextPath = removeTrailingSlash(next.pathname, router.basepath); // Both modes compare normalized paths; fuzzy matches need a segment boundary. active = (activeOptions?.exact ? currentPath === nextPath : currentPath.startsWith(nextPath) && (currentPath.length === nextPath.length || currentPath[nextPath.length] === '/')) && (!(activeOptions?.includeSearch ?? true) || deepEqual(location.search, next.search, !activeOptions?.exact, activeOptions?.explicitUndefined)) && (!activeOptions?.includeHash || (hasHydrated && !hasHydrated() ? '' : location.hash) === next.hash); } } href = external === null ? undefined : external || href; return prev && prev[0 /* href */] === href && prev[1 /* external */] === external && prev[2 /* active */] === active ? prev : [href, external, active]; }; const linkState = (isServer ?? router.isServer) ? getLinkState : Solid.createMemo(getLinkState); const resolveLinkStateProps = ([href, external, active], base) => { const disabled = options.disabled || external === null; base.href = href; base.disabled = disabled; base.target = options.target; if (disabled) { Object.assign(base, STATIC_DISABLED_PROPS); } if (options.activeProps === undefined && options.inactiveProps === undefined && options.class === undefined && options.style === undefined) { return active ? Object.assign(base, STATIC_DEFAULT_ACTIVE_ATTRIBUTES) : base; } const stateProps = functionalUpdate((active ? options.activeProps : options.inactiveProps) ?? (active ? STATIC_ACTIVE_PROPS : EMPTY_OBJECT), {}) ?? EMPTY_OBJECT; const baseStyle = options.style; const stateStyle = stateProps.style; // Snapshot reactive style properties so in-place updates remain observable. const style = baseStyle || stateStyle ? { ...baseStyle, ...stateStyle } : undefined; const baseClass = options.class; const stateClass = stateProps.class; const className = baseClass ? stateClass ? `${baseClass} ${stateClass}` : baseClass : stateClass; return { ...base, ...stateProps, // State props can override element props, but not routing options. href, disabled, target: base.target, ...(style && hasKeys(style) && { style }), ...(className && { class: className }), ...(active && STATIC_ACTIVE_ATTRIBUTES), }; }; // Keep the guard inline so browser builds can drop the server return. if (isServer ?? router.isServer) { const props = resolveLinkStateProps(linkState(), { onClick: options.onClick, onBlur: options.onBlur, onFocus: options.onFocus, onMouseEnter: options.onMouseEnter, onMouseLeave: options.onMouseLeave, onMouseOut: options.onMouseOut, onMouseOver: options.onMouseOver, onTouchStart: options.onTouchStart, ref: options.ref, }); // Avoid creating merged-prop getters for absent server event handlers. for (const key of STATIC_EVENT_PROPS) { if (props[key] === undefined) { delete props[key]; } } return Solid.mergeProps(propsSafeToSpread, props); } let hasRenderFetched = false; const preload = () => !options.reloadDocument && linkState()[1 /* external */] === undefined && !options.disabled && (options.preload ?? router.options.defaultPreload); const preloadDelay = () => options.preloadDelay ?? router.options.defaultPreloadDelay ?? 0; const doPreload = () => router.preloadRoute(dest()).catch((err) => { console.warn(err); console.warn(preloadWarning); }); const [ref, setRef] = Solid.createSignal(null); let preloadTimeout; const cancelPreload = () => { clearTimeout(preloadTimeout); preloadTimeout = undefined; }; const enqueuePreload = (e) => { if (!e) { cancelPreload(); return; } if (!(e.isIntersecting ?? preload() === 'intent')) { if (e.isIntersecting === false) { cancelPreload(); } return; } if (!preloadDelay()) { doPreload(); return; } preloadTimeout ?? (preloadTimeout = setTimeout(() => { preloadTimeout = undefined; doPreload(); }, preloadDelay())); }; useIntersectionObserver(ref, enqueuePreload, preload); Solid.createEffect(() => { if (hasRenderFetched) { return; } if (preload() === 'render') { doPreload(); hasRenderFetched = true; } }); // 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 && linkState()[1 /* external */] === undefined && !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && !e.defaultPrevented && (!effectiveTarget || effectiveTarget === '_self') && e.button === 0) { e.preventDefault(); cancelPreload(); // All is well? Navigate! // N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing router.navigate(dest()); } }; const handleTouchStart = () => { if (preload() !== 'intent') return; doPreload(); }; const handleLeave = () => { if (preload() === 'intent') { cancelPreload(); } }; const onClick = createComposedHandler(() => options.onClick, handleClick); const onBlur = createComposedHandler(() => options.onBlur, handleLeave); const onFocus = createComposedHandler(() => options.onFocus, enqueuePreload); const onMouseEnter = createComposedHandler(() => options.onMouseEnter, enqueuePreload); const onMouseOver = createComposedHandler(() => options.onMouseOver, enqueuePreload); const onMouseLeave = createComposedHandler(() => options.onMouseLeave, handleLeave); const onMouseOut = createComposedHandler(() => options.onMouseOut, handleLeave); const onTouchStart = createComposedHandler(() => options.onTouchStart, handleTouchStart); // One ref callback per element. The selected state props' ref, else the // Link's own, receives the element once; a state change does not re-call it. const linkRef = (el) => { setRef(el); const ref = resolved.ref ?? options.ref; if (typeof ref === 'function') { ref(el); } }; // `mergeProps` memoizes this itself; the ref reads its latest result. let resolved; const resolvedProps = () => (resolved = resolveLinkStateProps(linkState(), { onClick, onBlur, onFocus, onMouseEnter, onMouseOver, onMouseLeave, onMouseOut, onTouchStart, })); // The ref sits after the memo so reading it does not track the memo: the // element's ref effect then runs once. return Solid.mergeProps(propsSafeToSpread, resolvedProps, { ref: linkRef, }); } const isStore = (value) => !!value?.[Solid.$PROXY]; // Props that decide where and how a Link navigates. const NAVIGATION_KEYS = [ 'to', 'reloadDocument', 'replace', 'resetScroll', 'hashScrollIntoView', 'startTransition', 'viewTransition', 'ignoreBlocker', 'params', 'search', 'hash', 'state', 'mask', 'unsafeRelative', 'from', 'href', ]; // Options the Link consumes; every other option is an element prop. const LINK_OPTION_KEYS = [ ...NAVIGATION_KEYS, 'activeProps', 'inactiveProps', 'activeOptions', 'preload', 'preloadDelay', 'preloadIntentProximity', 'target', 'disabled', 'style', 'class', 'onClick', 'onBlur', 'onFocus', 'onMouseEnter', 'onMouseLeave', 'onMouseOver', 'onMouseOut', 'onTouchStart', ]; // A Link also consumes its children and component, and drops `type`. const LINK_ELEMENT_KEYS = [...LINK_OPTION_KEYS, '_asChild', 'children', 'type']; const STATIC_EVENT_PROPS = [ 'onClick', 'onBlur', 'onFocus', 'onMouseEnter', 'onMouseLeave', 'onMouseOut', 'onMouseOver', 'onTouchStart', ]; const STATIC_ACTIVE_PROPS = { class: 'active' }; const 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 = props; // One props proxy between the element and the Link's props. const linkProps = createLinkProps(props, LINK_ELEMENT_KEYS); // Element insertion tracks this itself; only a custom component, which may // read its children more than once, gets a memo. const children = () => { 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>; } const memo = Solid.createMemo(children); return (<Dynamic component={local._asChild} {...linkProps}> {memo()} </Dynamic>); }; export const linkOptions = (options) => { return options; }; //# sourceMappingURL=link.jsx.map