UNPKG

@tanstack/router-core

Version:

Modern and scalable routing for React applications

1,178 lines (1,177 loc) 45.7 kB
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