UNPKG

@tanstack/solid-router

Version:

Modern and scalable routing for Solid applications

431 lines 17.1 kB
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