Repository: TanStack/router (ts)

Issue:
Path params do not support `%`
### Which project does this relate to?

Router

### Describe the bug

This is a two-fold issue.
- `navigate()` does not support path params with `%`
- The router cannot resolve paths with `%25`

### Your Example Website or App

https://stackblitz.com/edit/github-dnzkk3ai?file=src%2Froutes%2Findex.tsx

### Steps to Reproduce the Bug or Issue

### Regarding `navigate()`:
`navigate()` will throw `URI malformed` if a param contains `%`.
Example: `navigate({..., params: {q: "100%"}})`
Example: `<Link to="100%">` and `<Link to="/$q" params={{q:"100%"}}>`

**Working around the issue**
This can be circumvented by manually encoding (though I would expect the library to handle this).
Example: `navigate({..., params: {q: encodeURIComponent("100%")}})` - (`q` then becomes `"100%25"`)

The latter will successfully pushState, although the `%` ends up double-encoded.
Example: `http://website.com/search/100%2525`

`useParams` does successfully double-decode, so `const params = useParams()` yields `{q: "100%"}`

However..

### Regarding route resolution
The router cannot resolve paths (unless pushState) containing `%25` nor `%2525`.
Examples:
`http://website.com/search/100%25` - throws `URI malformed` in SSR
`http://website.com/search/100%2525` - works in SSR but throws `URI malformed` client side 

### Expected behavior

- I would expect `navigate` to handle URI encoding of `%` for me.
- I would expect the URL to not need double-encoded `%2525`.
- I would expect the router (SSR and CSR) to be able to handle `%25` in path.

### Screenshots or Videos

_No response_

### Platform

- OS: macOS
- Version: 1.102.5


### Additional context

Related PR for handling `%` in query-params: https://github.com/TanStack/router/pull/3381

Pull request description:
fix(router-core): allow `%25` in path params
fixes #3402 

Patch:
diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts
index 1173806aa..f2daff510 100644
--- a/packages/router-core/src/path.ts
+++ b/packages/router-core/src/path.ts
@@ -183,7 +183,12 @@ export function parsePathname(pathname?: string): Array<Segment> {
 
       return {
         type: 'pathname',
-        value: decodeURI(part),
+        value: part.includes('%25')
+          ? part
+              .split('%25')
+              .map((segment) => decodeURI(segment))
+              .join('%25')
+          : decodeURI(part),
       }
     }),
   )


Test patch:
diff --git a/e2e/react-router/basic-file-based/src/routeTree.gen.ts b/e2e/react-router/basic-file-based/src/routeTree.gen.ts
index 590f7df91..0cc739749 100644
--- a/e2e/react-router/basic-file-based/src/routeTree.gen.ts
+++ b/e2e/react-router/basic-file-based/src/routeTree.gen.ts
@@ -36,6 +36,7 @@ import { Route as RedirectPreloadFirstImport } from './routes/redirect/preload/f
 import { Route as RedirectTargetViaLoaderImport } from './routes/redirect/$target/via-loader'
 import { Route as RedirectTargetViaBeforeLoadImport } from './routes/redirect/$target/via-beforeLoad'
 import { Route as PostsPostIdEditImport } from './routes/posts_.$postId.edit'
+import { Route as ParamsSingleValueImport } from './routes/params.single.$value'
 import { Route as LayoutLayout2LayoutBImport } from './routes/_layout/_layout-2/layout-b'
 import { Route as LayoutLayout2LayoutAImport } from './routes/_layout/_layout-2/layout-a'
 import { Route as groupSubfolderInsideImport } from './routes/(group)/subfolder/inside'
@@ -191,6 +192,12 @@ const PostsPostIdEditRoute = PostsPostIdEditImport.update({
   getParentRoute: () => rootRoute,
 } as any)
 
