diff --git a/packages/react-router/__tests__/path-matching-test.tsx b/packages/react-router/__tests__/path-matching-test.tsx index 4f4a4ad32e..91f430344a 100644 --- a/packages/react-router/__tests__/path-matching-test.tsx +++ b/packages/react-router/__tests__/path-matching-test.tsx @@ -10,7 +10,11 @@ function pickPathsAndParams(routes: RouteObject[], pathname: string) { let matches = matchRoutes(routes, pathname); return ( matches && - matches.map((match) => ({ path: match.route.path, params: match.params })) + matches.map((match) => ({ + ...(match.route.index ? { index: match.route.index } : {}), + ...(match.route.path ? { path: match.route.path } : {}), + params: match.params, + })) ); } @@ -277,7 +281,7 @@ describe("path matching with splats", () => { }); }); -describe("path matchine with optional segments", () => { +describe("path matching with optional segments", () => { test("optional static segment at the start of the path", () => { let routes = [ { @@ -357,6 +361,24 @@ describe("path matchine with optional segments", () => { }, ]); }); + + test("optional static segment in nested routes", () => { + let nested = [ + { + path: "/en?", + children: [ + { + path: "abc", + }, + ], + }, + ]; + + expect(pickPathsAndParams(nested, "/en/abc")).toEqual([ + { path: "/en?", params: {} }, + { path: "abc", params: {} }, + ]); + }); }); describe("path matching with optional dynamic segments", () => { @@ -439,4 +461,312 @@ describe("path matching with optional dynamic segments", () => { }, ]); }); + + test("consecutive optional dynamic segments in nested routes", () => { + let nested = [ + { + path: "/one/:two?", + children: [ + { + path: "three/:four?", + children: [ + { + path: ":five?", + }, + ], + }, + ], + }, + ]; + expect(pickPathsAndParams(nested, "/one/dos/three/cuatro/cinco")).toEqual([ + { + path: "/one/:two?", + params: { two: "dos", four: "cuatro", five: "cinco" }, + }, + { + path: "three/:four?", + params: { two: "dos", four: "cuatro", five: "cinco" }, + }, + { path: ":five?", params: { two: "dos", four: "cuatro", five: "cinco" } }, + ]); + expect(pickPathsAndParams(nested, "/one/dos/three/cuatro")).toEqual([ + { + path: "/one/:two?", + params: { two: "dos", four: "cuatro" }, + }, + { + path: "three/:four?", + params: { two: "dos", four: "cuatro" }, + }, + { + path: ":five?", + params: { two: "dos", four: "cuatro" }, + }, + ]); + expect(pickPathsAndParams(nested, "/one/dos/three")).toEqual([ + { + path: "/one/:two?", + params: { two: "dos" }, + }, + { + path: "three/:four?", + params: { two: "dos" }, + }, + // Matches into 5 because it's just like if we did path="" + { + path: ":five?", + params: { two: "dos" }, + }, + ]); + expect(pickPathsAndParams(nested, "/one/dos")).toEqual([ + { + path: "/one/:two?", + params: { two: "dos" }, + }, + ]); + expect(pickPathsAndParams(nested, "/one")).toEqual([ + { + path: "/one/:two?", + params: {}, + }, + ]); + expect(pickPathsAndParams(nested, "/one/three/cuatro/cinco")).toEqual([ + { + path: "/one/:two?", + params: { four: "cuatro", five: "cinco" }, + }, + { + path: "three/:four?", + params: { four: "cuatro", five: "cinco" }, + }, + { path: ":five?", params: { four: "cuatro", five: "cinco" } }, + ]); + expect(pickPathsAndParams(nested, "/one/three/cuatro")).toEqual([ + { + path: "/one/:two?", + params: { four: "cuatro" }, + }, + { + path: "three/:four?", + params: { four: "cuatro" }, + }, + { + path: ":five?", + params: { four: "cuatro" }, + }, + ]); + expect(pickPathsAndParams(nested, "/one/three")).toEqual([ + { + path: "/one/:two?", + params: {}, + }, + { + path: "three/:four?", + params: {}, + }, + // Matches into 5 because it's just like if we did path="" + { + path: ":five?", + params: {}, + }, + ]); + expect(pickPathsAndParams(nested, "/one")).toEqual([ + { + path: "/one/:two?", + params: {}, + }, + ]); + }); + + test("prefers optional static over optional dynamic segments", () => { + let nested = [ + { + path: "/one", + children: [ + { + path: ":param?", + children: [ + { + path: "three", + }, + ], + }, + { + path: "two?", + children: [ + { + path: "three", + }, + ], + }, + ], + }, + ]; + + // static `two` segment should win + expect(pickPathsAndParams(nested, "/one/two/three")).toEqual([ + { + params: {}, + path: "/one", + }, + { + params: {}, + path: "two?", + }, + { + params: {}, + path: "three", + }, + ]); + + // fall back to param when no static match + expect(pickPathsAndParams(nested, "/one/not-two/three")).toEqual([ + { + params: { + param: "not-two", + }, + path: "/one", + }, + { + params: { + param: "not-two", + }, + path: ":param?", + }, + { + params: { + param: "not-two", + }, + path: "three", + }, + ]); + + // No optional segment provided - earlier "dup" route should win + expect(pickPathsAndParams(nested, "/one/three")).toEqual([ + { + params: {}, + path: "/one", + }, + { + params: {}, + path: ":param?", + }, + { + params: {}, + path: "three", + }, + ]); + }); + + test("prefers index routes over optional static segments", () => { + let nested = [ + { + path: "/one", + children: [ + { + path: ":param?", + children: [ + { + path: "three?", + }, + { + index: true, + }, + ], + }, + ], + }, + ]; + + expect(pickPathsAndParams(nested, "/one/two")).toEqual([ + { + params: { + param: "two", + }, + path: "/one", + }, + { + params: { + param: "two", + }, + path: ":param?", + }, + { + index: true, + params: { + param: "two", + }, + }, + ]); + expect(pickPathsAndParams(nested, "/one")).toEqual([ + { + params: {}, + path: "/one", + }, + { + params: {}, + path: ":param?", + }, + { + index: true, + params: {}, + }, + ]); + }); + + test("prefers index routes over optional dynamic segments", () => { + let nested = [ + { + path: "/one", + children: [ + { + path: ":param?", + children: [ + { + path: ":three?", + }, + { + index: true, + }, + ], + }, + ], + }, + ]; + + expect(pickPathsAndParams(nested, "/one/two")).toEqual([ + { + params: { + param: "two", + }, + path: "/one", + }, + { + params: { + param: "two", + }, + path: ":param?", + }, + { + index: true, + params: { + param: "two", + }, + }, + ]); + expect(pickPathsAndParams(nested, "/one")).toEqual([ + { + params: {}, + path: "/one", + }, + { + params: {}, + path: ":param?", + }, + { + index: true, + params: {}, + }, + ]); + }); }); diff --git a/packages/router/utils.ts b/packages/router/utils.ts index 72699b4ee1..6ed5604f1a 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -372,9 +372,14 @@ function flattenRoutes< parentsMeta: RouteMeta[] = [], parentPath = "" ): RouteBranch[] { - routes.forEach((route, index) => { + let flattenRoute = ( + route: RouteObjectType, + index: number, + relativePath?: string + ) => { let meta: RouteMeta = { - relativePath: route.path || "", + relativePath: + relativePath === undefined ? route.path || "" : relativePath, caseSensitive: route.caseSensitive === true, childrenIndex: index, route, @@ -415,48 +420,75 @@ function flattenRoutes< return; } - // Handle optional params - /path/:optional? - let segments = path.split("/"); - let optionalParams: string[] = []; - segments.forEach((segment) => { - let match = segment.match(/^:?([^?]+)\?$/); - if (match) { - optionalParams.push(match[1]); - } + branches.push({ + path, + score: computeScore(path, route.index), + routesMeta, }); - - if (optionalParams.length > 0) { - for (let i = 0; i <= optionalParams.length; i++) { - let newPath = path; - let newMeta = routesMeta.map((m) => ({ ...m })); - - for (let j = optionalParams.length - 1; j >= 0; j--) { - let re = new RegExp(`(\\/:?${optionalParams[j]})\\?`); - let replacement = j < i ? "$1" : ""; - newPath = newPath.replace(re, replacement); - newMeta[newMeta.length - 1].relativePath = newMeta[ - newMeta.length - 1 - ].relativePath.replace(re, replacement); - } - - branches.push({ - path: newPath, - score: computeScore(newPath, route.index), - routesMeta: newMeta, - }); - } + }; + routes.forEach((route, index) => { + // coarse-grain check for optional params + if (route.path === "" || !route.path?.includes("?")) { + flattenRoute(route, index); } else { - branches.push({ - path, - score: computeScore(path, route.index), - routesMeta, - }); + 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("/")); + return restExploded + .flatMap((subpath) => { + // /one + / + :two/three -> /one/:two/three + let requiredExploded = + subpath === "" ? required : required + "/" + subpath; + // For optional segments, return the exploded path _without_ current segment first (`subpath`) + // and exploded path _with_ current segment later (`subpath`) + // This ensures that exploded paths are emitted in priority order + // `/one/three/:four` will come before `/one/three/:five` + return isOptional ? [subpath, requiredExploded] : [requiredExploded]; + }) + .map((exploded) => { + // for absolute paths, ensure `/` instead of empty segment + return path.startsWith("/") && exploded === "" ? "/" : exploded; + }); +} + function rankRouteBranches(branches: RouteBranch[]): void { branches.sort((a, b) => a.score !== b.score