diff --git a/.changeset/new-taxis-stare.md b/.changeset/new-taxis-stare.md new file mode 100644 index 0000000000..b3085a58cd --- /dev/null +++ b/.changeset/new-taxis-stare.md @@ -0,0 +1,34 @@ +--- +"react-router": minor +"@remix-run/router": minor +--- + +Allows optional routes and optional static segments + +**Optional params examples** + +`:lang?/about` will get expanded matched with + +``` +/:lang/about +/about +``` + +`/multistep/:widget1?/widget2?/widget3?` +Will get expanded matched with: + +``` +/multistep +/multistep/:widget1 +/multistep/:widget1/:widget2 +/multistep/:widget1/:widget2/:widget3 +``` + +**optional static segment example** + +`/fr?/about` will get expanded and matched with: + +``` +/about +/fr/about +``` diff --git a/contributors.yml b/contributors.yml index aa40f67154..091718e2c1 100644 --- a/contributors.yml +++ b/contributors.yml @@ -76,6 +76,7 @@ - KostiantynPopovych - KutnerUri - latin-1 +- lordofthecactus - liuhanqu - loun4 - lqze diff --git a/package.json b/package.json index d359a2a285..d7b6afc2db 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "35 kB" + "none": "35.5 kB" }, "packages/react-router/dist/react-router.production.min.js": { "none": "12.5 kB" diff --git a/packages/react-router/__tests__/path-matching-test.tsx b/packages/react-router/__tests__/path-matching-test.tsx index dd5e58a3af..4f4a4ad32e 100644 --- a/packages/react-router/__tests__/path-matching-test.tsx +++ b/packages/react-router/__tests__/path-matching-test.tsx @@ -6,6 +6,14 @@ function pickPaths(routes: RouteObject[], pathname: string): string[] | null { return matches && matches.map((match) => match.route.path || ""); } +function pickPathsAndParams(routes: RouteObject[], pathname: string) { + let matches = matchRoutes(routes, pathname); + return ( + matches && + matches.map((match) => ({ path: match.route.path, params: match.params })) + ); +} + describe("path matching", () => { test("root vs. dynamic", () => { let routes = [{ path: "/" }, { path: ":id" }]; @@ -251,20 +259,184 @@ describe("path matching with splats", () => { expect(match).not.toBeNull(); expect(match).toHaveLength(3); - expect(match[0]).toMatchObject({ + expect(match![0]).toMatchObject({ params: { "*": "abc" }, pathname: "/", pathnameBase: "/", }); - expect(match[1]).toMatchObject({ + expect(match![1]).toMatchObject({ params: { "*": "abc" }, pathname: "/courses", pathnameBase: "/courses", }); - expect(match[2]).toMatchObject({ + expect(match![2]).toMatchObject({ params: { "*": "abc" }, pathname: "/courses/abc", pathnameBase: "/courses", }); }); }); + +describe("path matchine with optional segments", () => { + test("optional static segment at the start of the path", () => { + let routes = [ + { + path: "/en?/abc", + }, + ]; + + expect(pickPathsAndParams(routes, "/")).toEqual(null); + expect(pickPathsAndParams(routes, "/abc")).toEqual([ + { + path: "/en?/abc", + params: {}, + }, + ]); + expect(pickPathsAndParams(routes, "/en/abc")).toEqual([ + { + path: "/en?/abc", + params: {}, + }, + ]); + expect(pickPathsAndParams(routes, "/en/abc/bar")).toEqual(null); + }); + + test("optional static segment at the end of the path", () => { + let routes = [ + { + path: "/nested/one?/two?", + }, + ]; + + expect(pickPathsAndParams(routes, "/nested")).toEqual([ + { + path: "/nested/one?/two?", + params: {}, + }, + ]); + expect(pickPathsAndParams(routes, "/nested/one")).toEqual([ + { + path: "/nested/one?/two?", + params: {}, + }, + ]); + expect(pickPathsAndParams(routes, "/nested/one/two")).toEqual([ + { + path: "/nested/one?/two?", + params: {}, + }, + ]); + expect(pickPathsAndParams(routes, "/nested/one/two/baz")).toEqual(null); + }); + + test("intercalated optional static segments", () => { + let routes = [ + { + path: "/nested/one?/two/three?", + }, + ]; + + expect(pickPathsAndParams(routes, "/nested")).toEqual(null); + expect(pickPathsAndParams(routes, "/nested/one")).toEqual(null); + expect(pickPathsAndParams(routes, "/nested/two")).toEqual([ + { + path: "/nested/one?/two/three?", + params: {}, + }, + ]); + expect(pickPathsAndParams(routes, "/nested/one/two")).toEqual([ + { + path: "/nested/one?/two/three?", + params: {}, + }, + ]); + expect(pickPathsAndParams(routes, "/nested/one/two/three")).toEqual([ + { + path: "/nested/one?/two/three?", + params: {}, + }, + ]); + }); +}); + +describe("path matching with optional dynamic segments", () => { + test("optional params at the start of the path", () => { + let routes = [ + { + path: "/:lang?/abc", + }, + ]; + + expect(pickPathsAndParams(routes, "/")).toEqual(null); + expect(pickPathsAndParams(routes, "/abc")).toEqual([ + { + path: "/:lang?/abc", + params: {}, + }, + ]); + expect(pickPathsAndParams(routes, "/en/abc")).toEqual([ + { + path: "/:lang?/abc", + params: { lang: "en" }, + }, + ]); + expect(pickPathsAndParams(routes, "/en/abc/bar")).toEqual(null); + }); + + test("optional params at the end of the path", () => { + let routes = [ + { + path: "/nested/:one?/:two?", + }, + ]; + + expect(pickPathsAndParams(routes, "/nested")).toEqual([ + { + path: "/nested/:one?/:two?", + params: {}, + }, + ]); + expect(pickPathsAndParams(routes, "/nested/foo")).toEqual([ + { + path: "/nested/:one?/:two?", + params: { one: "foo" }, + }, + ]); + expect(pickPathsAndParams(routes, "/nested/foo/bar")).toEqual([ + { + path: "/nested/:one?/:two?", + params: { one: "foo", two: "bar" }, + }, + ]); + expect(pickPathsAndParams(routes, "/nested/foo/bar/baz")).toEqual(null); + }); + + test("intercalated optional params", () => { + let routes = [ + { + path: "/nested/:one?/two/:three?", + }, + ]; + + expect(pickPathsAndParams(routes, "/nested")).toEqual(null); + expect(pickPathsAndParams(routes, "/nested/foo")).toEqual(null); + expect(pickPathsAndParams(routes, "/nested/two")).toEqual([ + { + path: "/nested/:one?/two/:three?", + params: {}, + }, + ]); + expect(pickPathsAndParams(routes, "/nested/foo/two")).toEqual([ + { + path: "/nested/:one?/two/:three?", + params: { one: "foo" }, + }, + ]); + expect(pickPathsAndParams(routes, "/nested/foo/two/bar")).toEqual([ + { + path: "/nested/:one?/two/:three?", + params: { one: "foo", three: "bar" }, + }, + ]); + }); +}); diff --git a/packages/router/utils.ts b/packages/router/utils.ts index d9ce7d3242..72699b4ee1 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -415,7 +415,43 @@ function flattenRoutes< return; } - branches.push({ path, score: computeScore(path, route.index), routesMeta }); + // 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]); + } + }); + + 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, + }); + } + } else { + branches.push({ + path, + score: computeScore(path, route.index), + routesMeta, + }); + } }); return branches;