@tanstack/router-core
Version:
Modern and scalable routing for React applications
1,178 lines (1,177 loc) • 45.7 kB
JavaScript
import { batch as batch$1 } from "./utils/batch.js";
import { DEFAULT_PROTOCOL_ALLOWLIST, createControlledPromise, decodePath, deepEqual, encodePathLikeUrl, findLast, functionalUpdate, isDangerousProtocol, last, nullReplaceEqualDeep, replaceEqualDeep } from "./utils.js";
import { createLRUCache } from "./lru-cache.js";
import { findFlatMatch, findRouteMatch, findSingleMatch, processRouteMasks, processRouteTree } from "./new-process-route-tree.js";
import { cleanPath, compileDecodeCharMap, interpolatePath, resolvePath, trimPath, trimPathRight } from "./path.js";
import { isNotFound } from "./not-found.js";
import { setupScrollRestoration } from "./scroll-restoration.js";
import { defaultParseSearch, defaultStringifySearch } from "./searchParams.js";
import { rootRouteId } from "./root.js";
import { isRedirect, redirect } from "./redirect.js";
import { loadMatches, loadRouteChunk, routeNeedsPreload } from "./load-matches.js";
import { composeRewrites, executeRewriteInput, executeRewriteOutput, rewriteBasepath } from "./rewrite.js";
import { createStore } from "@tanstack/store";
import { createBrowserHistory, parseHref } from "@tanstack/history";
import { isServer } from "@tanstack/router-core/isServer";
//#region src/router.ts
/**
* Convert an unknown error into a minimal, serializable object.
* Includes name and message (and stack in development).
*/
function defaultSerializeError(err) {
if (err instanceof Error) {
const obj = {
name: err.name,
message: err.message
};
if (process.env.NODE_ENV === "development") obj.stack = err.stack;
return obj;
}
return { data: err };
}
/** Options for configuring trailing-slash behavior. */
var trailingSlashOptions = {
always: "always",
never: "never",
preserve: "preserve"
};
/**
* Compute whether path, href or hash changed between previous and current
* resolved locations in router state.
*/
function getLocationChangeInfo(routerState) {
const fromLocation = routerState.resolvedLocation;
const toLocation = routerState.location;
return {
fromLocation,
toLocation,
pathChanged: fromLocation?.pathname !== toLocation.pathname,
hrefChanged: fromLocation?.href !== toLocation.href,
hashChanged: fromLocation?.hash !== toLocation.hash
};
}
function filterRedirectedCachedMatches(matches) {
const filtered = matches.filter((d) => d.status !== "redirected");
return filtered.length === matches.length ? matches : filtered;
}
function createServerStore(initialState) {
const store = {
state: initialState,
setState: (updater) => {
store.state = updater(store.state);
}
};
return store;
}
var RouterCore = class {
/**
* @deprecated Use the `createRouter` function instead
*/
constructor(options) {
this.tempLocationKey = `${Math.round(Math.random() * 1e7)}`;
this.resetNextScroll = true;
this.shouldViewTransition = void 0;
this.isViewTransitionTypesSupported = void 0;
this.subscribers = /* @__PURE__ */ new Set();
this.isScrollRestoring = false;
this.isScrollRestorationSetup = false;
this.startTransition = (fn) => fn();
this.update = (newOptions) => {
if (process.env.NODE_ENV !== "production") {
if (newOptions.notFoundRoute) console.warn("The notFoundRoute API is deprecated and will be removed in the next major version. See https://tanstack.com/router/v1/docs/framework/react/guide/not-found-errors#migrating-from-notfoundroute for more info.");
}
const prevOptions = this.options;
const prevBasepath = this.basepath ?? prevOptions?.basepath ?? "/";
const basepathWasUnset = this.basepath === void 0;
const prevRewriteOption = prevOptions?.rewrite;
this.options = {
...prevOptions,
...newOptions
};
this.isServer = this.options.isServer ?? typeof document === "undefined";
this.protocolAllowlist = new Set(this.options.protocolAllowlist);
if (this.options.pathParamsAllowedCharacters) this.pathParamsDecoder = compileDecodeCharMap(this.options.pathParamsAllowedCharacters);
if (!this.history || this.options.history && this.options.history !== this.history) if (!this.options.history) {
if (!(isServer ?? this.isServer)) this.history = createBrowserHistory();
} else this.history = this.options.history;
this.origin = this.options.origin;
if (!this.origin) if (!(isServer ?? this.isServer) && window?.origin && window.origin !== "null") this.origin = window.origin;
else this.origin = "http://localhost";
if (this.history) this.updateLatestLocation();
if (this.options.routeTree !== this.routeTree) {
this.routeTree = this.options.routeTree;
let processRouteTreeResult;
if ((isServer ?? this.isServer) && process.env.NODE_ENV !== "development" && globalThis.__TSR_CACHE__ && globalThis.__TSR_CACHE__.routeTree === this.routeTree) {
const cached = globalThis.__TSR_CACHE__;
this.resolvePathCache = cached.resolvePathCache;
processRouteTreeResult = cached.processRouteTreeResult;
} else {
this.resolvePathCache = createLRUCache(1e3);
processRouteTreeResult = this.buildRouteTree();
if ((isServer ?? this.isServer) && process.env.NODE_ENV !== "development" && globalThis.__TSR_CACHE__ === void 0) globalThis.__TSR_CACHE__ = {
routeTree: this.routeTree,
processRouteTreeResult,
resolvePathCache: this.resolvePathCache
};
}
this.setRoutes(processRouteTreeResult);
}
if (!this.__store && this.latestLocation) if (isServer ?? this.isServer) this.__store = createServerStore(getInitialRouterState(this.latestLocation));
else {
this.__store = createStore(getInitialRouterState(this.latestLocation));
setupScrollRestoration(this);
}
let needsLocationUpdate = false;
const nextBasepath = this.options.basepath ?? "/";
const nextRewriteOption = this.options.rewrite;
if (basepathWasUnset || prevBasepath !== nextBasepath || prevRewriteOption !== nextRewriteOption) {
this.basepath = nextBasepath;
const rewrites = [];
const trimmed = trimPath(nextBasepath);
if (trimmed && trimmed !== "/") rewrites.push(rewriteBasepath({ basepath: nextBasepath }));
if (nextRewriteOption) rewrites.push(nextRewriteOption);
this.rewrite = rewrites.length === 0 ? void 0 : rewrites.length === 1 ? rewrites[0] : composeRewrites(rewrites);
if (this.history) this.updateLatestLocation();
needsLocationUpdate = true;
}
if (needsLocationUpdate && this.__store) this.__store.setState((s) => ({
...s,
location: this.latestLocation
}));
if (typeof window !== "undefined" && "CSS" in window && typeof window.CSS?.supports === "function") this.isViewTransitionTypesSupported = window.CSS.supports("selector(:active-view-transition-type(a)");
};
this.updateLatestLocation = () => {
this.latestLocation = this.parseLocation(this.history.location, this.latestLocation);
};
this.buildRouteTree = () => {
const result = processRouteTree(this.routeTree, this.options.caseSensitive, (route, i) => {
route.init({ originalIndex: i });
});
if (this.options.routeMasks) processRouteMasks(this.options.routeMasks, result.processedTree);
return result;
};
this.subscribe = (eventType, fn) => {
const listener = {
eventType,
fn
};
this.subscribers.add(listener);
return () => {
this.subscribers.delete(listener);
};
};
this.emit = (routerEvent) => {
this.subscribers.forEach((listener) => {
if (listener.eventType === routerEvent.type) listener.fn(routerEvent);
});
};
this.parseLocation = (locationToParse, previousLocation) => {
const parse = ({ pathname, search, hash, href, state }) => {
if (!this.rewrite && !/[ \x00-\x1f\x7f\u0080-\uffff]/.test(pathname)) {
const parsedSearch = this.options.parseSearch(search);
const searchStr = this.options.stringifySearch(parsedSearch);
return {
href: pathname + searchStr + hash,
publicHref: href,
pathname: decodePath(pathname).path,
external: false,
searchStr,
search: nullReplaceEqualDeep(previousLocation?.search, parsedSearch),
hash: decodePath(hash.slice(1)).path,
state: replaceEqualDeep(previousLocation?.state, state)
};
}
const fullUrl = new URL(href, this.origin);
const url = executeRewriteInput(this.rewrite, fullUrl);
const parsedSearch = this.options.parseSearch(url.search);
const searchStr = this.options.stringifySearch(parsedSearch);
url.search = searchStr;
return {
href: url.href.replace(url.origin, ""),
publicHref: href,
pathname: decodePath(url.pathname).path,
external: !!this.rewrite && url.origin !== this.origin,
searchStr,
search: nullReplaceEqualDeep(previousLocation?.search, parsedSearch),
hash: decodePath(url.hash.slice(1)).path,
state: replaceEqualDeep(previousLocation?.state, state)
};
};
const location = parse(locationToParse);
const { __tempLocation, __tempKey } = location.state;
if (__tempLocation && (!__tempKey || __tempKey === this.tempLocationKey)) {
const parsedTempLocation = parse(__tempLocation);
parsedTempLocation.state.key = location.state.key;
parsedTempLocation.state.__TSR_key = location.state.__TSR_key;
delete parsedTempLocation.state.__tempLocation;
return {
...parsedTempLocation,
maskedLocation: location
};
}
return location;
};
this.resolvePathWithBase = (from, path) => {
return resolvePath({
base: from,
to: cleanPath(path),
trailingSlash: this.options.trailingSlash,
cache: this.resolvePathCache
});
};
this.matchRoutes = (pathnameOrNext, locationSearchOrOpts, opts) => {
if (typeof pathnameOrNext === "string") return this.matchRoutesInternal({
pathname: pathnameOrNext,
search: locationSearchOrOpts
}, opts);
return this.matchRoutesInternal(pathnameOrNext, locationSearchOrOpts);
};
this.getMatchedRoutes = (pathname) => {
return getMatchedRoutes({
pathname,
routesById: this.routesById,
processedTree: this.processedTree
});
};
this.cancelMatch = (id) => {
const match = this.getMatch(id);
if (!match) return;
match.abortController.abort();
clearTimeout(match._nonReactive.pendingTimeout);
match._nonReactive.pendingTimeout = void 0;
};
this.cancelMatches = () => {
const currentPendingMatches = this.state.matches.filter((match) => match.status === "pending");
const currentLoadingMatches = this.state.matches.filter((match) => match.isFetching === "loader");
new Set([
...this.state.pendingMatches ?? [],
...currentPendingMatches,
...currentLoadingMatches
]).forEach((match) => {
this.cancelMatch(match.id);
});
};
this.buildLocation = (opts) => {
const build = (dest = {}) => {
const currentLocation = dest._fromLocation || this.pendingBuiltLocation || this.latestLocation;
const lightweightResult = this.matchRoutesLightweight(currentLocation);
if (dest.from && process.env.NODE_ENV !== "production" && dest._isNavigate) {
const allFromMatches = this.getMatchedRoutes(dest.from).matchedRoutes;
const matchedFrom = findLast(lightweightResult.matchedRoutes, (d) => {
return comparePaths(d.fullPath, dest.from);
});
const matchedCurrent = findLast(allFromMatches, (d) => {
return comparePaths(d.fullPath, lightweightResult.fullPath);
});
if (!matchedFrom && !matchedCurrent) console.warn(`Could not find match for from: ${dest.from}`);
}
const defaultedFromPath = dest.unsafeRelative === "path" ? currentLocation.pathname : dest.from ?? lightweightResult.fullPath;
const fromPath = this.resolvePathWithBase(defaultedFromPath, ".");
const fromSearch = lightweightResult.search;
const fromParams = Object.assign(Object.create(null), lightweightResult.params);
const nextTo = dest.to ? this.resolvePathWithBase(fromPath, `${dest.to}`) : this.resolvePathWithBase(fromPath, ".");
const nextParams = dest.params === false || dest.params === null ? Object.create(null) : (dest.params ?? true) === true ? fromParams : Object.assign(fromParams, functionalUpdate(dest.params, fromParams));
const destMatchResult = this.getMatchedRoutes(nextTo);
let destRoutes = destMatchResult.matchedRoutes;
if ((!destMatchResult.foundRoute || destMatchResult.foundRoute.path !== "/" && destMatchResult.routeParams["**"]) && this.options.notFoundRoute) destRoutes = [...destRoutes, this.options.notFoundRoute];
if (Object.keys(nextParams).length > 0) for (const route of destRoutes) {
const fn = route.options.params?.stringify ?? route.options.stringifyParams;
if (fn) try {
Object.assign(nextParams, fn(nextParams));
} catch {}
}
const nextPathname = opts.leaveParams ? nextTo : decodePath(interpolatePath({
path: nextTo,
params: nextParams,
decoder: this.pathParamsDecoder,
server: this.isServer
}).interpolatedPath).path;
let nextSearch = fromSearch;
if (opts._includeValidateSearch && this.options.search?.strict) {
const validatedSearch = {};
destRoutes.forEach((route) => {
if (route.options.validateSearch) try {
Object.assign(validatedSearch, validateSearch(route.options.validateSearch, {
...validatedSearch,
...nextSearch
}));
} catch {}
});
nextSearch = validatedSearch;
}
nextSearch = applySearchMiddleware({
search: nextSearch,
dest,
destRoutes,
_includeValidateSearch: opts._includeValidateSearch
});
nextSearch = nullReplaceEqualDeep(fromSearch, nextSearch);
const searchStr = this.options.stringifySearch(nextSearch);
const hash = dest.hash === true ? currentLocation.hash : dest.hash ? functionalUpdate(dest.hash, currentLocation.hash) : void 0;
const hashStr = hash ? `#${hash}` : "";
let nextState = dest.state === true ? currentLocation.state : dest.state ? functionalUpdate(dest.state, currentLocation.state) : {};
nextState = replaceEqualDeep(currentLocation.state, nextState);
const fullPath = `${nextPathname}${searchStr}${hashStr}`;
let href;
let publicHref;
let external = false;
if (this.rewrite) {
const url = new URL(fullPath, this.origin);
const rewrittenUrl = executeRewriteOutput(this.rewrite, url);
href = url.href.replace(url.origin, "");
if (rewrittenUrl.origin !== this.origin) {
publicHref = rewrittenUrl.href;
external = true;
} else publicHref = rewrittenUrl.pathname + rewrittenUrl.search + rewrittenUrl.hash;
} else {
href = encodePathLikeUrl(fullPath);
publicHref = href;
}
return {
publicHref,
href,
pathname: nextPathname,
search: nextSearch,
searchStr,
state: nextState,
hash: hash ?? "",
external,
unmaskOnReload: dest.unmaskOnReload
};
};
const buildWithMatches = (dest = {}, maskedDest) => {
const next = build(dest);
let maskedNext = maskedDest ? build(maskedDest) : void 0;
if (!maskedNext) {
const params = Object.create(null);
if (this.options.routeMasks) {
const match = findFlatMatch(next.pathname, this.processedTree);
if (match) {
Object.assign(params, match.rawParams);
const { from: _from, params: maskParams, ...maskProps } = match.route;
const nextParams = maskParams === false || maskParams === null ? Object.create(null) : (maskParams ?? true) === true ? params : Object.assign(params, functionalUpdate(maskParams, params));
maskedDest = {
from: opts.from,
...maskProps,
params: nextParams
};
maskedNext = build(maskedDest);
}
}
}
if (maskedNext) next.maskedLocation = maskedNext;
return next;
};
if (opts.mask) return buildWithMatches(opts, {
from: opts.from,
...opts.mask
});
return buildWithMatches(opts);
};
this.commitLocation = async ({ viewTransition, ignoreBlocker, ...next }) => {
const isSameState = () => {
const ignoredProps = [
"key",
"__TSR_key",
"__TSR_index",
"__hashScrollIntoViewOptions"
];
ignoredProps.forEach((prop) => {
next.state[prop] = this.latestLocation.state[prop];
});
const isEqual = deepEqual(next.state, this.latestLocation.state);
ignoredProps.forEach((prop) => {
delete next.state[prop];
});
return isEqual;
};
const isSameUrl = trimPathRight(this.latestLocation.href) === trimPathRight(next.href);
let previousCommitPromise = this.commitLocationPromise;
this.commitLocationPromise = createControlledPromise(() => {
previousCommitPromise?.resolve();
previousCommitPromise = void 0;
});
if (isSameUrl && isSameState()) this.load();
else {
let { maskedLocation, hashScrollIntoView, ...nextHistory } = next;
if (maskedLocation) {
nextHistory = {
...maskedLocation,
state: {
...maskedLocation.state,
__tempKey: void 0,
__tempLocation: {
...nextHistory,
search: nextHistory.searchStr,
state: {
...nextHistory.state,
__tempKey: void 0,
__tempLocation: void 0,
__TSR_key: void 0,
key: void 0
}
}
}
};
if (nextHistory.unmaskOnReload ?? this.options.unmaskOnReload ?? false) nextHistory.state.__tempKey = this.tempLocationKey;
}
nextHistory.state.__hashScrollIntoViewOptions = hashScrollIntoView ?? this.options.defaultHashScrollIntoView ?? true;
this.shouldViewTransition = viewTransition;
this.history[next.replace ? "replace" : "push"](nextHistory.publicHref, nextHistory.state, { ignoreBlocker });
}
this.resetNextScroll = next.resetScroll ?? true;
if (!this.history.subscribers.size) this.load();
return this.commitLocationPromise;
};
this.buildAndCommitLocation = ({ replace, resetScroll, hashScrollIntoView, viewTransition, ignoreBlocker, href, ...rest } = {}) => {
if (href) {
const currentIndex = this.history.location.state.__TSR_index;
const parsed = parseHref(href, { __TSR_index: replace ? currentIndex : currentIndex + 1 });
const hrefUrl = new URL(parsed.pathname, this.origin);
rest.to = executeRewriteInput(this.rewrite, hrefUrl).pathname;
rest.search = this.options.parseSearch(parsed.search);
rest.hash = parsed.hash.slice(1);
}
const location = this.buildLocation({
...rest,
_includeValidateSearch: true
});
this.pendingBuiltLocation = location;
const commitPromise = this.commitLocation({
...location,
viewTransition,
replace,
resetScroll,
hashScrollIntoView,
ignoreBlocker
});
Promise.resolve().then(() => {
if (this.pendingBuiltLocation === location) this.pendingBuiltLocation = void 0;
});
return commitPromise;
};
this.navigate = async ({ to, reloadDocument, href, publicHref, ...rest }) => {
let hrefIsUrl = false;
if (href) try {
new URL(`${href}`);
hrefIsUrl = true;
} catch {}
if (hrefIsUrl && !reloadDocument) reloadDocument = true;
if (reloadDocument) {
if (to !== void 0 || !href) {
const location = this.buildLocation({
to,
...rest
});
href = href ?? location.publicHref;
publicHref = publicHref ?? location.publicHref;
}
const reloadHref = !hrefIsUrl && publicHref ? publicHref : href;
if (isDangerousProtocol(reloadHref, this.protocolAllowlist)) {
if (process.env.NODE_ENV !== "production") console.warn(`Blocked navigation to dangerous protocol: ${reloadHref}`);
return Promise.resolve();
}
if (!rest.ignoreBlocker) {
const blockers = this.history.getBlockers?.() ?? [];
for (const blocker of blockers) if (blocker?.blockerFn) {
if (await blocker.blockerFn({
currentLocation: this.latestLocation,
nextLocation: this.latestLocation,
action: "PUSH"
})) return Promise.resolve();
}
}
if (rest.replace) window.location.replace(reloadHref);
else window.location.href = reloadHref;
return Promise.resolve();
}
return this.buildAndCommitLocation({
...rest,
href,
to,
_isNavigate: true
});
};
this.beforeLoad = () => {
this.cancelMatches();
this.updateLatestLocation();
if (isServer ?? this.isServer) {
const nextLocation = this.buildLocation({
to: this.latestLocation.pathname,
search: true,
params: true,
hash: true,
state: true,
_includeValidateSearch: true
});
if (this.latestLocation.publicHref !== nextLocation.publicHref) {
const href = this.getParsedLocationHref(nextLocation);
if (nextLocation.external) throw redirect({ href });
else throw redirect({
href,
_builtLocation: nextLocation
});
}
}
const pendingMatches = this.matchRoutes(this.latestLocation);
this.__store.setState((s) => ({
...s,
status: "pending",
statusCode: 200,
isLoading: true,
location: this.latestLocation,
pendingMatches,
cachedMatches: s.cachedMatches.filter((d) => !pendingMatches.some((e) => e.id === d.id))
}));
};
this.load = async (opts) => {
let redirect;
let notFound;
let loadPromise;
const previousLocation = this.state.resolvedLocation ?? this.state.location;
loadPromise = new Promise((resolve) => {
this.startTransition(async () => {
try {
this.beforeLoad();
const next = this.latestLocation;
const prevLocation = this.state.resolvedLocation;
if (!this.state.redirect) this.emit({
type: "onBeforeNavigate",
...getLocationChangeInfo({
resolvedLocation: prevLocation,
location: next
})
});
this.emit({
type: "onBeforeLoad",
...getLocationChangeInfo({
resolvedLocation: prevLocation,
location: next
})
});
await loadMatches({
router: this,
sync: opts?.sync,
forceStaleReload: previousLocation.href === next.href,
matches: this.state.pendingMatches,
location: next,
updateMatch: this.updateMatch,
onReady: async () => {
this.startTransition(() => {
this.startViewTransition(async () => {
let exitingMatches = [];
let hookExitingMatches = [];
let hookEnteringMatches = [];
let hookStayingMatches = [];
batch$1(() => {
this.__store.setState((s) => {
const previousMatches = s.matches;
const newMatches = s.pendingMatches || s.matches;
exitingMatches = previousMatches.filter((match) => !newMatches.some((d) => d.id === match.id));
hookExitingMatches = previousMatches.filter((match) => !newMatches.some((d) => d.routeId === match.routeId));
hookEnteringMatches = newMatches.filter((match) => !previousMatches.some((d) => d.routeId === match.routeId));
hookStayingMatches = newMatches.filter((match) => previousMatches.some((d) => d.routeId === match.routeId));
return {
...s,
isLoading: false,
loadedAt: Date.now(),
matches: newMatches,
pendingMatches: void 0,
cachedMatches: [...s.cachedMatches, ...exitingMatches.filter((d) => d.status !== "error" && d.status !== "notFound" && d.status !== "redirected")]
};
});
this.clearExpiredCache();
});
[
[hookExitingMatches, "onLeave"],
[hookEnteringMatches, "onEnter"],
[hookStayingMatches, "onStay"]
].forEach(([matches, hook]) => {
matches.forEach((match) => {
this.looseRoutesById[match.routeId].options[hook]?.(match);
});
});
});
});
}
});
} catch (err) {
if (isRedirect(err)) {
redirect = err;
if (!(isServer ?? this.isServer)) this.navigate({
...redirect.options,
replace: true,
ignoreBlocker: true
});
} else if (isNotFound(err)) notFound = err;
this.__store.setState((s) => ({
...s,
statusCode: redirect ? redirect.status : notFound ? 404 : s.matches.some((d) => d.status === "error") ? 500 : 200,
redirect
}));
}
if (this.latestLoadPromise === loadPromise) {
this.commitLocationPromise?.resolve();
this.latestLoadPromise = void 0;
this.commitLocationPromise = void 0;
}
resolve();
});
});
this.latestLoadPromise = loadPromise;
await loadPromise;
while (this.latestLoadPromise && loadPromise !== this.latestLoadPromise) await this.latestLoadPromise;
let newStatusCode = void 0;
if (this.hasNotFoundMatch()) newStatusCode = 404;
else if (this.__store.state.matches.some((d) => d.status === "error")) newStatusCode = 500;
if (newStatusCode !== void 0) this.__store.setState((s) => ({
...s,
statusCode: newStatusCode
}));
};
this.startViewTransition = (fn) => {
const shouldViewTransition = this.shouldViewTransition ?? this.options.defaultViewTransition;
this.shouldViewTransition = void 0;
if (shouldViewTransition && typeof document !== "undefined" && "startViewTransition" in document && typeof document.startViewTransition === "function") {
let startViewTransitionParams;
if (typeof shouldViewTransition === "object" && this.isViewTransitionTypesSupported) {
const next = this.latestLocation;
const prevLocation = this.state.resolvedLocation;
const resolvedViewTransitionTypes = typeof shouldViewTransition.types === "function" ? shouldViewTransition.types(getLocationChangeInfo({
resolvedLocation: prevLocation,
location: next
})) : shouldViewTransition.types;
if (resolvedViewTransitionTypes === false) {
fn();
return;
}
startViewTransitionParams = {
update: fn,
types: resolvedViewTransitionTypes
};
} else startViewTransitionParams = fn;
document.startViewTransition(startViewTransitionParams);
} else fn();
};
this.updateMatch = (id, updater) => {
this.startTransition(() => {
const matchesKey = this.state.pendingMatches?.some((d) => d.id === id) ? "pendingMatches" : this.state.matches.some((d) => d.id === id) ? "matches" : this.state.cachedMatches.some((d) => d.id === id) ? "cachedMatches" : "";
if (matchesKey) if (matchesKey === "cachedMatches") this.__store.setState((s) => ({
...s,
cachedMatches: filterRedirectedCachedMatches(s.cachedMatches.map((d) => d.id === id ? updater(d) : d))
}));
else this.__store.setState((s) => ({
...s,
[matchesKey]: s[matchesKey]?.map((d) => d.id === id ? updater(d) : d)
}));
});
};
this.getMatch = (matchId) => {
const findFn = (d) => d.id === matchId;
return this.state.cachedMatches.find(findFn) ?? this.state.pendingMatches?.find(findFn) ?? this.state.matches.find(findFn);
};
this.invalidate = (opts) => {
const invalidate = (d) => {
if (opts?.filter?.(d) ?? true) return {
...d,
invalid: true,
...opts?.forcePending || d.status === "error" || d.status === "notFound" ? {
status: "pending",
error: void 0
} : void 0
};
return d;
};
this.__store.setState((s) => ({
...s,
matches: s.matches.map(invalidate),
cachedMatches: s.cachedMatches.map(invalidate),
pendingMatches: s.pendingMatches?.map(invalidate)
}));
this.shouldViewTransition = false;
return this.load({ sync: opts?.sync });
};
this.getParsedLocationHref = (location) => {
return location.publicHref || "/";
};
this.resolveRedirect = (redirect) => {
const locationHeader = redirect.headers.get("Location");
if (!redirect.options.href || redirect.options._builtLocation) {
const location = redirect.options._builtLocation ?? this.buildLocation(redirect.options);
const href = this.getParsedLocationHref(location);
redirect.options.href = href;
redirect.headers.set("Location", href);
} else if (locationHeader) try {
const url = new URL(locationHeader);
if (this.origin && url.origin === this.origin) {
const href = url.pathname + url.search + url.hash;
redirect.options.href = href;
redirect.headers.set("Location", href);
}
} catch {}
if (redirect.options.href && !redirect.options._builtLocation && isDangerousProtocol(redirect.options.href, this.protocolAllowlist)) throw new Error(process.env.NODE_ENV !== "production" ? `Redirect blocked: unsafe protocol in href "${redirect.options.href}". Allowed protocols: ${Array.from(this.protocolAllowlist).join(", ")}.` : "Redirect blocked: unsafe protocol");
if (!redirect.headers.get("Location")) redirect.headers.set("Location", redirect.options.href);
return redirect;
};
this.clearCache = (opts) => {
const filter = opts?.filter;
if (filter !== void 0) this.__store.setState((s) => {
return {
...s,
cachedMatches: s.cachedMatches.filter((m) => !filter(m))
};
});
else this.__store.setState((s) => {
return {
...s,
cachedMatches: []
};
});
};
this.clearExpiredCache = () => {
const filter = (d) => {
const route = this.looseRoutesById[d.routeId];
if (!route.options.loader) return true;
const gcTime = (d.preload ? route.options.preloadGcTime ?? this.options.defaultPreloadGcTime : route.options.gcTime ?? this.options.defaultGcTime) ?? 300 * 1e3;
if (d.status === "error") return true;
return Date.now() - d.updatedAt >= gcTime;
};
this.clearCache({ filter });
};
this.loadRouteChunk = loadRouteChunk;
this.preloadRoute = async (opts) => {
const next = opts._builtLocation ?? this.buildLocation(opts);
let matches = this.matchRoutes(next, {
throwOnError: true,
preload: true,
dest: opts
});
const activeMatchIds = new Set([...this.state.matches, ...this.state.pendingMatches ?? []].map((d) => d.id));
const loadedMatchIds = new Set([...activeMatchIds, ...this.state.cachedMatches.map((d) => d.id)]);
batch$1(() => {
matches.forEach((match) => {
if (!loadedMatchIds.has(match.id)) this.__store.setState((s) => ({
...s,
cachedMatches: [...s.cachedMatches, match]
}));
});
});
try {
matches = await loadMatches({
router: this,
matches,
location: next,
preload: true,
updateMatch: (id, updater) => {
if (activeMatchIds.has(id)) matches = matches.map((d) => d.id === id ? updater(d) : d);
else this.updateMatch(id, updater);
}
});
return matches;
} catch (err) {
if (isRedirect(err)) {
if (err.options.reloadDocument) return;
return await this.preloadRoute({
...err.options,
_fromLocation: next
});
}
if (!isNotFound(err)) console.error(err);
return;
}
};
this.matchRoute = (location, opts) => {
const matchLocation = {
...location,
to: location.to ? this.resolvePathWithBase(location.from || "", location.to) : void 0,
params: location.params || {},
leaveParams: true
};
const next = this.buildLocation(matchLocation);
if (opts?.pending && this.state.status !== "pending") return false;
const baseLocation = (opts?.pending === void 0 ? !this.state.isLoading : opts.pending) ? this.latestLocation : this.state.resolvedLocation || this.state.location;
const match = findSingleMatch(next.pathname, opts?.caseSensitive ?? false, opts?.fuzzy ?? false, baseLocation.pathname, this.processedTree);
if (!match) return false;
if (location.params) {
if (!deepEqual(match.rawParams, location.params, { partial: true })) return false;
}
if (opts?.includeSearch ?? true) return deepEqual(baseLocation.search, next.search, { partial: true }) ? match.rawParams : false;
return match.rawParams;
};
this.hasNotFoundMatch = () => {
return this.__store.state.matches.some((d) => d.status === "notFound" || d.globalNotFound);
};
this.update({
defaultPreloadDelay: 50,
defaultPendingMs: 1e3,
defaultPendingMinMs: 500,
context: void 0,
...options,
caseSensitive: options.caseSensitive ?? false,
notFoundMode: options.notFoundMode ?? "fuzzy",
stringifySearch: options.stringifySearch ?? defaultStringifySearch,
parseSearch: options.parseSearch ?? defaultParseSearch,
protocolAllowlist: options.protocolAllowlist ?? DEFAULT_PROTOCOL_ALLOWLIST
});
if (typeof document !== "undefined") self.__TSR_ROUTER__ = this;
}
isShell() {
return !!this.options.isShell;
}
isPrerendering() {
return !!this.options.isPrerendering;
}
get state() {
return this.__store.state;
}
setRoutes({ routesById, routesByPath, processedTree }) {
this.routesById = routesById;
this.routesByPath = routesByPath;
this.processedTree = processedTree;
const notFoundRoute = this.options.notFoundRoute;
if (notFoundRoute) {
notFoundRoute.init({ originalIndex: 99999999999 });
this.routesById[notFoundRoute.id] = notFoundRoute;
}
}
get looseRoutesById() {
return this.routesById;
}
getParentContext(parentMatch) {
return !parentMatch?.id ? this.options.context ?? void 0 : parentMatch.context ?? this.options.context ?? void 0;
}
matchRoutesInternal(next, opts) {
const matchedRoutesResult = this.getMatchedRoutes(next.pathname);
const { foundRoute, routeParams, parsedParams } = matchedRoutesResult;
let { matchedRoutes } = matchedRoutesResult;
let isGlobalNotFound = false;
if (foundRoute ? foundRoute.path !== "/" && routeParams["**"] : trimPathRight(next.pathname)) if (this.options.notFoundRoute) matchedRoutes = [...matchedRoutes, this.options.notFoundRoute];
else isGlobalNotFound = true;
const globalNotFoundRouteId = isGlobalNotFound ? findGlobalNotFoundRouteId(this.options.notFoundMode, matchedRoutes) : void 0;
const matches = new Array(matchedRoutes.length);
const previousMatchesByRouteId = new Map(this.state.matches.map((match) => [match.routeId, match]));
for (let index = 0; index < matchedRoutes.length; index++) {
const route = matchedRoutes[index];
const parentMatch = matches[index - 1];
let preMatchSearch;
let strictMatchSearch;
let searchError;
{
const parentSearch = parentMatch?.search ?? next.search;
const parentStrictSearch = parentMatch?._strictSearch ?? void 0;
try {
const strictSearch = validateSearch(route.options.validateSearch, { ...parentSearch }) ?? void 0;
preMatchSearch = {
...parentSearch,
...strictSearch
};
strictMatchSearch = {
...parentStrictSearch,
...strictSearch
};
searchError = void 0;
} catch (err) {
let searchParamError = err;
if (!(err instanceof SearchParamError)) searchParamError = new SearchParamError(err.message, { cause: err });
if (opts?.throwOnError) throw searchParamError;
preMatchSearch = parentSearch;
strictMatchSearch = {};
searchError = searchParamError;
}
}
const loaderDeps = route.options.loaderDeps?.({ search: preMatchSearch }) ?? "";
const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : "";
const { interpolatedPath, usedParams } = interpolatePath({
path: route.fullPath,
params: routeParams,
decoder: this.pathParamsDecoder,
server: this.isServer
});
const matchId = route.id + interpolatedPath + loaderDepsHash;
const existingMatch = this.getMatch(matchId);
const previousMatch = previousMatchesByRouteId.get(route.id);
const strictParams = existingMatch?._strictParams ?? usedParams;
let paramsError = void 0;
if (!existingMatch) try {
extractStrictParams(route, usedParams, parsedParams, strictParams);
} catch (err) {
if (isNotFound(err) || isRedirect(err)) paramsError = err;
else paramsError = new PathParamError(err.message, { cause: err });
if (opts?.throwOnError) throw paramsError;
}
Object.assign(routeParams, strictParams);
const cause = previousMatch ? "stay" : "enter";
let match;
if (existingMatch) match = {
...existingMatch,
cause,
params: previousMatch?.params ?? routeParams,
_strictParams: strictParams,
search: previousMatch ? nullReplaceEqualDeep(previousMatch.search, preMatchSearch) : nullReplaceEqualDeep(existingMatch.search, preMatchSearch),
_strictSearch: strictMatchSearch
};
else {
const status = route.options.loader || route.options.beforeLoad || route.lazyFn || routeNeedsPreload(route) ? "pending" : "success";
match = {
id: matchId,
ssr: isServer ?? this.isServer ? void 0 : route.options.ssr,
index,
routeId: route.id,
params: previousMatch?.params ?? routeParams,
_strictParams: strictParams,
pathname: interpolatedPath,
updatedAt: Date.now(),
search: previousMatch ? nullReplaceEqualDeep(previousMatch.search, preMatchSearch) : preMatchSearch,
_strictSearch: strictMatchSearch,
searchError: void 0,
status,
isFetching: false,
error: void 0,
paramsError,
__routeContext: void 0,
_nonReactive: { loadPromise: createControlledPromise() },
__beforeLoadContext: void 0,
context: {},
abortController: new AbortController(),
fetchCount: 0,
cause,
loaderDeps: previousMatch ? replaceEqualDeep(previousMatch.loaderDeps, loaderDeps) : loaderDeps,
invalid: false,
preload: false,
links: void 0,
scripts: void 0,
headScripts: void 0,
meta: void 0,
staticData: route.options.staticData || {},
fullPath: route.fullPath
};
}
if (!opts?.preload) match.globalNotFound = globalNotFoundRouteId === route.id;
match.searchError = searchError;
const parentContext = this.getParentContext(parentMatch);
match.context = {
...parentContext,
...match.__routeContext,
...match.__beforeLoadContext
};
matches[index] = match;
}
for (let index = 0; index < matches.length; index++) {
const match = matches[index];
const route = this.looseRoutesById[match.routeId];
const existingMatch = this.getMatch(match.id);
const previousMatch = previousMatchesByRouteId.get(match.routeId);
match.params = previousMatch ? nullReplaceEqualDeep(previousMatch.params, routeParams) : routeParams;
if (!existingMatch) {
const parentMatch = matches[index - 1];
const parentContext = this.getParentContext(parentMatch);
if (route.options.context) {
const contextFnContext = {
deps: match.loaderDeps,
params: match.params,
context: parentContext ?? {},
location: next,
navigate: (opts) => this.navigate({
...opts,
_fromLocation: next
}),
buildLocation: this.buildLocation,
cause: match.cause,
abortController: match.abortController,
preload: !!match.preload,
matches,
routeId: route.id
};
match.__routeContext = route.options.context(contextFnContext) ?? void 0;
}
match.context = {
...parentContext,
...match.__routeContext,
...match.__beforeLoadContext
};
}
}
return matches;
}
/**
* Lightweight route matching for buildLocation.
* Only computes fullPath, accumulated search, and params - skipping expensive
* operations like AbortController, ControlledPromise, loaderDeps, and full match objects.
*/
matchRoutesLightweight(location) {
const { matchedRoutes, routeParams, parsedParams } = this.getMatchedRoutes(location.pathname);
const lastRoute = last(matchedRoutes);
const accumulatedSearch = { ...location.search };
for (const route of matchedRoutes) try {
Object.assign(accumulatedSearch, validateSearch(route.options.validateSearch, accumulatedSearch));
} catch {}
const lastStateMatch = last(this.state.matches);
const canReuseParams = lastStateMatch && lastStateMatch.routeId === lastRoute.id && location.pathname === this.state.location.pathname;
let params;
if (canReuseParams) params = lastStateMatch.params;
else {
const strictParams = Object.assign(Object.create(null), routeParams);
for (const route of matchedRoutes) try {
extractStrictParams(route, routeParams, parsedParams ?? {}, strictParams);
} catch {}
params = strictParams;
}
return {
matchedRoutes,
fullPath: lastRoute.fullPath,
search: accumulatedSearch,
params
};
}
};
/** Error thrown when search parameter validation fails. */
var SearchParamError = class extends Error {};
/** Error thrown when path parameter parsing/validation fails. */
var PathParamError = class extends Error {};
var normalize = (str) => str.endsWith("/") && str.length > 1 ? str.slice(0, -1) : str;
function comparePaths(a, b) {
return normalize(a) === normalize(b);
}
/**
* Lazily import a module function and forward arguments to it, retaining
* parameter and return types for the selected export key.
*/
function lazyFn(fn, key) {
return async (...args) => {
return (await fn())[key || "default"](...args);
};
}
/** Create an initial RouterState from a parsed location. */
function getInitialRouterState(location) {
return {
loadedAt: 0,
isLoading: false,
isTransitioning: false,
status: "idle",
resolvedLocation: void 0,
location,
matches: [],
pendingMatches: [],
cachedMatches: [],
statusCode: 200
};
}
function validateSearch(validateSearch, input) {
if (validateSearch == null) return {};
if ("~standard" in validateSearch) {
const result = validateSearch["~standard"].validate(input);
if (result instanceof Promise) throw new SearchParamError("Async validation not supported");
if (result.issues) throw new SearchParamError(JSON.stringify(result.issues, void 0, 2), { cause: result });
return result.value;
}
if ("parse" in validateSearch) return validateSearch.parse(input);
if (typeof validateSearch === "function") return validateSearch(input);
return {};
}
/**
* Build the matched route chain and extract params for a pathname.
* Falls back to the root route if no specific route is found.
*/
function getMatchedRoutes({ pathname, routesById, processedTree }) {
const routeParams = Object.create(null);
const trimmedPath = trimPathRight(pathname);
let foundRoute = void 0;
let parsedParams = void 0;
const match = findRouteMatch(trimmedPath, processedTree, true);
if (match) {
foundRoute = match.route;
Object.assign(routeParams, match.rawParams);
parsedParams = Object.assign(Object.create(null), match.parsedParams);
}
return {
matchedRoutes: match?.branch || [routesById["__root__"]],
routeParams,
foundRoute,
parsedParams
};
}
/**
* TODO: once caches are persisted across requests on the server,
* we can cache the built middleware chain using `last(destRoutes)` as the key
*/
function applySearchMiddleware({ search, dest, destRoutes, _includeValidateSearch }) {
return buildMiddlewareChain(destRoutes)(search, dest, _includeValidateSearch ?? false);
}
function buildMiddlewareChain(destRoutes) {
const context = {
dest: null,
_includeValidateSearch: false,
middlewares: []
};
for (const route of destRoutes) {
if ("search" in route.options) {
if (route.options.search?.middlewares) context.middlewares.push(...route.options.search.middlewares);
} else if (route.options.preSearchFilters || route.options.postSearchFilters) {
const legacyMiddleware = ({ search, next }) => {
let nextSearch = search;
if ("preSearchFilters" in route.options && route.options.preSearchFilters) nextSearch = route.options.preSearchFilters.reduce((prev, next) => next(prev), search);
const result = next(nextSearch);
if ("postSearchFilters" in route.options && route.options.postSearchFilters) return route.options.postSearchFilters.reduce((prev, next) => next(prev), result);
return result;
};
context.middlewares.push(legacyMiddleware);
}
if (route.options.validateSearch) {
const validate = ({ search, next }) => {
const result = next(search);
if (!context._includeValidateSearch) return result;
try {
return {
...result,
...validateSearch(route.options.validateSearch, result) ?? void 0
};
} catch {
return result;
}
};
context.middlewares.push(validate);
}
}
const final = ({ search }) => {
const dest = context.dest;
if (!dest.search) return {};
if (dest.search === true) return search;
return functionalUpdate(dest.search, search);
};
context.middlewares.push(final);
const applyNext = (index, currentSearch, middlewares) => {
if (index >= middlewares.length) return currentSearch;
const middleware = middlewares[index];
const next = (newSearch) => {
return applyNext(index + 1, newSearch, middlewares);
};
return middleware({
search: currentSearch,
next
});
};
return function middleware(search, dest, _includeValidateSearch) {
context.dest = dest;
context._includeValidateSearch = _includeValidateSearch;
return applyNext(0, search, context.middlewares);
};
}
function findGlobalNotFoundRouteId(notFoundMode, routes) {
if (notFoundMode !== "root") for (let i = routes.length - 1; i >= 0; i--) {
const route = routes[i];
if (route.children) return route.id;
}
return rootRouteId;
}
function extractStrictParams(route, referenceParams, parsedParams, accumulatedParams) {
const parseParams = route.options.params?.parse ?? route.options.parseParams;
if (parseParams) if (route.options.skipRouteOnParseError) {
for (const key in referenceParams) if (key in parsedParams) accumulatedParams[key] = parsedParams[key];
} else {
const result = parseParams(accumulatedParams);
Object.assign(accumulatedParams, result);
}
}
//#endregion
export { PathParamError, RouterCore, SearchParamError, defaultSerializeError, getInitialRouterState, getLocationChangeInfo, getMatchedRoutes, lazyFn, trailingSlashOptions };
//# sourceMappingURL=router.js.map