+const ParamsSingleValueRoute = ParamsSingleValueImport.update({
+  id: '/params/single/$value',
+  path: '/params/single/$value',
+  getParentRoute: () => rootRoute,
+} as any)
+
 const LayoutLayout2LayoutBRoute = LayoutLayout2LayoutBImport.update({
   id: '/layout-b',
   path: '/layout-b',
@@ -366,6 +373,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof LayoutLayout2LayoutBImport
       parentRoute: typeof LayoutLayout2Import
     }
+    '/params/single/$value': {
+      id: '/params/single/$value'
+      path: '/params/single/$value'
+      fullPath: '/params/single/$value'
+      preLoaderRoute: typeof ParamsSingleValueImport
+      parentRoute: typeof rootRoute
+    }
     '/posts_/$postId/edit': {
       id: '/posts_/$postId/edit'
       path: '/posts/$postId/edit'
@@ -520,6 +534,7 @@ export interface FileRoutesByFullPath {
   '/subfolder/inside': typeof groupSubfolderInsideRoute
   '/layout-a': typeof LayoutLayout2LayoutARoute
   '/layout-b': typeof LayoutLayout2LayoutBRoute
+  '/params/single/$value': typeof ParamsSingleValueRoute
   '/posts/$postId/edit': typeof PostsPostIdEditRoute
   '/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute
   '/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute
@@ -546,6 +561,7 @@ export interface FileRoutesByTo {
   '/subfolder/inside': typeof groupSubfolderInsideRoute
   '/layout-a': typeof LayoutLayout2LayoutARoute
   '/layout-b': typeof LayoutLayout2LayoutBRoute
+  '/params/single/$value': typeof ParamsSingleValueRoute
   '/posts/$postId/edit': typeof PostsPostIdEditRoute
   '/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute
   '/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute
@@ -578,6 +594,7 @@ export interface FileRoutesById {
   '/(group)/subfolder/inside': typeof groupSubfolderInsideRoute
   '/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute
   '/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute
+  '/params/single/$value': typeof ParamsSingleValueRoute
   '/posts_/$postId/edit': typeof PostsPostIdEditRoute
   '/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute
   '/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute
@@ -608,6 +625,7 @@ export interface FileRouteTypes {
     | '/subfolder/inside'
     | '/layout-a'
     | '/layout-b'
+    | '/params/single/$value'
     | '/posts/$postId/edit'
     | '/redirect/$target/via-beforeLoad'
     | '/redirect/$target/via-loader'
@@ -633,6 +651,7 @@ export interface FileRouteTypes {
     | '/subfolder/inside'
     | '/layout-a'
     | '/layout-b'
+    | '/params/single/$value'
     | '/posts/$postId/edit'
     | '/redirect/$target/via-beforeLoad'
     | '/redirect/$target/via-loader'
@@ -663,6 +682,7 @@ export interface FileRouteTypes {
     | '/(group)/subfolder/inside'
     | '/_layout/_layout-2/layout-a'
     | '/_layout/_layout-2/layout-b'
+    | '/params/single/$value'
     | '/posts_/$postId/edit'
     | '/redirect/$target/via-beforeLoad'
     | '/redirect/$target/via-loader'
@@ -685,6 +705,7 @@ export interface RootRouteChildren {
   RedirectTargetRoute: typeof RedirectTargetRouteWithChildren
   StructuralSharingEnabledRoute: typeof StructuralSharingEnabledRoute
   RedirectIndexRoute: typeof RedirectIndexRoute
+  ParamsSingleValueRoute: typeof ParamsSingleValueRoute
   PostsPostIdEditRoute: typeof PostsPostIdEditRoute
   RedirectPreloadFirstRoute: typeof RedirectPreloadFirstRoute
   RedirectPreloadSecondRoute: typeof RedirectPreloadSecondRoute
@@ -703,6 +724,7 @@ const rootRouteChildren: RootRouteChildren = {
   RedirectTargetRoute: RedirectTargetRouteWithChildren,
   StructuralSharingEnabledRoute: StructuralSharingEnabledRoute,
   RedirectIndexRoute: RedirectIndexRoute,
+  ParamsSingleValueRoute: ParamsSingleValueRoute,
   PostsPostIdEditRoute: PostsPostIdEditRoute,
   RedirectPreloadFirstRoute: RedirectPreloadFirstRoute,
   RedirectPreloadSecondRoute: RedirectPreloadSecondRoute,
@@ -730,6 +752,7 @@ export const routeTree = rootRoute
         "/redirect/$target",
         "/structural-sharing/$enabled",
         "/redirect/",
+        "/params/single/$value",
         "/posts_/$postId/edit",
         "/redirect/preload/first",
         "/redirect/preload/second",
@@ -834,6 +857,9 @@ export const routeTree = rootRoute
       "filePath": "_layout/_layout-2/layout-b.tsx",
       "parent": "/_layout/_layout-2"
     },
+    "/params/single/$value": {
+      "filePath": "params.single.$value.tsx"
+    },
     "/posts_/$postId/edit": {
       "filePath": "posts_.$postId.edit.tsx"
     },
diff --git a/e2e/react-router/basic-file-based/src/routes/params.single.$value.tsx b/e2e/react-router/basic-file-based/src/routes/params.single.$value.tsx
new file mode 100644
index 000000000..b5761e839
--- /dev/null
+++ b/e2e/react-router/basic-file-based/src/routes/params.single.$value.tsx
@@ -0,0 +1,42 @@
+import * as React from 'react'
+import { Link, createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/params/single/$value')({
+  component: RouteComponent,
+})
+
+function RouteComponent() {
+  const value = Route.useParams({ select: (s) => s.value })
+
+  return (
+    <div className="p-2 grid gap-4">
+      <div>
+        <h2>What's the value???</h2>
+        <p>Check path param value is printed correctly for a single param.</p>
+      </div>
+      <p>
+        Value: <span data-testid="parsed-param-value">{value}</span>
+      </p>
+      <div className="flex gap-2">
+        <Link
+          to="/params/single/$value"
+          params={{ value }}
+          reloadDocument
+          className="border p-2"
+          data-testid="self-link-same"
+        >
+          Self link to same
+        </Link>
+        <Link
+          to="/params/single/$value"
+          params={{ value: `e2e${value}` }}
+          reloadDocument
+          className="border p-2"
+          data-testid="self-link-amended"
+        >
+          Self link to amended value {`e2e${value}`}
+        </Link>
+      </div>
+    </div>
+  )
+}
diff --git a/e2e/react-router/basic-file-based/tests/params.spec.ts b/e2e/react-router/basic-file-based/tests/params.spec.ts
new file mode 100644
index 000000000..e95bc56c5
--- /dev/null
+++ b/e2e/react-router/basic-file-based/tests/params.spec.ts
@@ -0,0 +1,56 @@
+import { expect, test } from '@playwright/test'
+import type { Page } from '@playwright/test'
+
+test.beforeEach(async ({ page }) => {
+  await page.goto('/')
+})
+
+test.describe('ensure single params have been parsed correctly whilst being stable in the browser', () => {
+  const cases = [
+    { value: 'hello', expected: 'hello' },
+    {
+      value: '100%25',
+      expected: '100%',
+    },
+    {
+      value: '100%2525',
+      expected: '100%25',
+    },
+    {
+      value: '100%26',
+      expected: '100&',
+    },
+  ]
+
+  function getParsedValue(page: Page) {
+    return page.getByTestId('parsed-param-value').textContent()
+  }
+
+  for (const { value, expected } of cases) {
+    test(`navigating to /params/single/${value}`, async ({ page, baseURL }) => {
+      await page.goto(`/params/single/${value}`)
+
+      // on the first run, the value should be the same as the expected value
+      const valueOnFirstRun = await getParsedValue(page)
+      expect(valueOnFirstRun).toBe(expected)
+
+      // the url/pathname should be the same as the expected value
+      const urlOnFirstRun = page.url().replace(baseURL!, '')
+      expect(urlOnFirstRun).toBe(`/params/single/${value}`)
+
+      // click on the self link to the same value
+      await page.getByTestId('self-link-same').click()
+      const valueOnSecondRun = await getParsedValue(page)
+      expect(valueOnSecondRun).toBe(expected)
+
+      // click on the self link to the amended value
+      await page.getByTestId('self-link-amended').click()
+      const valueOnThirdRun = await getParsedValue(page)
+      expect(valueOnThirdRun).toBe(`e2e${expected}`)
+
+      // the url/pathname should be the same as the expected value
+      const urlOnThirdRun = page.url().replace(baseURL!, '')
+      expect(urlOnThirdRun).toBe(`/params/single/e2e${value}`)
+    })
+  }
+})
diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx
index eb77d36f5..1a295a3b0 100644
--- a/packages/react-router/tests/router.test.tsx
+++ b/packages/react-router/tests/router.test.tsx
@@ -333,6 +333,26 @@ describe('encoding: URL param segment for /posts/$slug', () => {
     expect(router.state.location.pathname).toBe('/posts/🚀')
   })
 
+  it('state.location.pathname, should have the params.slug value of "100%25"', async () => {
+    const { router } = createTestRouter({
+      history: createMemoryHistory({ initialEntries: ['/posts/100%25'] }),
+    })
+
+    await act(() => router.load())
+
+    expect(router.state.location.pathname).toBe('/posts/100%25')
+  })
+
+  it('state.location.pathname, should have the params.slug value of "100%26"', async () => {
+    const { router } = createTestRouter({
+      history: createMemoryHistory({ initialEntries: ['/posts/100%26'] }),
+    })
+
+    await act(() => router.load())
+
+    expect(router.state.location.pathname).toBe('/posts/100%26')
+  })
+
   it('state.location.pathname, should have the params.slug value of "%F0%9F%9A%80"', async () => {
     const { router } = createTestRouter({
       history: createMemoryHistory({ initialEntries: ['/posts/%F0%9F%9A%80'] }),
@@ -413,6 +433,78 @@ describe('encoding: URL param segment for /posts/$slug', () => {
     expect((match.params as unknown as any).slug).toBe('🚀')
   })
 
+  it('params.slug for the matched route, should be "100%"', async () => {
+    const { router, routes } = createTestRouter({
+      history: createMemoryHistory({ initialEntries: ['/posts/100%25'] }),
+    })
+
+    await act(() => router.load())
+
+    const match = router.state.matches.find(
+      (r) => r.routeId === routes.postIdRoute.id,
+    )
+
+    if (!match) {
+      throw new Error('No match found')
+    }
+
+    expect((match.params as unknown as any).slug).toBe('100%')
+  })
+
+  it('params.slug for the matched route, should be "100&"', async () => {
+    const { router, routes } = createTestRouter({
+      history: createMemoryHistory({ initialEntries: ['/posts/100%26'] }),
+    })
+
+    await act(() => router.load())
+
+    const match = router.state.matches.find(
+      (r) => r.routeId === routes.postIdRoute.id,
+    )
+
+    if (!match) {
+      throw new Error('No match found')
+    }
+
+    expect((match.params as unknown as any).slug).toBe('100&')
+  })
+
+  it('params.slug for the matched route, should be "100%100"', async () => {
+    const { router, routes } = createTestRouter({
+      history: createMemoryHistory({ initialEntries: ['/posts/100%25100'] }),
+    })
+
+    await act(() => router.load())
+
+    const match = router.state.matches.find(
+      (r) => r.routeId === routes.postIdRoute.id,
+    )
+
+    if (!match) {
+      throw new Error('No match found')
+    }
+
+    expect((match.params as unknown as any).slug).toBe('100%100')
+  })
+
+  it('params.slug for the matched route, should be "100&100"', async () => {
+    const { router, routes } = createTestRouter({
+      history: createMemoryHistory({ initialEntries: ['/posts/100%26100'] }),
+    })
+
+    await act(() => router.load())
+
+    const match = router.state.matches.find(
+      (r) => r.routeId === routes.postIdRoute.id,
+    )
+
+    if (!match) {
+      throw new Error('No match found')
+    }
+
+    expect((match.params as unknown as any).slug).toBe('100&100')
+  })
+
   it('params.slug for the matched route, should be "framework/react/guide/file-based-routing tanstack" instead of it being "framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack"', async () => {
     const { router, routes } = createTestRouter({
       history: createMemoryHistory({
@@ -490,6 +582,26 @@ describe('encoding: URL splat segment for /$', () => {
     expect(router.state.location.pathname).toBe('/🚀')
   })
 
+  it('state.location.pathname, should have the params._splat value of "100%25"', async () => {
+    const { router } = createTestRouter({
+      history: createMemoryHistory({ initialEntries: ['/100%25'] }),
+    })
+
+    await router.load()
+
+    expect(router.state.location.pathname).toBe('/100%25')
+  })
+
+  it('state.location.pathname, should have the params._splat value of "100%26"', async () => {
+    const { router } = createTestRouter({
+      history: createMemoryHistory({ initialEntries: ['/100%26'] }),
+    })
+
+    await router.load()
+
+    expect(router.state.location.pathname).toBe('/100%26')
+  })
+
   it('state.location.pathname, should have the params._splat value of "%F0%9F%9A%80"', async () => {
     const { router } = createTestRouter({
       history: createMemoryHistory({ initialEntries: ['/%F0%9F%9A%80'] }),
@@ -619,6 +731,21 @@ describe('encoding: URL path segment', () => {
       output: '/path-segment/é',
       type: 'not encoded',
     },
+    {
+      input: '/path-segment/100%25', // `%25` = `%`
+      output: '/path-segment/100%25',
+      type: 'not encoded',
+    },
+    {
+      input: '/path-segment/100%25%25',
+      output: '/path-segment/100%25%25',
+      type: 'not encoded',
+    },
+    {
+      input: '/path-segment/100%26', // `%26` = `&`
+      output: '/path-segment/100%26',
+      type: 'not encoded',
+    },
     {
       input: '/path-segment/%F0%9F%9A%80',
       output: '/path-segment/🚀',
@@ -629,6 +756,22 @@ describe('encoding: URL path segment', () => {
       output: '/path-segment/🚀to%2Fthe%2Fmoon',
       type: 'encoded',
     },
+    {
+      input: '/path-segment/%25%F0%9F%9A%80to%2Fthe%2Fmoon',
+      output: '/path-segment/%25🚀to%2Fthe%2Fmoon',
+      type: 'encoded',
+    },
+    {
+      input: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25',
+      output: '/path-segment/🚀to%2Fthe%2Fmoon%25',
+      type: 'encoded',
+    },
+    {
+      input:
+        '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25%F0%9F%9A%80to%2Fthe%2Fmoon',
+      output: '/path-segment/🚀to%2Fthe%2Fmoon%25🚀to%2Fthe%2Fmoon',
+      type: 'encoded',
+    },
     {
       input: '/path-segment/🚀',
       output: '/path-segment/🚀',