diff --git a/.changeset/two-mice-grin.md b/.changeset/two-mice-grin.md
new file mode 100644
index 0000000000..3c8383d48e
--- /dev/null
+++ b/.changeset/two-mice-grin.md
@@ -0,0 +1,5 @@
+---
+"react-router": patch
+---
+
+Handle encoded question mark and hash characters in ancestor splat routes
diff --git a/packages/react-router/__tests__/dom/special-characters-test.tsx b/packages/react-router/__tests__/dom/special-characters-test.tsx
index 48e9cd2669..e37a24f9d1 100644
--- a/packages/react-router/__tests__/dom/special-characters-test.tsx
+++ b/packages/react-router/__tests__/dom/special-characters-test.tsx
@@ -603,6 +603,164 @@ describe("special character tests", () => {
);
}
});
+
+ it("handles encoded question marks in ancestor splat route segments", async () => {
+ let ctx = render(
+
{JSON.stringify(useParams())}
+ >
+ );
+ }
+ });
+
+ it("handles encoded hashes in ancestor splat route segments", async () => {
+ let ctx = render(
+ {JSON.stringify(useParams())}
+ >
+ );
+ }
+ });
});
describe("when matching as part of the defined route path", () => {
diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx
index 4356cd4ee6..c877445f9e 100644
--- a/packages/react-router/lib/hooks.tsx
+++ b/packages/react-router/lib/hooks.tsx
@@ -869,9 +869,14 @@ export function useRoutesImpl(
params: Object.assign({}, parentParams, match.params),
pathname: joinPaths([
parentPathnameBase,
- // Re-encode pathnames that were decoded inside matchRoutes
+ // Re-encode pathnames that were decoded inside matchRoutes.
+ // Pre-encode `?` and `#` ahead of `encodeLocation` because it uses
+ // `new URL()` internally and we need to prevent it from treating
+ // them as separators
navigator.encodeLocation
- ? navigator.encodeLocation(match.pathname).pathname
+ ? navigator.encodeLocation(
+ match.pathname.replace(/\?/g, "%3F").replace(/#/g, "%23"),
+ ).pathname
: match.pathname,
]),
pathnameBase:
@@ -880,8 +885,15 @@ export function useRoutesImpl(
: joinPaths([
parentPathnameBase,
// Re-encode pathnames that were decoded inside matchRoutes
+ // Pre-encode `?` and `#` ahead of `encodeLocation` because it uses
+ // `new URL()` internally and we need to prevent it from treating
+ // them as separators
navigator.encodeLocation
- ? navigator.encodeLocation(match.pathnameBase).pathname
+ ? navigator.encodeLocation(
+ match.pathnameBase
+ .replace(/\?/g, "%3F")
+ .replace(/#/g, "%23"),
+ ).pathname
: match.pathnameBase,
]),
}),