UNPKG

@tanstack/react-router

Version:

Modern and scalable routing for React applications

479 lines (478 loc) • 17.6 kB
import { useForwardedRef, useIntersectionObserver } from "./utils.js"; import { useHydrated } from "./ClientOnly.js"; import { useRouter } from "./useRouter.js"; import { deepEqual, exactPathTest, functionalUpdate, isDangerousProtocol, preloadWarning, removeTrailingSlash } from "@tanstack/router-core"; import * as React$1 from "react"; import { jsx } from "react/jsx-runtime"; import { useStore } from "@tanstack/react-store"; import { isServer } from "@tanstack/router-core/isServer"; import { flushSync } from "react-dom"; //#region src/link.tsx /** * Build anchor-like props for declarative navigation and preloading. * * Returns stable `href`, event handlers and accessibility props derived from * router options and active state. Used internally by `Link` and custom links. * * Options cover `to`, `params`, `search`, `hash`, `state`, `preload`, * `activeProps`, `inactiveProps`, and more. * * @returns React anchor props suitable for `<a>` or custom components. * @link https://tanstack.com/router/latest/docs/framework/react/api/router/useLinkPropsHook */ function useLinkProps(options, forwardedRef) { const router = useRouter(); const innerRef = useForwardedRef(forwardedRef); const _isServer = isServer ?? router.isServer; const { activeProps, inactiveProps, activeOptions, to, preload: userPreload, preloadDelay: userPreloadDelay, hashScrollIntoView, replace, startTransition, resetScroll, viewTransition, children, target, disabled, style, className, onClick, onBlur, onFocus, onMouseEnter, onMouseLeave, onTouchStart, ignoreBlocker, params: _params, search: _search, hash: _hash, state: _state, mask: _mask, reloadDocument: _reloadDocument, unsafeRelative: _unsafeRelative, from: _from, _fromLocation, ...propsSafeToSpread } = options; if (_isServer) { const safeInternal = isSafeInternal(to); if (typeof to === "string" && !safeInternal && to.indexOf(":") > -1) try { new URL(to); if (isDangerousProtocol(to, router.protocolAllowlist)) { if (process.env.NODE_ENV !== "production") console.warn(`Blocked Link with dangerous protocol: ${to}`); return { ...propsSafeToSpread, ref: innerRef, href: void 0, ...children && { children }, ...target && { target }, ...disabled && { disabled }, ...style && { style }, ...className && { className } }; } return { ...propsSafeToSpread, ref: innerRef, href: to, ...children && { children }, ...target && { target }, ...disabled && { disabled }, ...style && { style }, ...className && { className } }; } catch {} const next = router.buildLocation({ ...options, from: options.from }); const hrefOption = getHrefOption(next.maskedLocation ? next.maskedLocation.publicHref : next.publicHref, next.maskedLocation ? next.maskedLocation.external : next.external, router.history, disabled); const externalLink = (() => { if (hrefOption?.external) { if (isDangerousProtocol(hrefOption.href, router.protocolAllowlist)) { if (process.env.NODE_ENV !== "production") console.warn(`Blocked Link with dangerous protocol: ${hrefOption.href}`); return; } return hrefOption.href; } if (safeInternal) return void 0; if (typeof to === "string" && to.indexOf(":") > -1) try { new URL(to); if (isDangerousProtocol(to, router.protocolAllowlist)) { if (process.env.NODE_ENV !== "production") console.warn(`Blocked Link with dangerous protocol: ${to}`); return; } return to; } catch {} })(); const isActive = (() => { if (externalLink) return false; const currentLocation = router.stores.location.state; const exact = activeOptions?.exact ?? false; if (exact) { if (!exactPathTest(currentLocation.pathname, next.pathname, router.basepath)) return false; } else { const currentPathSplit = removeTrailingSlash(currentLocation.pathname, router.basepath); const nextPathSplit = removeTrailingSlash(next.pathname, router.basepath); if (!(currentPathSplit.startsWith(nextPathSplit) && (currentPathSplit.length === nextPathSplit.length || currentPathSplit[nextPathSplit.length] === "/"))) return false; } if (activeOptions?.includeSearch ?? true) { if (currentLocation.search !== next.search) { const currentSearchEmpty = !currentLocation.search || typeof currentLocation.search === "object" && Object.keys(currentLocation.search).length === 0; const nextSearchEmpty = !next.search || typeof next.search === "object" && Object.keys(next.search).length === 0; if (!(currentSearchEmpty && nextSearchEmpty)) { if (!deepEqual(currentLocation.search, next.search, { partial: !exact, ignoreUndefined: !activeOptions?.explicitUndefined })) return false; } } } if (activeOptions?.includeHash) return false; return true; })(); if (externalLink) return { ...propsSafeToSpread, ref: innerRef, href: externalLink, ...children && { children }, ...target && { target }, ...disabled && { disabled }, ...style && { style }, ...className && { className } }; const resolvedActiveProps = isActive ? functionalUpdate(activeProps, {}) ?? STATIC_ACTIVE_OBJECT : STATIC_EMPTY_OBJECT; const resolvedInactiveProps = isActive ? STATIC_EMPTY_OBJECT : functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT; const resolvedStyle = (() => { const baseStyle = style; const activeStyle = resolvedActiveProps.style; const inactiveStyle = resolvedInactiveProps.style; if (!baseStyle && !activeStyle && !inactiveStyle) return; if (baseStyle && !activeStyle && !inactiveStyle) return baseStyle; if (!baseStyle && activeStyle && !inactiveStyle) return activeStyle; if (!baseStyle && !activeStyle && inactiveStyle) return inactiveStyle; return { ...baseStyle, ...activeStyle, ...inactiveStyle }; })(); const resolvedClassName = (() => { const baseClassName = className; const activeClassName = resolvedActiveProps.className; const inactiveClassName = resolvedInactiveProps.className; if (!baseClassName && !activeClassName && !inactiveClassName) return ""; let out = ""; if (baseClassName) out = baseClassName; if (activeClassName) out = out ? `${out} ${activeClassName}` : activeClassName; if (inactiveClassName) out = out ? `${out} ${inactiveClassName}` : inactiveClassName; return out; })(); return { ...propsSafeToSpread, ...resolvedActiveProps, ...resolvedInactiveProps, href: hrefOption?.href, ref: innerRef, disabled: !!disabled, target, ...resolvedStyle && { style: resolvedStyle }, ...resolvedClassName && { className: resolvedClassName }, ...disabled && STATIC_DISABLED_PROPS, ...isActive && STATIC_ACTIVE_PROPS }; } const isHydrated = useHydrated(); const _options = React$1.useMemo(() => options, [ router, options.from, options._fromLocation, options.hash, options.to, options.search, options.params, options.state, options.mask, options.unsafeRelative ]); const currentLocation = useStore(router.stores.location, (l) => l, (prev, next) => prev.href === next.href); const next = React$1.useMemo(() => { const opts = { _fromLocation: currentLocation, ..._options }; return router.buildLocation(opts); }, [ router, currentLocation, _options ]); const hrefOptionPublicHref = next.maskedLocation ? next.maskedLocation.publicHref : next.publicHref; const hrefOptionExternal = next.maskedLocation ? next.maskedLocation.external : next.external; const hrefOption = React$1.useMemo(() => getHrefOption(hrefOptionPublicHref, hrefOptionExternal, router.history, disabled), [ disabled, hrefOptionExternal, hrefOptionPublicHref, router.history ]); const externalLink = React$1.useMemo(() => { if (hrefOption?.external) { if (isDangerousProtocol(hrefOption.href, router.protocolAllowlist)) { if (process.env.NODE_ENV !== "production") console.warn(`Blocked Link with dangerous protocol: ${hrefOption.href}`); return; } return hrefOption.href; } if (isSafeInternal(to)) return void 0; if (typeof to !== "string" || to.indexOf(":") === -1) return void 0; try { new URL(to); if (isDangerousProtocol(to, router.protocolAllowlist)) { if (process.env.NODE_ENV !== "production") console.warn(`Blocked Link with dangerous protocol: ${to}`); return; } return to; } catch {} }, [ to, hrefOption, router.protocolAllowlist ]); const isActive = React$1.useMemo(() => { if (externalLink) return false; if (activeOptions?.exact) { if (!exactPathTest(currentLocation.pathname, next.pathname, router.basepath)) return false; } else { const currentPathSplit = removeTrailingSlash(currentLocation.pathname, router.basepath); const nextPathSplit = removeTrailingSlash(next.pathname, router.basepath); if (!(currentPathSplit.startsWith(nextPathSplit) && (currentPathSplit.length === nextPathSplit.length || currentPathSplit[nextPathSplit.length] === "/"))) return false; } if (activeOptions?.includeSearch ?? true) { if (!deepEqual(currentLocation.search, next.search, { partial: !activeOptions?.exact, ignoreUndefined: !activeOptions?.explicitUndefined })) return false; } if (activeOptions?.includeHash) return isHydrated && currentLocation.hash === next.hash; return true; }, [ activeOptions?.exact, activeOptions?.explicitUndefined, activeOptions?.includeHash, activeOptions?.includeSearch, currentLocation, externalLink, isHydrated, next.hash, next.pathname, next.search, router.basepath ]); const resolvedActiveProps = isActive ? functionalUpdate(activeProps, {}) ?? STATIC_ACTIVE_OBJECT : STATIC_EMPTY_OBJECT; const resolvedInactiveProps = isActive ? STATIC_EMPTY_OBJECT : functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT; const resolvedClassName = [ className, resolvedActiveProps.className, resolvedInactiveProps.className ].filter(Boolean).join(" "); const resolvedStyle = (style || resolvedActiveProps.style || resolvedInactiveProps.style) && { ...style, ...resolvedActiveProps.style, ...resolvedInactiveProps.style }; const [isTransitioning, setIsTransitioning] = React$1.useState(false); const hasRenderFetched = React$1.useRef(false); const preload = options.reloadDocument || externalLink ? false : userPreload ?? router.options.defaultPreload; const preloadDelay = userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0; const doPreload = React$1.useCallback(() => { router.preloadRoute({ ..._options, _builtLocation: next }).catch((err) => { console.warn(err); console.warn(preloadWarning); }); }, [ router, _options, next ]); useIntersectionObserver(innerRef, React$1.useCallback((entry) => { if (entry?.isIntersecting) doPreload(); }, [doPreload]), intersectionObserverOptions, { disabled: !!disabled || !(preload === "viewport") }); React$1.useEffect(() => { if (hasRenderFetched.current) return; if (!disabled && preload === "render") { doPreload(); hasRenderFetched.current = true; } }, [ disabled, doPreload, preload ]); const handleClick = (e) => { const elementTarget = e.currentTarget.getAttribute("target"); const effectiveTarget = target !== void 0 ? target : elementTarget; if (!disabled && !isCtrlEvent(e) && !e.defaultPrevented && (!effectiveTarget || effectiveTarget === "_self") && e.button === 0) { e.preventDefault(); flushSync(() => { setIsTransitioning(true); }); const unsub = router.subscribe("onResolved", () => { unsub(); setIsTransitioning(false); }); router.navigate({ ..._options, replace, resetScroll, hashScrollIntoView, startTransition, viewTransition, ignoreBlocker }); } }; if (externalLink) return { ...propsSafeToSpread, ref: innerRef, href: externalLink, ...children && { children }, ...target && { target }, ...disabled && { disabled }, ...style && { style }, ...className && { className }, ...onClick && { onClick }, ...onBlur && { onBlur }, ...onFocus && { onFocus }, ...onMouseEnter && { onMouseEnter }, ...onMouseLeave && { onMouseLeave }, ...onTouchStart && { onTouchStart } }; const enqueueIntentPreload = (e) => { if (disabled || preload !== "intent") return; if (!preloadDelay) { doPreload(); return; } const eventTarget = e.currentTarget; if (timeoutMap.has(eventTarget)) return; const id = setTimeout(() => { timeoutMap.delete(eventTarget); doPreload(); }, preloadDelay); timeoutMap.set(eventTarget, id); }; const handleTouchStart = (_) => { if (disabled || preload !== "intent") return; doPreload(); }; const handleLeave = (e) => { if (disabled || !preload || !preloadDelay) return; const eventTarget = e.currentTarget; const id = timeoutMap.get(eventTarget); if (id) { clearTimeout(id); timeoutMap.delete(eventTarget); } }; return { ...propsSafeToSpread, ...resolvedActiveProps, ...resolvedInactiveProps, href: hrefOption?.href, ref: innerRef, onClick: composeHandlers([onClick, handleClick]), onBlur: composeHandlers([onBlur, handleLeave]), onFocus: composeHandlers([onFocus, enqueueIntentPreload]), onMouseEnter: composeHandlers([onMouseEnter, enqueueIntentPreload]), onMouseLeave: composeHandlers([onMouseLeave, handleLeave]), onTouchStart: composeHandlers([onTouchStart, handleTouchStart]), disabled: !!disabled, target, ...resolvedStyle && { style: resolvedStyle }, ...resolvedClassName && { className: resolvedClassName }, ...disabled && STATIC_DISABLED_PROPS, ...isActive && STATIC_ACTIVE_PROPS, ...isHydrated && isTransitioning && STATIC_TRANSITIONING_PROPS }; } var STATIC_EMPTY_OBJECT = {}; var STATIC_ACTIVE_OBJECT = { className: "active" }; var STATIC_DISABLED_PROPS = { role: "link", "aria-disabled": true }; var STATIC_ACTIVE_PROPS = { "data-status": "active", "aria-current": "page" }; var STATIC_TRANSITIONING_PROPS = { "data-transitioning": "transitioning" }; var timeoutMap = /* @__PURE__ */ new WeakMap(); var intersectionObserverOptions = { rootMargin: "100px" }; var composeHandlers = (handlers) => (e) => { for (const handler of handlers) { if (!handler) continue; if (e.defaultPrevented) return; handler(e); } }; function getHrefOption(publicHref, external, history, disabled) { if (disabled) return void 0; if (external) return { href: publicHref, external: true }; return { href: history.createHref(publicHref) || "/", external: false }; } function isSafeInternal(to) { if (typeof to !== "string") return false; const zero = to.charCodeAt(0); if (zero === 47) return to.charCodeAt(1) !== 47; return zero === 46; } /** * Creates a typed Link-like component that preserves TanStack Router's * navigation semantics and type-safety while delegating rendering to the * provided host component. * * Useful for integrating design system anchors/buttons while keeping * router-aware props (eg. `to`, `params`, `search`, `preload`). * * @param Comp The host component to render (eg. a design-system Link/Button) * @returns A router-aware component with the same API as `Link`. * @link https://tanstack.com/router/latest/docs/framework/react/guide/custom-link */ function createLink(Comp) { return React$1.forwardRef(function CreatedLink(props, ref) { return /* @__PURE__ */ jsx(Link, { ...props, _asChild: Comp, ref }); }); } /** * A strongly-typed anchor component for declarative navigation. * Handles path, search, hash and state updates with optional route preloading * and active-state styling. * * Props: * - `preload`: Controls route preloading (eg. 'intent', 'render', 'viewport', true/false) * - `preloadDelay`: Delay in ms before preloading on hover * - `activeProps`/`inactiveProps`: Additional props merged when link is active/inactive * - `resetScroll`/`hashScrollIntoView`: Control scroll behavior on navigation * - `viewTransition`/`startTransition`: Use View Transitions/React transitions for navigation * - `ignoreBlocker`: Bypass registered blockers * * @returns An anchor-like element that navigates without full page reloads. * @link https://tanstack.com/router/latest/docs/framework/react/api/router/linkComponent */ var Link = React$1.forwardRef((props, ref) => { const { _asChild, ...rest } = props; const { type: _type, ...linkProps } = useLinkProps(rest, ref); const children = typeof rest.children === "function" ? rest.children({ isActive: linkProps["data-status"] === "active" }) : rest.children; if (!_asChild) { const { disabled: _, ...rest } = linkProps; return React$1.createElement("a", rest, children); } return React$1.createElement(_asChild, linkProps, children); }); function isCtrlEvent(e) { return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey); } /** * Validate and reuse navigation options for `Link`, `navigate` or `redirect`. * Accepts a literal options object and returns it typed for later spreading. * @example * const opts = linkOptions({ to: '/dashboard', search: { tab: 'home' } }) * @link https://tanstack.com/router/latest/docs/framework/react/api/router/linkOptions */ var linkOptions = (options) => { return options; }; /** * Type-check a literal object for use with `Link`, `navigate` or `redirect`. * Use to validate and reuse navigation options across your app. * @example * const opts = linkOptions({ to: '/dashboard', search: { tab: 'home' } }) * @link https://tanstack.com/router/latest/docs/framework/react/api/router/linkOptions */ //#endregion export { Link, createLink, linkOptions, useLinkProps }; //# sourceMappingURL=link.js.map