diff --git a/contributors.yml b/contributors.yml index 70d633a861..c5aa159a28 100644 --- a/contributors.yml +++ b/contributors.yml @@ -16,6 +16,7 @@ - petersendidit - RobHannay - sergiodxa +- shamsup - shivamsinghchahar - thisiskartik - timdorr diff --git a/packages/react-router/__tests__/descendant-routes-splat-matching-test.tsx b/packages/react-router/__tests__/descendant-routes-splat-matching-test.tsx index 5c94dd9023..33d2b3a675 100644 --- a/packages/react-router/__tests__/descendant-routes-splat-matching-test.tsx +++ b/packages/react-router/__tests__/descendant-routes-splat-matching-test.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import * as TestRenderer from "react-test-renderer"; -import { MemoryRouter, Outlet, Routes, Route } from "react-router"; +import { MemoryRouter, Outlet, Routes, Route, useParams } from "react-router"; +import type { InitialEntry } from "history"; describe("Descendant splat matching", () => { describe("when the parent route path ends with /*", () => { @@ -57,5 +58,158 @@ describe("Descendant splat matching", () => { `); }); + describe("works with paths beginning with special characters", () => { + function PrintParams() { + return

The params are {JSON.stringify(useParams())}

; + } + function ReactCourses() { + return ( +
+

React

+ + +

React Fundamentals

+ +
+ } + /> +
+ + ); + } + + function Courses() { + return ( +
+

Courses

+ +
+ ); + } + + function renderNestedSplatRoute(initialEntries: InitialEntry[]) { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + }> + } /> + + + + ); + }); + return renderer; + } + + it("allows `-` to appear at the beginning", () => { + let renderer = renderNestedSplatRoute([ + "/courses/react/-react-fundamentals" + ]); + expect(renderer.toJSON()).toMatchInlineSnapshot(` +
+

+ Courses +

+
+

+ React +

+
+

+ React Fundamentals +

+

+ The params are + {"*":"-react-fundamentals","splat":"-react-fundamentals"} +

+
+
+
+ `); + }); + it("allows `.` to appear at the beginning", () => { + let renderer = renderNestedSplatRoute([ + "/courses/react/.react-fundamentals" + ]); + expect(renderer.toJSON()).toMatchInlineSnapshot(` +
+

+ Courses +

+
+

+ React +

+
+

+ React Fundamentals +

+

+ The params are + {"*":".react-fundamentals","splat":".react-fundamentals"} +

+
+
+
+ `); + }); + it("allows `~` to appear at the beginning", () => { + let renderer = renderNestedSplatRoute([ + "/courses/react/~react-fundamentals" + ]); + expect(renderer.toJSON()).toMatchInlineSnapshot(` +
+

+ Courses +

+
+

+ React +

+
+

+ React Fundamentals +

+

+ The params are + {"*":"~react-fundamentals","splat":"~react-fundamentals"} +

+
+
+
+ `); + }); + it("allows url-encoded entities to appear at the beginning", () => { + let renderer = renderNestedSplatRoute([ + "/courses/react/%20react-fundamentals" + ]); + expect(renderer.toJSON()).toMatchInlineSnapshot(` +
+

+ Courses +

+
+

+ React +

+
+

+ React Fundamentals +

+

+ The params are + {"*":" react-fundamentals","splat":" react-fundamentals"} +

+
+
+
+ `); + }); + }); }); }); diff --git a/packages/react-router/__tests__/layout-routes-test.tsx b/packages/react-router/__tests__/layout-routes-test.tsx index ae0f5b9ab5..0884451520 100644 --- a/packages/react-router/__tests__/layout-routes-test.tsx +++ b/packages/react-router/__tests__/layout-routes-test.tsx @@ -31,4 +31,211 @@ describe("A layout route", () => { `); }); + describe("matches when a nested splat route begins with a special character", () => { + it("allows routes starting with `-`", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + +

Layout

+ + + } + > + +

Splat

+ + } + /> +
+
+
+ ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +
+

+ Layout +

+
+

+ Splat +

+
+
+ `); + }); + it("allows routes starting with `~`", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + +

Layout

+ + + } + > + +

Splat

+ + } + /> +
+
+
+ ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +
+

+ Layout +

+
+

+ Splat +

+
+
+ `); + }); + it("allows routes starting with `_`", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + +

Layout

+ + + } + > + +

Splat

+ + } + /> +
+
+
+ ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +
+

+ Layout +

+
+

+ Splat +

+
+
+ `); + }); + it("allows routes starting with `.`", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + +

Layout

+ + + } + > + +

Splat

+ + } + /> +
+
+
+ ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +
+

+ Layout +

+
+

+ Splat +

+
+
+ `); + }); + it("allows routes starting with url-encoded entities", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + +

Layout

+ + + } + > + +

Splat

+ + } + /> +
+
+
+ ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +
+

+ Layout +

+
+

+ Splat +

+
+
+ `); + }); + }); }); diff --git a/packages/react-router/index.tsx b/packages/react-router/index.tsx index 135d2e6bae..edbee188d4 100644 --- a/packages/react-router/index.tsx +++ b/packages/react-router/index.tsx @@ -1222,7 +1222,10 @@ function compilePath( : // Otherwise, match a word boundary or a proceeding /. The word boundary restricts // parent routes to matching only their own words and nothing more, e.g. parent // route "/home" should not match "/home2". - "(?:\\b|\\/|$)"; + // Additionally, allow paths starting with `.`, `-`, `~`, and url-encoded entities, + // but do not consume the character in the matched path so they can match against + // nested paths. + "(?:(?=[.~-]|%[0-9A-F]{2})|\\b|\\/|$)"; } let matcher = new RegExp(regexpSource, caseSensitive ? undefined : "i");