import type { Location, Path, To } from "./history"; import { invariant, parsePath } from "./history"; /** * Map of routeId -> data returned from a loader/action/error */ export interface RouteData { [routeId: string]: any; } export enum ResultType { data = "data", deferred = "deferred", redirect = "redirect", error = "error", } /** * Successful result from a loader or action */ export interface SuccessResult { type: ResultType.data; data: any; statusCode?: number; headers?: Headers; } /** * Successful defer() result from a loader or action */ export interface DeferredResult { type: ResultType.deferred; deferredData: DeferredData; statusCode?: number; headers?: Headers; } /** * Redirect result from a loader or action */ export interface RedirectResult { type: ResultType.redirect; status: number; location: string; revalidate: boolean; } /** * Unsuccessful result from a loader or action */ export interface ErrorResult { type: ResultType.error; error: any; headers?: Headers; } /** * Result from a loader or action - potentially successful or unsuccessful */ export type DataResult = | SuccessResult | DeferredResult | RedirectResult | ErrorResult; export type MutationFormMethod = "post" | "put" | "patch" | "delete"; export type FormMethod = "get" | MutationFormMethod; export type FormEncType = | "application/x-www-form-urlencoded" | "multipart/form-data"; /** * @private * Internal interface to pass around for action submissions, not intended for * external consumption */ export interface Submission { formMethod: FormMethod; formAction: string; formEncType: FormEncType; formData: FormData; } /** * @private * Arguments passed to route loader/action functions. Same for now but we keep * this as a private implementation detail in case they diverge in the future. */ interface DataFunctionArgs { request: Request; params: Params; context?: any; } /** * Arguments passed to loader functions */ export interface LoaderFunctionArgs extends DataFunctionArgs {} /** * Arguments passed to action functions */ export interface ActionFunctionArgs extends DataFunctionArgs {} /** * Route loader function signature */ export interface LoaderFunction { (args: LoaderFunctionArgs): Promise | Response | Promise | any; } /** * Route action function signature */ export interface ActionFunction { (args: ActionFunctionArgs): Promise | Response | Promise | any; } /** * Route shouldRevalidate function signature. This runs after any submission * (navigation or fetcher), so we flatten the navigation/fetcher submission * onto the arguments. It shouldn't matter whether it came from a navigation * or a fetcher, what really matters is the URLs and the formData since loaders * have to re-run based on the data models that were potentially mutated. */ export interface ShouldRevalidateFunction { (args: { currentUrl: URL; currentParams: AgnosticDataRouteMatch["params"]; nextUrl: URL; nextParams: AgnosticDataRouteMatch["params"]; formMethod?: Submission["formMethod"]; formAction?: Submission["formAction"]; formEncType?: Submission["formEncType"]; formData?: Submission["formData"]; actionResult?: DataResult; defaultShouldRevalidate: boolean; }): boolean; } /** * Base RouteObject with common props shared by all types of routes */ type AgnosticBaseRouteObject = { caseSensitive?: boolean; path?: string; id?: string; loader?: LoaderFunction; action?: ActionFunction; hasErrorBoundary?: boolean; shouldRevalidate?: ShouldRevalidateFunction; handle?: any; }; /** * Index routes must not have children */ export type AgnosticIndexRouteObject = AgnosticBaseRouteObject & { children?: undefined; index: true; }; /** * Non-index routes may have children, but cannot have index */ export type AgnosticNonIndexRouteObject = AgnosticBaseRouteObject & { children?: AgnosticRouteObject[]; index?: false; }; /** * A route object represents a logical route, with (optionally) its child * routes organized in a tree-like structure. */ export type AgnosticRouteObject = | AgnosticIndexRouteObject | AgnosticNonIndexRouteObject; export type AgnosticDataIndexRouteObject = AgnosticIndexRouteObject & { id: string; }; export type AgnosticDataNonIndexRouteObject = AgnosticNonIndexRouteObject & { children?: AgnosticDataRouteObject[]; id: string; }; /** * A data route object, which is just a RouteObject with a required unique ID */ export type AgnosticDataRouteObject = | AgnosticDataIndexRouteObject | AgnosticDataNonIndexRouteObject; // Recursive helper for finding path parameters in the absence of wildcards type _PathParam = // split path into individual path segments Path extends `${infer L}/${infer R}` ? _PathParam | _PathParam : // find params after `:` Path extends `:${infer Param}` ? Param extends `${infer Optional}?` ? Optional : Param : // otherwise, there aren't any params present never; /** * Examples: * "/a/b/*" -> "*" * ":a" -> "a" * "/a/:b" -> "b" * "/a/blahblahblah:b" -> "b" * "/:a/:b" -> "a" | "b" * "/:a/b/:c/*" -> "a" | "c" | "*" */ type PathParam = // check if path is just a wildcard Path extends "*" ? "*" : // look for wildcard at the end of the path Path extends `${infer Rest}/*` ? "*" | _PathParam : // look for params in the absence of wildcards _PathParam; // Attempt to parse the given string segment. If it fails, then just return the // plain string type as a default fallback. Otherwise return the union of the // parsed string literals that were referenced as dynamic segments in the route. export type ParamParseKey = // if could not find path params, fallback to `string` [PathParam] extends [never] ? string : PathParam; /** * The parameters that were parsed from the URL path. */ export type Params = { readonly [key in Key]: string | undefined; }; /** * A RouteMatch contains info about how a route matched a URL. */ export interface AgnosticRouteMatch< ParamKey extends string = string, RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject > { /** * The names and values of dynamic parameters in the URL. */ params: Params; /** * The portion of the URL pathname that was matched. */ pathname: string; /** * The portion of the URL pathname that was matched before child routes. */ pathnameBase: string; /** * The route object that was used to match. */ route: RouteObjectType; } export interface AgnosticDataRouteMatch extends AgnosticRouteMatch {} function isIndexRoute( route: AgnosticRouteObject ): route is AgnosticIndexRouteObject { return route.index === true; } // Walk the route tree generating unique IDs where necessary so we are working // solely with AgnosticDataRouteObject's within the Router export function convertRoutesToDataRoutes( routes: AgnosticRouteObject[], parentPath: number[] = [], allIds: Set = new Set() ): AgnosticDataRouteObject[] { return routes.map((route, index) => { let treePath = [...parentPath, index]; let id = typeof route.id === "string" ? route.id : treePath.join("-"); invariant( route.index !== true || !route.children, `Cannot specify children on an index route` ); invariant( !allIds.has(id), `Found a route id collision on id "${id}". Route ` + "id's must be globally unique within Data Router usages" ); allIds.add(id); if (isIndexRoute(route)) { let indexRoute: AgnosticDataIndexRouteObject = { ...route, id }; return indexRoute; } else { let pathOrLayoutRoute: AgnosticDataNonIndexRouteObject = { ...route, id, children: route.children ? convertRoutesToDataRoutes(route.children, treePath, allIds) : undefined, }; return pathOrLayoutRoute; } }); } /** * Matches the given routes to a location and returns the match data. * * @see https://reactrouter.com/utils/match-routes */ export function matchRoutes< RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject >( routes: RouteObjectType[], locationArg: Partial | string, basename = "/" ): AgnosticRouteMatch[] | null { let location = typeof locationArg === "string" ? parsePath(locationArg) : locationArg; let pathname = stripBasename(location.pathname || "/", basename); if (pathname == null) { return null; } let branches = flattenRoutes(routes); rankRouteBranches(branches); let matches = null; for (let i = 0; matches == null && i < branches.length; ++i) { matches = matchRouteBranch( branches[i], // Incoming pathnames are generally encoded from either window.location // or from router.navigate, but we want to match against the unencoded // paths in the route definitions. Memory router locations won't be // encoded here but there also shouldn't be anything to decode so this // should be a safe operation. This avoids needing matchRoutes to be // history-aware. safelyDecodeURI(pathname) ); } return matches; } interface RouteMeta< RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject > { relativePath: string; caseSensitive: boolean; childrenIndex: number; route: RouteObjectType; } interface RouteBranch< RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject > { path: string; score: number; routesMeta: RouteMeta[]; } function flattenRoutes< RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject >( routes: RouteObjectType[], branches: RouteBranch[] = [], parentsMeta: RouteMeta[] = [], parentPath = "" ): RouteBranch[] { let flattenRoute = ( route: RouteObjectType, index: number, relativePath?: string ) => { let meta: RouteMeta = { relativePath: relativePath === undefined ? route.path || "" : relativePath, caseSensitive: route.caseSensitive === true, childrenIndex: index, route, }; if (meta.relativePath.startsWith("/")) { invariant( meta.relativePath.startsWith(parentPath), `Absolute route path "${meta.relativePath}" nested under path ` + `"${parentPath}" is not valid. An absolute child route path ` + `must start with the combined path of all its parent routes.` ); meta.relativePath = meta.relativePath.slice(parentPath.length); } let path = joinPaths([parentPath, meta.relativePath]); let routesMeta = parentsMeta.concat(meta); // Add the children before adding this route to the array so we traverse the // route tree depth-first and child routes appear before their parents in // the "flattened" version. if (route.children && route.children.length > 0) { invariant( // Our types know better, but runtime JS may not! // @ts-expect-error route.index !== true, `Index routes must not have child routes. Please remove ` + `all child routes from route path "${path}".` ); flattenRoutes(route.children, branches, routesMeta, path); } // Routes without a path shouldn't ever match by themselves unless they are // index routes, so don't add them to the list of possible branches. if (route.path == null && !route.index) { return; } branches.push({ path, score: computeScore(path, route.index), routesMeta, }); }; routes.forEach((route, index) => { // coarse-grain check for optional params if (route.path === "" || !route.path?.includes("?")) { flattenRoute(route, index); } else { for (let exploded of explodeOptionalSegments(route.path)) { flattenRoute(route, index, exploded); } } }); return branches; } /** * Computes all combinations of optional path segments for a given path, * excluding combinations that are ambiguous and of lower priority. * * For example, `/one/:two?/three/:four?/:five?` explodes to: * - `/one/three` * - `/one/:two/three` * - `/one/three/:four` * - `/one/three/:five` * - `/one/:two/three/:four` * - `/one/:two/three/:five` * - `/one/three/:four/:five` * - `/one/:two/three/:four/:five` */ function explodeOptionalSegments(path: string): string[] { let segments = path.split("/"); if (segments.length === 0) return []; let [first, ...rest] = segments; // Optional path segments are denoted by a trailing `?` let isOptional = first.endsWith("?"); // Compute the corresponding required segment: `foo?` -> `foo` let required = first.replace(/\?$/, ""); if (rest.length === 0) { // Intepret empty string as omitting an optional segment // `["one", "", "three"]` corresponds to omitting `:two` from `/one/:two?/three` -> `/one/three` return isOptional ? [required, ""] : [required]; } let restExploded = explodeOptionalSegments(rest.join("/")); let result: string[] = []; // All child paths with the prefix. Do this for all children before the // optional version for all children so we get consistent ordering where the // parent optional aspect is preferred as required. Otherwise, we can get // child sections interspersed where deeper optional segments are higher than // parent optional segments, where for example, /:two would explodes _earlier_ // then /:one. By always including the parent as required _for all children_ // first, we avoid this issue result.push( ...restExploded.map((subpath) => subpath === "" ? required : [required, subpath].join("/") ) ); // Then if this is an optional value, add all child versions without if (isOptional) { result.push(...restExploded); } // for absolute paths, ensure `/` instead of empty segment return result.map((exploded) => path.startsWith("/") && exploded === "" ? "/" : exploded ); } function rankRouteBranches(branches: RouteBranch[]): void { branches.sort((a, b) => a.score !== b.score ? b.score - a.score // Higher score first : compareIndexes( a.routesMeta.map((meta) => meta.childrenIndex), b.routesMeta.map((meta) => meta.childrenIndex) ) ); } const paramRe = /^:\w+$/; const dynamicSegmentValue = 3; const indexRouteValue = 2; const emptySegmentValue = 1; const staticSegmentValue = 10; const splatPenalty = -2; const isSplat = (s: string) => s === "*"; function computeScore(path: string, index: boolean | undefined): number { let segments = path.split("/"); let initialScore = segments.length; if (segments.some(isSplat)) { initialScore += splatPenalty; } if (index) { initialScore += indexRouteValue; } return segments .filter((s) => !isSplat(s)) .reduce( (score, segment) => score + (paramRe.test(segment) ? dynamicSegmentValue : segment === "" ? emptySegmentValue : staticSegmentValue), initialScore ); } function compareIndexes(a: number[], b: number[]): number { let siblings = a.length === b.length && a.slice(0, -1).every((n, i) => n === b[i]); return siblings ? // If two routes are siblings, we should try to match the earlier sibling // first. This allows people to have fine-grained control over the matching // behavior by simply putting routes with identical paths in the order they // want them tried. a[a.length - 1] - b[b.length - 1] : // Otherwise, it doesn't really make sense to rank non-siblings by index, // so they sort equally. 0; } function matchRouteBranch< ParamKey extends string = string, RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject >( branch: RouteBranch, pathname: string ): AgnosticRouteMatch[] | null { let { routesMeta } = branch; let matchedParams = {}; let matchedPathname = "/"; let matches: AgnosticRouteMatch[] = []; for (let i = 0; i < routesMeta.length; ++i) { let meta = routesMeta[i]; let end = i === routesMeta.length - 1; let remainingPathname = matchedPathname === "/" ? pathname : pathname.slice(matchedPathname.length) || "/"; let match = matchPath( { path: meta.relativePath, caseSensitive: meta.caseSensitive, end }, remainingPathname ); if (!match) return null; Object.assign(matchedParams, match.params); let route = meta.route; matches.push({ // TODO: Can this as be avoided? params: matchedParams as Params, pathname: joinPaths([matchedPathname, match.pathname]), pathnameBase: normalizePathname( joinPaths([matchedPathname, match.pathnameBase]) ), route, }); if (match.pathnameBase !== "/") { matchedPathname = joinPaths([matchedPathname, match.pathnameBase]); } } return matches; } /** * Returns a path with params interpolated. * * @see https://reactrouter.com/utils/generate-path */ export function generatePath( originalPath: Path, params: { [key in PathParam]: string | null; } = {} as any ): string { let path = originalPath; if (path.endsWith("*") && path !== "*" && !path.endsWith("/*")) { warning( false, `Route path "${path}" will be treated as if it were ` + `"${path.replace(/\*$/, "/*")}" because the \`*\` character must ` + `always follow a \`/\` in the pattern. To get rid of this warning, ` + `please change the route path to "${path.replace(/\*$/, "/*")}".` ); path = path.replace(/\*$/, "/*") as Path; } return ( path .replace( /^:(\w+)(\??)/g, (_, key: PathParam, optional: string | undefined) => { let param = params[key]; if (optional === "?") { return param == null ? "" : param; } if (param == null) { invariant(false, `Missing ":${key}" param`); } return param; } ) .replace( /\/:(\w+)(\??)/g, (_, key: PathParam, optional: string | undefined) => { let param = params[key]; if (optional === "?") { return param == null ? "" : `/${param}`; } if (param == null) { invariant(false, `Missing ":${key}" param`); } return `/${param}`; } ) // Remove any optional markers from optional static segments .replace(/\?/g, "") .replace(/(\/?)\*/, (_, prefix, __, str) => { const star = "*" as PathParam; if (params[star] == null) { // If no splat was provided, trim the trailing slash _unless_ it's // the entire path return str === "/*" ? "/" : ""; } // Apply the splat return `${prefix}${params[star]}`; }) ); } /** * A PathPattern is used to match on some portion of a URL pathname. */ export interface PathPattern { /** * A string to match against a URL pathname. May contain `:id`-style segments * to indicate placeholders for dynamic parameters. May also end with `/*` to * indicate matching the rest of the URL pathname. */ path: Path; /** * Should be `true` if the static portions of the `path` should be matched in * the same case. */ caseSensitive?: boolean; /** * Should be `true` if this pattern should match the entire URL pathname. */ end?: boolean; } /** * A PathMatch contains info about how a PathPattern matched on a URL pathname. */ export interface PathMatch { /** * The names and values of dynamic parameters in the URL. */ params: Params; /** * The portion of the URL pathname that was matched. */ pathname: string; /** * The portion of the URL pathname that was matched before child routes. */ pathnameBase: string; /** * The pattern that was used to match. */ pattern: PathPattern; } type Mutable = { -readonly [P in keyof T]: T[P]; }; /** * Performs pattern matching on a URL pathname and returns information about * the match. * * @see https://reactrouter.com/utils/match-path */ export function matchPath< ParamKey extends ParamParseKey, Path extends string >( pattern: PathPattern | Path, pathname: string ): PathMatch | null { if (typeof pattern === "string") { pattern = { path: pattern, caseSensitive: false, end: true }; } let [matcher, paramNames] = compilePath( pattern.path, pattern.caseSensitive, pattern.end ); let match = pathname.match(matcher); if (!match) return null; let matchedPathname = match[0]; let pathnameBase = matchedPathname.replace(/(.)\/+$/, "$1"); let captureGroups = match.slice(1); let params: Params = paramNames.reduce>( (memo, paramName, index) => { // We need to compute the pathnameBase here using the raw splat value // instead of using params["*"] later because it will be decoded then if (paramName === "*") { let splatValue = captureGroups[index] || ""; pathnameBase = matchedPathname .slice(0, matchedPathname.length - splatValue.length) .replace(/(.)\/+$/, "$1"); } memo[paramName] = safelyDecodeURIComponent( captureGroups[index] || "", paramName ); return memo; }, {} ); return { params, pathname: matchedPathname, pathnameBase, pattern, }; } function compilePath( path: string, caseSensitive = false, end = true ): [RegExp, string[]] { warning( path === "*" || !path.endsWith("*") || path.endsWith("/*"), `Route path "${path}" will be treated as if it were ` + `"${path.replace(/\*$/, "/*")}" because the \`*\` character must ` + `always follow a \`/\` in the pattern. To get rid of this warning, ` + `please change the route path to "${path.replace(/\*$/, "/*")}".` ); let paramNames: string[] = []; let regexpSource = "^" + path .replace(/\/*\*?$/, "") // Ignore trailing / and /*, we'll handle it below .replace(/^\/*/, "/") // Make sure it has a leading / .replace(/[\\.*+^$?{}|()[\]]/g, "\\$&") // Escape special regex chars .replace(/\/:(\w+)/g, (_: string, paramName: string) => { paramNames.push(paramName); return "/([^\\/]+)"; }); if (path.endsWith("*")) { paramNames.push("*"); regexpSource += path === "*" || path === "/*" ? "(.*)$" // Already matched the initial /, just match the rest : "(?:\\/(.+)|\\/*)$"; // Don't include the / in params["*"] } else if (end) { // When matching to the end, ignore trailing slashes regexpSource += "\\/*$"; } else if (path !== "" && path !== "/") { // If our path is non-empty and contains anything beyond an initial slash, // then we have _some_ form of path in our regex so we should expect to // match only if we find the end of this path segment. Look for an optional // non-captured trailing slash (to match a portion of the URL) or the end // of the path (if we've matched to the end). We used to do this with a // word boundary but that gives false positives on routes like // /user-preferences since `-` counts as a word boundary. regexpSource += "(?:(?=\\/|$))"; } else { // Nothing to match for "" or "/" } let matcher = new RegExp(regexpSource, caseSensitive ? undefined : "i"); return [matcher, paramNames]; } function safelyDecodeURI(value: string) { try { return decodeURI(value); } catch (error) { warning( false, `The URL path "${value}" could not be decoded because it is is a ` + `malformed URL segment. This is probably due to a bad percent ` + `encoding (${error}).` ); return value; } } function safelyDecodeURIComponent(value: string, paramName: string) { try { return decodeURIComponent(value); } catch (error) { warning( false, `The value for the URL param "${paramName}" will not be decoded because` + ` the string "${value}" is a malformed URL segment. This is probably` + ` due to a bad percent encoding (${error}).` ); return value; } } /** * @private */ export function stripBasename( pathname: string, basename: string ): string | null { if (basename === "/") return pathname; if (!pathname.toLowerCase().startsWith(basename.toLowerCase())) { return null; } // We want to leave trailing slash behavior in the user's control, so if they // specify a basename with a trailing slash, we should support it let startIndex = basename.endsWith("/") ? basename.length - 1 : basename.length; let nextChar = pathname.charAt(startIndex); if (nextChar && nextChar !== "/") { // pathname does not start with basename/ return null; } return pathname.slice(startIndex) || "/"; } /** * @private */ export function warning(cond: any, message: string): void { if (!cond) { // eslint-disable-next-line no-console if (typeof console !== "undefined") console.warn(message); try { // Welcome to debugging @remix-run/router! // // This error is thrown as a convenience so you can more easily // find the source for a warning that appears in the console by // enabling "pause on exceptions" in your JavaScript debugger. throw new Error(message); // eslint-disable-next-line no-empty } catch (e) {} } } /** * Returns a resolved path object relative to the given pathname. * * @see https://reactrouter.com/utils/resolve-path */ export function resolvePath(to: To, fromPathname = "/"): Path { let { pathname: toPathname, search = "", hash = "", } = typeof to === "string" ? parsePath(to) : to; let pathname = toPathname ? toPathname.startsWith("/") ? toPathname : resolvePathname(toPathname, fromPathname) : fromPathname; return { pathname, search: normalizeSearch(search), hash: normalizeHash(hash), }; } function resolvePathname(relativePath: string, fromPathname: string): string { let segments = fromPathname.replace(/\/+$/, "").split("/"); let relativeSegments = relativePath.split("/"); relativeSegments.forEach((segment) => { if (segment === "..") { // Keep the root "" segment so the pathname starts at / if (segments.length > 1) segments.pop(); } else if (segment !== ".") { segments.push(segment); } }); return segments.length > 1 ? segments.join("/") : "/"; } function getInvalidPathError( char: string, field: string, dest: string, path: Partial ) { return ( `Cannot include a '${char}' character in a manually specified ` + `\`to.${field}\` field [${JSON.stringify( path )}]. Please separate it out to the ` + `\`to.${dest}\` field. Alternatively you may provide the full path as ` + `a string in and the router will parse it for you.` ); } /** * @private * * When processing relative navigation we want to ignore ancestor routes that * do not contribute to the path, such that index/pathless layout routes don't * interfere. * * For example, when moving a route element into an index route and/or a * pathless layout route, relative link behavior contained within should stay * the same. Both of the following examples should link back to the root: * * * * * * * * }> // <-- Does not contribute * // <-- Does not contribute * * */ export function getPathContributingMatches< T extends AgnosticRouteMatch = AgnosticRouteMatch >(matches: T[]) { return matches.filter( (match, index) => index === 0 || (match.route.path && match.route.path.length > 0) ); } /** * @private */ export function resolveTo( toArg: To, routePathnames: string[], locationPathname: string, isPathRelative = false ): Path { let to: Partial; if (typeof toArg === "string") { to = parsePath(toArg); } else { to = { ...toArg }; invariant( !to.pathname || !to.pathname.includes("?"), getInvalidPathError("?", "pathname", "search", to) ); invariant( !to.pathname || !to.pathname.includes("#"), getInvalidPathError("#", "pathname", "hash", to) ); invariant( !to.search || !to.search.includes("#"), getInvalidPathError("#", "search", "hash", to) ); } let isEmptyPath = toArg === "" || to.pathname === ""; let toPathname = isEmptyPath ? "/" : to.pathname; let from: string; // Routing is relative to the current pathname if explicitly requested. // // If a pathname is explicitly provided in `to`, it should be relative to the // route context. This is explained in `Note on `` values` in our // migration guide from v5 as a means of disambiguation between `to` values // that begin with `/` and those that do not. However, this is problematic for // `to` values that do not provide a pathname. `to` can simply be a search or // hash string, in which case we should assume that the navigation is relative // to the current location's pathname and *not* the route pathname. if (isPathRelative || toPathname == null) { from = locationPathname; } else { let routePathnameIndex = routePathnames.length - 1; if (toPathname.startsWith("..")) { let toSegments = toPathname.split("/"); // Each leading .. segment means "go up one route" instead of "go up one // URL segment". This is a key difference from how works and a // major reason we call this a "to" value instead of a "href". while (toSegments[0] === "..") { toSegments.shift(); routePathnameIndex -= 1; } to.pathname = toSegments.join("/"); } // If there are more ".." segments than parent routes, resolve relative to // the root / URL. from = routePathnameIndex >= 0 ? routePathnames[routePathnameIndex] : "/"; } let path = resolvePath(to, from); // Ensure the pathname has a trailing slash if the original "to" had one let hasExplicitTrailingSlash = toPathname && toPathname !== "/" && toPathname.endsWith("/"); // Or if this was a link to the current path which has a trailing slash let hasCurrentTrailingSlash = (isEmptyPath || toPathname === ".") && locationPathname.endsWith("/"); if ( !path.pathname.endsWith("/") && (hasExplicitTrailingSlash || hasCurrentTrailingSlash) ) { path.pathname += "/"; } return path; } /** * @private */ export function getToPathname(to: To): string | undefined { // Empty strings should be treated the same as / paths return to === "" || (to as Path).pathname === "" ? "/" : typeof to === "string" ? parsePath(to).pathname : to.pathname; } /** * @private */ export const joinPaths = (paths: string[]): string => paths.join("/").replace(/\/\/+/g, "/"); /** * @private */ export const normalizePathname = (pathname: string): string => pathname.replace(/\/+$/, "").replace(/^\/*/, "/"); /** * @private */ export const normalizeSearch = (search: string): string => !search || search === "?" ? "" : search.startsWith("?") ? search : "?" + search; /** * @private */ export const normalizeHash = (hash: string): string => !hash || hash === "#" ? "" : hash.startsWith("#") ? hash : "#" + hash; export type JsonFunction = ( data: Data, init?: number | ResponseInit ) => Response; /** * This is a shortcut for creating `application/json` responses. Converts `data` * to JSON and sets the `Content-Type` header. */ export const json: JsonFunction = (data, init = {}) => { let responseInit = typeof init === "number" ? { status: init } : init; let headers = new Headers(responseInit.headers); if (!headers.has("Content-Type")) { headers.set("Content-Type", "application/json; charset=utf-8"); } return new Response(JSON.stringify(data), { ...responseInit, headers, }); }; export interface TrackedPromise extends Promise { _tracked?: boolean; _data?: any; _error?: any; } export class AbortedDeferredError extends Error {} export class DeferredData { private pendingKeysSet: Set = new Set(); private controller: AbortController; private abortPromise: Promise; private unlistenAbortSignal: () => void; private subscribers: Set<(aborted: boolean, settledKey?: string) => void> = new Set(); data: Record; init?: ResponseInit; deferredKeys: string[] = []; constructor(data: Record, responseInit?: ResponseInit) { invariant( data && typeof data === "object" && !Array.isArray(data), "defer() only accepts plain objects" ); // Set up an AbortController + Promise we can race against to exit early // cancellation let reject: (e: AbortedDeferredError) => void; this.abortPromise = new Promise((_, r) => (reject = r)); this.controller = new AbortController(); let onAbort = () => reject(new AbortedDeferredError("Deferred data aborted")); this.unlistenAbortSignal = () => this.controller.signal.removeEventListener("abort", onAbort); this.controller.signal.addEventListener("abort", onAbort); this.data = Object.entries(data).reduce( (acc, [key, value]) => Object.assign(acc, { [key]: this.trackPromise(key, value), }), {} ); if (this.done) { // All incoming values were resolved this.unlistenAbortSignal(); } this.init = responseInit; } private trackPromise( key: string, value: Promise | unknown ): TrackedPromise | unknown { if (!(value instanceof Promise)) { return value; } this.deferredKeys.push(key); this.pendingKeysSet.add(key); // We store a little wrapper promise that will be extended with // _data/_error props upon resolve/reject let promise: TrackedPromise = Promise.race([value, this.abortPromise]).then( (data) => this.onSettle(promise, key, null, data as unknown), (error) => this.onSettle(promise, key, error as unknown) ); // Register rejection listeners to avoid uncaught promise rejections on // errors or aborted deferred values promise.catch(() => {}); Object.defineProperty(promise, "_tracked", { get: () => true }); return promise; } private onSettle( promise: TrackedPromise, key: string, error: unknown, data?: unknown ): unknown { if ( this.controller.signal.aborted && error instanceof AbortedDeferredError ) { this.unlistenAbortSignal(); Object.defineProperty(promise, "_error", { get: () => error }); return Promise.reject(error); } this.pendingKeysSet.delete(key); if (this.done) { // Nothing left to abort! this.unlistenAbortSignal(); } if (error) { Object.defineProperty(promise, "_error", { get: () => error }); this.emit(false, key); return Promise.reject(error); } Object.defineProperty(promise, "_data", { get: () => data }); this.emit(false, key); return data; } private emit(aborted: boolean, settledKey?: string) { this.subscribers.forEach((subscriber) => subscriber(aborted, settledKey)); } subscribe(fn: (aborted: boolean, settledKey?: string) => void) { this.subscribers.add(fn); return () => this.subscribers.delete(fn); } cancel() { this.controller.abort(); this.pendingKeysSet.forEach((v, k) => this.pendingKeysSet.delete(k)); this.emit(true); } async resolveData(signal: AbortSignal) { let aborted = false; if (!this.done) { let onAbort = () => this.cancel(); signal.addEventListener("abort", onAbort); aborted = await new Promise((resolve) => { this.subscribe((aborted) => { signal.removeEventListener("abort", onAbort); if (aborted || this.done) { resolve(aborted); } }); }); } return aborted; } get done() { return this.pendingKeysSet.size === 0; } get unwrappedData() { invariant( this.data !== null && this.done, "Can only unwrap data on initialized and settled deferreds" ); return Object.entries(this.data).reduce( (acc, [key, value]) => Object.assign(acc, { [key]: unwrapTrackedPromise(value), }), {} ); } get pendingKeys() { return Array.from(this.pendingKeysSet); } } function isTrackedPromise(value: any): value is TrackedPromise { return ( value instanceof Promise && (value as TrackedPromise)._tracked === true ); } function unwrapTrackedPromise(value: any) { if (!isTrackedPromise(value)) { return value; } if (value._error) { throw value._error; } return value._data; } export type DeferFunction = ( data: Record, init?: number | ResponseInit ) => DeferredData; export const defer: DeferFunction = (data, init = {}) => { let responseInit = typeof init === "number" ? { status: init } : init; return new DeferredData(data, responseInit); }; export type RedirectFunction = ( url: string, init?: number | ResponseInit ) => Response; /** * A redirect response. Sets the status code and the `Location` header. * Defaults to "302 Found". */ export const redirect: RedirectFunction = (url, init = 302) => { let responseInit = init; if (typeof responseInit === "number") { responseInit = { status: responseInit }; } else if (typeof responseInit.status === "undefined") { responseInit.status = 302; } let headers = new Headers(responseInit.headers); headers.set("Location", url); return new Response(null, { ...responseInit, headers, }); }; /** * @private * Utility class we use to hold auto-unwrapped 4xx/5xx Response bodies */ export class ErrorResponse { status: number; statusText: string; data: any; error?: Error; internal: boolean; constructor( status: number, statusText: string | undefined, data: any, internal = false ) { this.status = status; this.statusText = statusText || ""; this.internal = internal; if (data instanceof Error) { this.data = data.toString(); this.error = data; } else { this.data = data; } } } /** * Check if the given error is an ErrorResponse generated from a 4xx/5xx * Response thrown from an action/loader */ export function isRouteErrorResponse(error: any): error is ErrorResponse { return ( error != null && typeof error.status === "number" && typeof error.statusText === "string" && typeof error.internal === "boolean" && "data" in error ); }