@tanstack/react-router
Version:
Modern and scalable routing for React applications
347 lines (346 loc) • 13.2 kB
JavaScript
"use client";
import { createLinkScope, matchContext } from "./matchContext.js";
import { useRouter } from "./useRouter.js";
import { deepEqual, functionalUpdate, getUrlScheme, isDangerousProtocol, preloadWarning, removeTrailingSlash } from "@tanstack/router-core";
import * as React$1 from "react";
import { isServer } from "@tanstack/router-core/isServer";
import { jsx } from "react/jsx-runtime";
//#region src/link.tsx
function useStableValues(...values) {
const ref = React$1.useRef(values);
const stable = ref.current;
values.forEach((value, index) => {
if (!deepEqual(stable[index], value, false, true)) stable[index] = value;
});
return ref.current;
}
function preloadLink(router, options) {
router.preloadRoute(options).catch((err) => {
console.warn(err);
console.warn(preloadWarning);
});
}
function resolveExternalLink(to, protocolAllowlist) {
const scheme = typeof to === "string" && getUrlScheme(to);
if (!scheme) return;
if (!protocolAllowlist.has(scheme)) {
if (process.env.NODE_ENV !== "production") console.warn(`Blocked Link with dangerous protocol: ${to}`);
return null;
}
return to;
}
function resolveIsActive(location, next, activeOptions, basepath) {
const currentPath = removeTrailingSlash(location.pathname, basepath);
const nextPath = removeTrailingSlash(next.pathname, basepath);
if (activeOptions?.exact ? currentPath !== nextPath : !(currentPath.startsWith(nextPath) && (currentPath.length === nextPath.length || currentPath[nextPath.length] === "/"))) return false;
if (activeOptions?.includeSearch ?? true) {
if (!deepEqual(location.search, next.search, !activeOptions?.exact, activeOptions?.explicitUndefined)) return false;
}
return !activeOptions?.includeHash || location.hash === next.hash;
}
function useLinkProps(options, forwardedRef, host) {
const router = useRouter();
if (isServer ?? router.isServer) return getServerLinkProps(router, options, forwardedRef, host);
const innerRef = React$1.useRef(null);
const mergedRef = React$1.useCallback((element) => {
innerRef.current = element;
if (typeof forwardedRef === "function") return forwardedRef(element);
if (forwardedRef) forwardedRef.current = element;
}, [forwardedRef]);
const { activeOptions, to, preload: userPreload, preloadDelay: userPreloadDelay, disabled, target, onClick, onBlur, onFocus, onMouseEnter, onMouseLeave, onTouchStart } = options;
const [stableSearch, stableParams, stableActiveOptions] = useStableValues(options.search, options.params, activeOptions);
const routeId = React$1.useContext(matchContext);
const scope = (router.stores._linkScopes ??= {})[routeId] ??= createLinkScope(router, routeId);
const [dest, getLinkState, getHydrationState] = React$1.useMemo(() => {
const directExternalLink = resolveExternalLink(to, router.protocolAllowlist);
if (directExternalLink !== void 0) {
const state = [directExternalLink ?? void 0];
return [options, () => state];
}
const dest = { ...options };
let source;
let state;
let hydrationState;
const getLinkState = () => {
const location = scope[0]();
if (location !== source) {
dest._fromLocation = location;
const next = router.buildLocation(dest);
const href = getHrefOption(next, router, disabled);
const isActive = disabled || href && !getUrlScheme(href) ? resolveIsActive(location, next, stableActiveOptions, router.basepath) : void 0;
if (!state || state[0] !== href || state[1] !== isActive) state = [href, isActive];
source = location;
}
return state;
};
return [
dest,
getLinkState,
stableActiveOptions?.includeHash && (() => {
const state = getLinkState();
return state[1] ? hydrationState ??= [state[0], false] : state;
})
];
}, [
router,
scope,
options.from,
options.hash,
options.href,
to,
stableSearch,
stableParams,
options.state,
options.mask,
options.unsafeRelative,
stableActiveOptions,
disabled
]);
const [href, isActive] = React$1.useSyncExternalStore(scope[1], getLinkState, getHydrationState || getLinkState);
const externalLink = isActive === void 0 && href;
const linkDisabled = disabled || href === void 0;
const hasRenderFetched = React$1.useRef(false);
const preload = options.reloadDocument || externalLink || linkDisabled ? false : userPreload ?? router.options.defaultPreload;
const preloadDelay = userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0;
const enqueuePreload = React$1.useCallback((e) => {
const isIntersecting = e?.isIntersecting;
if (!(isIntersecting ?? preload === "intent")) {
if (isIntersecting === false) cancelPreload(innerRef);
return;
}
if (!preloadDelay) {
preloadLink(router, dest);
return;
}
innerRef.t ??= setTimeout(() => {
innerRef.t = void 0;
preloadLink(router, dest);
}, preloadDelay);
}, [
router,
dest,
innerRef,
preload,
preloadDelay
]);
React$1.useEffect(() => {
if (!preload) return;
if (preload === "render" && !hasRenderFetched.current) {
hasRenderFetched.current = true;
preloadLink(router, dest);
}
let active = true;
let observer;
if (preload === "viewport" && innerRef.current && typeof IntersectionObserver === "function") {
observer = new IntersectionObserver((entries) => {
if (active) enqueuePreload(entries.pop());
}, { rootMargin: "100px" });
observer.observe(innerRef.current);
}
return () => {
active = false;
observer?.disconnect();
cancelPreload(innerRef);
};
}, [
router,
dest,
preload,
enqueuePreload,
innerRef
]);
const props = collectElementProps(options, host);
props.ref = forwardedRef ? mergedRef : innerRef;
if (externalLink) {
props.href = externalLink;
return props;
}
const handleClick = (e) => {
const effectiveTarget = target ?? e.currentTarget.getAttribute("target");
if (!linkDisabled && !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && !e.defaultPrevented && (!effectiveTarget || effectiveTarget === "_self") && e.button === 0) {
e.preventDefault();
cancelPreload(innerRef);
router.navigate({
...options,
_fromLocation: dest._fromLocation
});
}
};
const handleTouchStart = () => {
if (preload === "intent") preloadLink(router, dest);
};
const handleLeave = () => {
if (preload === "intent") cancelPreload(innerRef);
};
props.onClick = composeHandlers(onClick, handleClick);
props.onBlur = composeHandlers(onBlur, handleLeave);
props.onFocus = composeHandlers(onFocus, enqueuePreload);
props.onMouseEnter = composeHandlers(onMouseEnter, enqueuePreload);
props.onMouseLeave = composeHandlers(onMouseLeave, handleLeave);
props.onTouchStart = composeHandlers(onTouchStart, handleTouchStart);
return applyLinkState(props, options, isActive, href, linkDisabled, host);
}
var STATIC_EMPTY_OBJECT = {};
var STATIC_ACTIVE_OBJECT = { className: "active" };
var ROUTER_OPTION_KEYS = /* @__PURE__ */ new Set([
"to",
"params",
"search",
"hash",
"state",
"mask",
"from",
"unsafeRelative",
"reloadDocument",
"preload",
"preloadDelay",
"preloadIntentProximity",
"hashScrollIntoView",
"replace",
"startTransition",
"resetScroll",
"viewTransition",
"ignoreBlocker",
"activeProps",
"inactiveProps",
"activeOptions",
"_asChild"
]);
function collectElementProps(options, host) {
const props = {};
for (const key in options) {
if (ROUTER_OPTION_KEYS.has(key) || key === "type" && host !== void 0 || key === "disabled" && host === "a") continue;
props[key] = options[key];
}
return props;
}
function applyLinkState(props, options, isActive, href, linkDisabled, host) {
const { activeProps, inactiveProps, className, style, target } = options;
const stateProps = functionalUpdate(isActive ? activeProps : inactiveProps, {}) ?? (isActive ? STATIC_ACTIVE_OBJECT : STATIC_EMPTY_OBJECT);
Object.assign(props, stateProps);
props.href = href;
if (host !== "a") props.disabled = linkDisabled;
props.target = target;
const stateStyle = stateProps.style;
if (style || stateStyle) props.style = style && stateStyle ? {
...style,
...stateStyle
} : style || stateStyle;
const stateClassName = stateProps.className;
if (className || stateClassName) props.className = className ? stateClassName ? `${className} ${stateClassName}` : className : stateClassName;
if (linkDisabled) {
props.role = "link";
props["aria-disabled"] = true;
}
if (isActive) {
props["data-status"] = "active";
props["aria-current"] = "page";
}
return props;
}
function getServerLinkProps(router, options, forwardedRef, host) {
const { to, disabled, activeOptions } = options;
const directExternalLink = resolveExternalLink(to, router.protocolAllowlist);
const next = directExternalLink === void 0 ? router.buildLocation(options) : void 0;
const hrefOption = next ? getHrefOption(next, router, disabled) : directExternalLink ?? void 0;
const linkDisabled = disabled || !hrefOption;
const externalLink = directExternalLink ?? (hrefOption && getUrlScheme(hrefOption) ? hrefOption : void 0);
const props = collectElementProps(options, host);
props.ref = forwardedRef;
if (externalLink) {
props.href = externalLink;
return props;
}
return applyLinkState(props, options, !!next && !(!disabled && !hrefOption) && !activeOptions?.includeHash && resolveIsActive(router.stores.location.get(), next, activeOptions, router.basepath), hrefOption, linkDisabled, host);
}
var cancelPreload = (ref) => {
clearTimeout(ref.t);
ref.t = void 0;
};
var composeHandlers = (first, second) => {
if (!first) return second;
return (event) => event.defaultPrevented || (first(event), event.defaultPrevented || second(event));
};
function getHrefOption(next, router, disabled) {
if (disabled) return;
const location = next.maskedLocation ?? next;
const href = location.external ? location.publicHref : router.history.createHref(location.publicHref) || "/";
if ((location.external || href !== location.publicHref) && isDangerousProtocol(href, router.protocolAllowlist)) {
if (process.env.NODE_ENV !== "production") console.warn(`Blocked Link with dangerous protocol: ${href}`);
return;
}
return href;
}
/**
* 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 focus, hover, or viewport entry
* - `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.memo(React$1.forwardRef((props, ref) => {
const host = props._asChild || "a";
const linkProps = useLinkProps(props, ref, host);
const children = typeof props.children === "function" ? props.children({ isActive: linkProps["data-status"] === "active" }) : props.children;
return React$1.createElement(host, linkProps, children);
}), areLinkPropsEqual);
function areLinkPropsEqual(prev, next) {
let extraKeys = 0;
for (const key in next) {
extraKeys++;
if (prev[key] === next[key]) continue;
if (!ROUTER_OPTION_KEYS.has(key) || !deepEqual(prev[key], next[key], false, true)) return false;
}
for (const _key in prev) extraKeys--;
return extraKeys === 0;
}
/**
* 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, composeHandlers, createLink, linkOptions, useLinkProps };
//# sourceMappingURL=link.js.map