UNPKG

fully-react

Version:
1,317 lines (1,101 loc) 31.1 kB
import invariant from "tiny-invariant"; import { LinkInfo, LinkOptions, NavigateOptions, ToOptions, ValidFromPath, ResolveRelativePath, } from "./link"; import { cleanPath, interpolatePath, joinPaths, matchPathname, parsePathname, resolvePath, trimPath, trimPathLeft, } from "./path"; import { Route, AnySearchSchema, AnyRoute, RootRoute, AnyContext, AnyRootRoute, } from "./route"; import { RoutesInfo, AnyRoutesInfo, RoutesById } from "./routeInfo"; import { AnyRouteMatch, RouteMatch, RouteMatchState } from "./routeMatch"; import { defaultParseSearch, defaultStringifySearch } from "./searchParams"; import { functionalUpdate, last, NoInfer, pick, PickAsRequired, Timeout, Updater, replaceEqualDeep, partialDeepEqual, } from "./utils"; import { createBrowserHistory, createMemoryHistory, RouterHistory, } from "./history"; import { RouteComponent } from "./react"; // declare global { interface Window { __DEHYDRATED__?: Record<string, any>; } } export interface Register { // router: Router } export type AnyRouter = Router<any, any, any>; export type RegisteredRouter = Register extends { router: Router<infer TRoute, infer TRoutesInfo>; } ? Router<TRoute, TRoutesInfo> : Router; export type RegisteredRoutesInfo = Register extends { router: Router<infer TRoute, infer TRoutesInfo>; } ? TRoutesInfo : AnyRoutesInfo; export interface LocationState {} export interface ParsedLocation< TSearchObj extends AnySearchSchema = {}, TState extends LocationState = LocationState, > { href: string; pathname: string; search: TSearchObj; searchStr: string; state: TState; hash: string; key?: string; } export interface FromLocation { pathname: string; search?: unknown; key?: string; hash?: string; } export type SearchSerializer = (searchObj: Record<string, any>) => string; export type SearchParser = (searchStr: string) => Record<string, any>; export type FilterRoutesFn = <TRoute extends AnyRoute>( routes: TRoute[], ) => TRoute[]; type RouterContextOptions<TRouteTree extends AnyRoute> = AnyContext extends TRouteTree["__types"]["routerContext"] ? { context?: TRouteTree["__types"]["routerContext"]; } : { context: TRouteTree["__types"]["routerContext"]; }; export interface RouterOptions< TRouteTree extends AnyRoute, TDehydrated extends Record<string, any>, > { history?: RouterHistory; stringifySearch?: SearchSerializer; parseSearch?: SearchParser; filterRoutes?: FilterRoutesFn; defaultPreload?: false | "intent"; defaultPreloadDelay?: number; defaultComponent?: RouteComponent; defaultErrorComponent?: RouteComponent<{ error: Error; info: { componentStack: string }; }>; defaultPendingComponent?: RouteComponent; defaultLoaderMaxAge?: number; defaultLoaderGcMaxAge?: number; caseSensitive?: boolean; routeTree?: TRouteTree; basepath?: string; createRoute?: (opts: { route: AnyRoute; router: AnyRouter }) => void; onRouteChange?: () => void; fetchServerDataFn?: FetchServerDataFn; context?: TRouteTree["__types"]["routerContext"]; Provider?: React.ComponentType<{ children: any }>; dehydrate?: () => TDehydrated; hydrate?: (dehydrated: TDehydrated) => void; } type FetchServerDataFn = (ctx: { router: AnyRouter; routeMatch: RouteMatch; }) => Promise<any>; export interface RouterState< TRoutesInfo extends AnyRoutesInfo = AnyRoutesInfo, TState extends LocationState = LocationState, > { status: "idle" | "pending"; latestLocation: ParsedLocation<TRoutesInfo["fullSearchSchema"], TState>; currentMatches: RouteMatch<TRoutesInfo, TRoutesInfo["routeIntersection"]>[]; currentLocation: ParsedLocation<TRoutesInfo["fullSearchSchema"], TState>; pendingMatches?: RouteMatch<TRoutesInfo, TRoutesInfo["routeIntersection"]>[]; pendingLocation?: ParsedLocation<TRoutesInfo["fullSearchSchema"], TState>; lastUpdated: number; } export type ListenerFn = () => void; export interface BuildNextOptions { to?: string | number | null; params?: true | Updater<unknown>; search?: true | Updater<unknown>; hash?: true | Updater<string>; state?: LocationState; key?: string; from?: string; fromCurrent?: boolean; __matches?: RouteMatch[]; } export type MatchCacheEntry = { gc: number; match: RouteMatch; }; export interface MatchLocation { to?: string | number | null; fuzzy?: boolean; caseSensitive?: boolean; from?: string; fromCurrent?: boolean; } export interface MatchRouteOptions { pending?: boolean; caseSensitive?: boolean; includeSearch?: boolean; fuzzy?: boolean; } type LinkCurrentTargetElement = { preloadTimeout?: null | ReturnType<typeof setTimeout>; }; export interface DehydratedRouterState extends Pick< RouterState, "status" | "latestLocation" | "currentLocation" | "lastUpdated" > { // currentMatches: DehydratedRouteMatch[] } export interface DehydratedRouter { state: DehydratedRouterState; } export type MatchCache = Record<string, MatchCacheEntry>; interface DehydratedRouteMatch { id: string; state: Pick<RouteMatchState<any, any>, "status">; } export const defaultFetchServerDataFn: FetchServerDataFn = async ({ router, routeMatch, }) => { const next = router.buildNext({ to: ".", search: (d: any) => ({ ...(d ?? {}), __data: { matchId: routeMatch.id, }, }), }); const res = await fetch(next.href, { method: "GET", signal: routeMatch.abortController.signal, }); if (res.ok) { return res.json(); } throw new Error("Failed to fetch match data"); }; export type RouterConstructorOptions< TRouteTree extends AnyRoute, TDehydrated extends Record<string, any>, > = Omit<RouterOptions<TRouteTree, TDehydrated>, "context"> & RouterContextOptions<TRouteTree>; export class Router< TRouteTree extends AnyRoute = AnyRoute, TRoutesInfo extends AnyRoutesInfo = RoutesInfo<TRouteTree>, TDehydrated extends Record<string, any> = Record<string, any>, > { types!: { // Super secret internal stuff RootRoute: TRouteTree; RoutesInfo: TRoutesInfo; }; options: PickAsRequired< RouterOptions<TRouteTree, TDehydrated>, "stringifySearch" | "parseSearch" | "context" >; context!: NonNullable<TRouteTree["__types"]["routerContext"]>; history!: RouterHistory; #unsubHistory?: () => void; basepath!: string; // __location: Location<TRoutesInfo['fullSearchSchema']> routeTree!: RootRoute; routesById!: RoutesById<TRoutesInfo>; navigateTimeout: undefined | Timeout; nextAction: undefined | "push" | "replace"; navigationPromise: undefined | Promise<void>; __store: Store<RouterState<TRoutesInfo>>; state: RouterState<TRoutesInfo>; startedLoadingAt = Date.now(); resolveNavigation: () => void = () => {}; constructor(options?: RouterConstructorOptions<TRouteTree, TDehydrated>) { this.options = { defaultPreloadDelay: 50, context: undefined!, ...options, stringifySearch: options?.stringifySearch ?? defaultStringifySearch, parseSearch: options?.parseSearch ?? defaultParseSearch, fetchServerDataFn: options?.fetchServerDataFn ?? defaultFetchServerDataFn, }; this.__store = new Store<RouterState<TRoutesInfo>>( getInitialRouterState(), { onUpdate: (state) => { this.state = state; }, }, ); this.state = this.__store.state; this.update(options); const next = this.buildNext({ hash: true, fromCurrent: true, search: true, state: true, }); if (this.state.latestLocation.href !== next.href) { this.#commitLocation({ ...next, replace: true }); } } reset = () => { this.__store.setState((s) => Object.assign(s, getInitialRouterState())); }; mount = () => { // Mount only does anything on the client if (!isServer) { // If the router matches are empty, start loading the matches if (!this.state.currentMatches.length) { this.safeLoad(); } } return () => {}; }; hydrate = async (__do_not_use_server_ctx?: any) => { let ctx = __do_not_use_server_ctx; // Client hydrates from window if (typeof document !== "undefined") { ctx = window.__DEHYDRATED__; invariant( ctx, "Expected to find a __DEHYDRATED__ property on window... but we did not. THIS IS VERY BAD", ); } this.options.hydrate?.(ctx); return await this.load(); }; update = (opts?: RouterOptions<any, any>): this => { Object.assign(this.options, opts); this.context = this.options.context; if ( !this.history || (this.options.history && this.options.history !== this.history) ) { if (this.#unsubHistory) { this.#unsubHistory(); } this.history = this.options.history ?? (isServer ? createMemoryHistory() : createBrowserHistory()!); const parsedLocation = this.#parseLocation(); this.__store.setState((s) => ({ ...s, latestLocation: parsedLocation, currentLocation: parsedLocation, })); this.#unsubHistory = this.history.listen(() => { this.safeLoad({ next: this.#parseLocation(this.state.latestLocation), }); }); } const { basepath, routeTree } = this.options; this.basepath = `/${trimPath(basepath ?? "") ?? ""}`; if (routeTree) { this.routesById = {} as any; this.routeTree = this.#buildRouteTree(routeTree) as RootRoute; } return this; }; buildNext = (opts: BuildNextOptions): ParsedLocation => { const next = this.#buildLocation(opts); const __matches = this.matchRoutes(next.pathname); return this.#buildLocation({ ...opts, __matches, }); }; cancelMatches = () => { [ ...this.state.currentMatches, ...(this.state.pendingMatches || []), ].forEach((match) => { match.cancel(); }); }; safeLoad = (opts?: { next?: ParsedLocation }) => { this.load(opts).catch((err) => { console.warn(err); invariant(false, "Encountered an error during router.load()! ☝️."); }); }; load = async (opts?: { next?: ParsedLocation }): Promise<void> => { let now = Date.now(); const startedAt = now; this.startedLoadingAt = startedAt; // Cancel any pending matches this.cancelMatches(); let matches!: RouteMatch<any, any>[]; this.__store.batch(() => { if (opts?.next) { // Ingest the new location this.__store.setState((s) => ({ ...s, latestLocation: opts.next!, })); } // Match the routes matches = this.matchRoutes(this.state.latestLocation.pathname, { strictParseParams: true, }); this.__store.setState((s) => ({ ...s, status: "pending", pendingMatches: matches, pendingLocation: this.state.latestLocation, })); }); // Load the matches await this.loadMatches( matches, this.state.pendingLocation!, // opts ); if (this.startedLoadingAt !== startedAt) { // Ignore side-effects of outdated side-effects return this.navigationPromise; } const previousMatches = this.state.currentMatches; const exiting: AnyRouteMatch[] = [], staying: AnyRouteMatch[] = []; previousMatches.forEach((d) => { if (matches.find((dd) => dd.id === d.id)) { staying.push(d); } else { exiting.push(d); } }); const entering = matches.filter((d) => { return !previousMatches.find((dd) => dd.id === d.id); }); now = Date.now(); exiting.forEach((d) => { d.__onExit?.({ params: d.params, search: d.state.routeSearch, }); // Clear non-loading error states when match leaves if (d.state.status === "error") { this.__store.setState((s) => ({ ...s, status: "idle", error: undefined, })); } }); staying.forEach((d) => { d.route.options.onTransition?.({ params: d.params, search: d.state.routeSearch, }); }); entering.forEach((d) => { d.__onExit = d.route.options.onLoaded?.({ params: d.params, search: d.state.search, }); }); const prevLocation = this.state.currentLocation; this.__store.setState((s) => ({ ...s, status: "idle", currentLocation: this.state.latestLocation, currentMatches: matches, pendingLocation: undefined, pendingMatches: undefined, })); matches.forEach((match) => { match.__commit(); }); if (prevLocation!.href !== this.state.currentLocation.href) { this.options.onRouteChange?.(); } this.resolveNavigation(); }; getRoute = <TId extends keyof TRoutesInfo["routesById"]>( id: TId, ): TRoutesInfo["routesById"][TId] => { const route = this.routesById[id]; invariant(route, `Route with id "${id as string}" not found`); return route; }; loadRoute = async ( navigateOpts: BuildNextOptions = this.state.latestLocation, ): Promise<RouteMatch[]> => { const next = this.buildNext(navigateOpts); const matches = this.matchRoutes(next.pathname, { strictParseParams: true, }); await this.loadMatches(matches, next); return matches; }; preloadRoute = async ( navigateOpts: BuildNextOptions = this.state.latestLocation, ) => { const next = this.buildNext(navigateOpts); const matches = this.matchRoutes(next.pathname, { strictParseParams: true, }); await this.loadMatches(matches, next, { preload: true, }); return matches; }; matchRoutes = (pathname: string, opts?: { strictParseParams?: boolean }) => { const matches: AnyRouteMatch[] = []; if (!this.routeTree) { return matches; } const existingMatches = [ ...this.state.currentMatches, ...(this.state.pendingMatches ?? []), ]; const findInRouteTree = async ( routes: Route<any, any>[], ): Promise<void> => { const parentMatch = last(matches); let params = parentMatch?.params ?? {}; const filteredRoutes = this.options.filterRoutes?.(routes) ?? routes; let matchingRoutes: Route[] = []; const findMatchInRoutes = (parentRoutes: Route[], routes: Route[]) => { routes.some((route) => { const children = route.children as undefined | Route[]; if (!route.path && children?.length) { return findMatchInRoutes( [...matchingRoutes, route], children as any, ); } const fuzzy = !!(route.path !== "/" || children?.length); const matchParams = matchPathname(this.basepath, pathname, { to: route.fullPath, fuzzy, caseSensitive: route.options.caseSensitive ?? this.options.caseSensitive, }); if (matchParams) { let parsedParams; try { parsedParams = route.options.parseParams?.(matchParams!) ?? matchParams; } catch (err) { if (opts?.strictParseParams) { throw err; } } params = { ...params, ...parsedParams, }; } if (!!matchParams) { matchingRoutes = [...parentRoutes, route]; } return !!matchingRoutes.length; }); return !!matchingRoutes.length; }; findMatchInRoutes([], filteredRoutes); if (!matchingRoutes.length) { return; } matchingRoutes.forEach((foundRoute) => { const interpolatedPath = interpolatePath(foundRoute.path, params); const matchId = interpolatePath(foundRoute.id, params, true); const match = existingMatches.find((d) => d.id === matchId) || new RouteMatch(this, foundRoute, { id: matchId, params, pathname: joinPaths([this.basepath, interpolatedPath]), }); matches.push(match); }); const foundRoute = last(matchingRoutes)!; const foundChildren = foundRoute.children as any; if (foundChildren?.length) { findInRouteTree(foundChildren); } }; findInRouteTree([this.routeTree as any]); return matches; }; loadMatches = async ( resolvedMatches: RouteMatch[], location: ParsedLocation, opts?: { preload?: boolean; // filter?: (match: RouteMatch<any, any>) => any }, ) => { let firstBadMatchIndex: number | undefined; // Check each match middleware to see if the route can be accessed try { await Promise.all( resolvedMatches.map(async (match, index) => { try { await match.route.options.beforeLoad?.({ router: this as any, match, }); } catch (err) { if (isRedirect(err)) { throw err; } firstBadMatchIndex = firstBadMatchIndex ?? index; const errorHandler = match.route.options.onBeforeLoadError ?? match.route.options.onError; try { errorHandler?.(err); } catch (errorHandlerErr) { if (isRedirect(errorHandlerErr)) { throw errorHandlerErr; } match.__store.setState((s) => ({ ...s, error: errorHandlerErr, status: "error", updatedAt: Date.now(), })); return; } match.__store.setState((s) => ({ ...s, error: err, status: "error", updatedAt: Date.now(), })); } }), ); } catch (err) { if (isRedirect(err)) { if (!opts?.preload) { this.navigate(err as any); } return; } throw err; // we should never end up here } const validResolvedMatches = resolvedMatches.slice(0, firstBadMatchIndex); const matchPromises = validResolvedMatches.map(async (match, index) => { const parentMatch = validResolvedMatches[index - 1]; match.__load({ preload: opts?.preload, location, parentMatch }); await match.__loadPromise; if (parentMatch) { await parentMatch.__loadPromise; } }); await Promise.all(matchPromises); }; reload = () => { this.navigate({ fromCurrent: true, replace: true, search: true, } as any); }; resolvePath = (from: string, path: string) => { return resolvePath(this.basepath!, from, cleanPath(path)); }; navigate = async < TFrom extends ValidFromPath<TRoutesInfo> = "/", TTo extends string = "", >({ from, to = "" as any, search, hash, replace, params, }: NavigateOptions<TRoutesInfo, TFrom, TTo>) => { // If this link simply reloads the current route, // make sure it has a new key so it will trigger a data refresh // If this `to` is a valid external URL, return // null for LinkUtils const toString = String(to); const fromString = typeof from === "undefined" ? from : String(from); let isExternal; try { new URL(`${toString}`); isExternal = true; } catch (e) {} invariant( !isExternal, "Attempting to navigate to external url with this.navigate!", ); return this.#commitLocation({ from: fromString, to: toString, search, hash, replace, params, }); }; matchRoute = < TFrom extends ValidFromPath<TRoutesInfo> = "/", TTo extends string = "", TResolved extends string = ResolveRelativePath<TFrom, NoInfer<TTo>>, >( location: ToOptions<TRoutesInfo, TFrom, TTo>, opts?: MatchRouteOptions, ): false | TRoutesInfo["routesById"][TResolved]["__types"]["allParams"] => { location = { ...location, to: location.to ? this.resolvePath(location.from ?? "", location.to) : undefined, } as any; const next = this.buildNext(location); const baseLocation = opts?.pending ? this.state.pendingLocation : this.state.currentLocation; if (!baseLocation) { return false; } const match = matchPathname(this.basepath, baseLocation.pathname, { ...opts, to: next.pathname, }) as any; if (!match) { return false; } if (opts?.includeSearch ?? true) { return partialDeepEqual(baseLocation.search, next.search) ? match : false; } return match; }; buildLink = < TFrom extends ValidFromPath<TRoutesInfo> = "/", TTo extends string = "", >({ from, to = "." as any, search, params, hash, target, replace, activeOptions, preload, preloadDelay: userPreloadDelay, disabled, }: LinkOptions<TRoutesInfo, TFrom, TTo>): LinkInfo => { // If this link simply reloads the current route, // make sure it has a new key so it will trigger a data refresh // If this `to` is a valid external URL, return // null for LinkUtils try { new URL(`${to}`); return { type: "external", href: to, }; } catch (e) {} const nextOpts = { from, to, search, params, hash, replace, }; const next = this.buildNext(nextOpts); preload = preload ?? this.options.defaultPreload; const preloadDelay = userPreloadDelay ?? this.options.defaultPreloadDelay ?? 0; // Compare path/hash for matches const currentPathSplit = this.state.currentLocation.pathname.split("/"); const nextPathSplit = next.pathname.split("/"); const pathIsFuzzyEqual = nextPathSplit.every( (d, i) => d === currentPathSplit[i], ); // Combine the matches based on user options const pathTest = activeOptions?.exact ? this.state.currentLocation.pathname === next.pathname : pathIsFuzzyEqual; const hashTest = activeOptions?.includeHash ? this.state.currentLocation.hash === next.hash : true; const searchTest = activeOptions?.includeSearch ?? true ? partialDeepEqual(this.state.currentLocation.search, next.search) : true; // The final "active" test const isActive = pathTest && hashTest && searchTest; // The click handler const handleClick = (e: MouseEvent) => { if ( !disabled && !isCtrlEvent(e) && !e.defaultPrevented && (!target || target === "_self") && e.button === 0 ) { e.preventDefault(); // All is well? Navigate! this.#commitLocation(nextOpts as any); } }; // The click handler const handleFocus = (e: MouseEvent) => { if (preload) { this.preloadRoute(nextOpts).catch((err) => { console.warn(err); console.warn("Error preloading route! ☝️"); }); } }; const handleTouchStart = (e: TouchEvent) => { this.preloadRoute(nextOpts).catch((err) => { console.warn(err); console.warn("Error preloading route! ☝️"); }); }; const handleEnter = (e: MouseEvent) => { const target = (e.target || {}) as LinkCurrentTargetElement; if (preload) { if (target.preloadTimeout) { return; } target.preloadTimeout = setTimeout(() => { target.preloadTimeout = null; this.preloadRoute(nextOpts).catch((err) => { console.warn(err); console.warn("Error preloading route! ☝️"); }); }, preloadDelay); } }; const handleLeave = (e: MouseEvent) => { const target = (e.target || {}) as LinkCurrentTargetElement; if (target.preloadTimeout) { clearTimeout(target.preloadTimeout); target.preloadTimeout = null; } }; return { type: "internal", next, handleFocus, handleClick, handleEnter, handleLeave, handleTouchStart, isActive, disabled, }; }; // dehydrate = (): DehydratedRouter => { // return { // state: { // ...pick(this.state, [ // 'latestLocation', // 'currentLocation', // 'status', // 'lastUpdated', // ]), // // currentMatches: this.state.currentMatches.map((match) => ({ // // id: match.id, // // state: { // // status: match.state.status, // // // status: 'idle', // // }, // // })), // }, // } // } // hydrate = (dehydratedRouter: DehydratedRouter) => { // this.__store.setState((s) => { // // Match the routes // // const currentMatches = this.matchRoutes( // // dehydratedRouter.state.latestLocation.pathname, // // { // // strictParseParams: true, // // }, // // ) // // currentMatches.forEach((match, index) => { // // const dehydratedMatch = dehydratedRouter.state.currentMatches[index] // // invariant( // // dehydratedMatch && dehydratedMatch.id === match.id, // // 'Oh no! There was a hydration mismatch when attempting to hydrate the state of the router! 😬', // // ) // // match.__store.setState((s) => ({ // // ...s, // // ...dehydratedMatch.state, // // })) // // }) // return { // ...s, // ...dehydratedRouter.state, // // currentMatches, // } // }) // } #buildRouteTree = (routeTree: AnyRoute) => { const recurseRoutes = (routes: Route[], parentRoute: Route | undefined) => { routes.forEach((route, i) => { route.init({ originalIndex: i, router: this }); const existingRoute = (this.routesById as any)[route.id]; invariant( !existingRoute, `Duplicate routes found with id: ${String(route.id)}`, ); (this.routesById as any)[route.id] = route; const children = route.children as Route[]; if (children?.length) { recurseRoutes(children, route); route.children = children .map((d, i) => { const parsed = parsePathname( trimPathLeft(cleanPath(d.path ?? "/")), ); while (parsed.length > 1 && parsed[0]?.value === "/") { parsed.shift(); } let score = 0; parsed.forEach((d, i) => { let modifier = 1; while (i--) { modifier *= 0.001; } if (d.type === "pathname" && d.value !== "/") { score += 1 * modifier; } else if (d.type === "param") { score += 2 * modifier; } else if (d.type === "wildcard") { score += 3 * modifier; } }); return { child: d, parsed, index: i, score }; }) .sort((a, b) => { if (a.score !== b.score) { return a.score - b.score; } return a.index - b.index; }) .map((d) => d.child); } }); }; recurseRoutes([routeTree] as Route[], undefined); const recurceCheckRoutes = ( routes: Route[], parentRoute: Route | undefined, ) => { routes.forEach((route) => { if (route.isRoot) { invariant( !parentRoute, "Root routes can only be used as the root of a route tree.", ); } else { invariant( parentRoute ? route.parentRoute === parentRoute : true, `Expected a route with path "${route.path}" to be passed to its parent route "${route.parentRoute?.id}" in an addChildren() call, but was instead passed as a child of the "${parentRoute?.id}" route.`, ); } if (route.children) { recurceCheckRoutes(route.children as Route[], route); } }); }; recurceCheckRoutes([routeTree] as Route[], undefined); return routeTree; }; #parseLocation = (previousLocation?: ParsedLocation): ParsedLocation => { let { pathname, search, hash, state } = this.history.location; const parsedSearch = this.options.parseSearch(search); return { pathname: pathname, searchStr: search, search: replaceEqualDeep(previousLocation?.search, parsedSearch), hash: hash.split("#").reverse()[0] ?? "", href: `${pathname}${search}${hash}`, state: state as LocationState, key: state?.key || "__init__", }; }; #buildLocation = (dest: BuildNextOptions = {}): ParsedLocation => { dest.fromCurrent = dest.fromCurrent ?? dest.to === ""; const fromPathname = dest.fromCurrent ? this.state.latestLocation.pathname : dest.from ?? this.state.latestLocation.pathname; let pathname = resolvePath( this.basepath ?? "/", fromPathname, `${dest.to ?? ""}`, ); const fromMatches = this.matchRoutes(this.state.latestLocation.pathname, { strictParseParams: true, }); const prevParams = { ...last(fromMatches)?.params }; let nextParams = (dest.params ?? true) === true ? prevParams : functionalUpdate(dest.params!, prevParams); if (nextParams) { dest.__matches ?.map((d) => d.route.options.stringifyParams) .filter(Boolean) .forEach((fn) => { nextParams = { ...nextParams!, ...fn!(nextParams!) }; }); } pathname = interpolatePath(pathname, nextParams ?? {}); const preSearchFilters = dest.__matches ?.map((match) => match.route.options.preSearchFilters ?? []) .flat() .filter(Boolean) ?? []; const postSearchFilters = dest.__matches ?.map((match) => match.route.options.postSearchFilters ?? []) .flat() .filter(Boolean) ?? []; // Pre filters first const preFilteredSearch = preSearchFilters?.length ? preSearchFilters?.reduce( (prev, next) => next(prev), this.state.latestLocation.search, ) : this.state.latestLocation.search; // Then the link/navigate function const destSearch = dest.search === true ? preFilteredSearch // Preserve resolvedFrom true : dest.search ? functionalUpdate(dest.search, preFilteredSearch) ?? {} // Updater : preSearchFilters?.length ? preFilteredSearch // Preserve resolvedFrom filters : {}; // Then post filters const postFilteredSearch = postSearchFilters?.length ? postSearchFilters.reduce((prev, next) => next(prev), destSearch) : destSearch; const search = replaceEqualDeep( this.state.latestLocation.search, postFilteredSearch, ); const searchStr = this.options.stringifySearch(search); let hash = dest.hash === true ? this.state.latestLocation.hash : functionalUpdate(dest.hash!, this.state.latestLocation.hash); hash = hash ? `#${hash}` : ""; const nextState = dest.state === true ? this.state.latestLocation.state : functionalUpdate(dest.state, this.state.latestLocation.state)!; return { pathname, search, searchStr, state: nextState, hash, href: this.history.createHref(`${pathname}${searchStr}${hash}`), key: dest.key, }; }; #commitLocation = async ( location: BuildNextOptions & { replace?: boolean }, ) => { const next = this.buildNext(location); const id = "" + Date.now() + Math.random(); if (this.navigateTimeout) clearTimeout(this.navigateTimeout); let nextAction: "push" | "replace" = "replace"; if (!location.replace) { nextAction = "push"; } const isSameUrl = this.state.latestLocation.href === next.href; if (isSameUrl && !next.key) { nextAction = "replace"; } const href = `${next.pathname}${next.searchStr}${ next.hash ? `${next.hash}` : "" }`; this.history[nextAction === "push" ? "push" : "replace"](href, { id, ...next.state, }); return (this.navigationPromise = new Promise((resolve) => { const previousNavigationResolve = this.resolveNavigation; this.resolveNavigation = () => { previousNavigationResolve(); resolve(); }; })); }; } // Detect if we're in the DOM const isServer = typeof window === "undefined" || !window.document.createElement; function getInitialRouterState(): RouterState<any, any> { return { status: "idle", latestLocation: null!, currentLocation: null!, currentMatches: [], lastUpdated: Date.now(), }; } function isCtrlEvent(e: MouseEvent) { return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey); } export type AnyRedirect = Redirect<any, any, any>; export type Redirect< TRoutesInfo extends AnyRoutesInfo = RegisteredRoutesInfo, TFrom extends TRoutesInfo["routePaths"] = "/", TTo extends string = "", > = NavigateOptions<TRoutesInfo, TFrom, TTo> & { code?: number; }; export function redirect< TRoutesInfo extends AnyRoutesInfo = RegisteredRoutesInfo, TFrom extends TRoutesInfo["routePaths"] = "/", TTo extends string = "", >(opts: Redirect<TRoutesInfo, TFrom, TTo>): Redirect<TRoutesInfo, TFrom, TTo> { (opts as any).isRedirect = true; return opts; } export function isRedirect(obj: any): obj is AnyRedirect { return !!obj?.isRedirect; }