Repository: TanStack/router (ts)

Issue:
Router.basepath is case sensitive
### Which project does this relate to?

Router

### Describe the bug

Navigating to a basepath URL that does not match the exact case of `basepath` mangles the URL.

### Your Example Website or App

https://github.com/sethlivingston/tanstack-router/blob/862fe551ff139eacca86fa25eb6dbcbc9c84ef23/app/client.tsx#L7

### Steps to Reproduce the Bug or Issue

1. Set the router basepath to a string with uppercase letters. See the example link.
2. Navigate to a path with all lowercase letters. Don't let the browser change the case of the URL right before you navigate; it does this to be "helpful".

### Expected behavior

I expect the all URL matching to be case insensitive.

### Screenshots or Videos

_No response_

### Platform

- OS: Windows
- Browser: Edge & Chrome
- Version: latest


### Additional context

I can submit a PR for this if you'd like. It looks like it's using a common trim function that is case sensitive. You probably don't want to change the common function. Perhaps create a different one, or add an optional parameter for comparison options.

Pull request description:
fix(react-router): basepath case sensitivity during path resolution
Fixes #2637 

Patch:
diff --git a/packages/react-router/src/path.ts b/packages/react-router/src/path.ts
index 7569ba31b..53c3ffe00 100644
--- a/packages/react-router/src/path.ts
+++ b/packages/react-router/src/path.ts
@@ -87,6 +87,7 @@ interface ResolvePathOptions {
   base: string
   to: string
   trailingSlash?: 'always' | 'never' | 'preserve'
+  caseSensitive?: boolean
 }
 
 export function resolvePath({
@@ -94,9 +95,10 @@ export function resolvePath({
   base,
   to,
   trailingSlash = 'never',
+  caseSensitive,
 }: ResolvePathOptions) {
-  base = removeBasepath(basepath, base)
-  to = removeBasepath(basepath, to)
+  base = removeBasepath(basepath, base, caseSensitive)
+  to = removeBasepath(basepath, to, caseSensitive)
 
   let baseSegments = parsePathname(base)
   const toSegments = parsePathname(to)
@@ -260,15 +262,23 @@ export function matchPathname(
   return pathParams ?? {}
 }
 
-export function removeBasepath(basepath: string, pathname: string) {
+export function removeBasepath(
+  basepath: string,
+  pathname: string,
+  caseSensitive: boolean = false,
+) {
+  // normalize basepath and pathname for case-insensitive comparison if needed
+  const normalizedBasepath = caseSensitive ? basepath : basepath.toLowerCase()
+  const normalizedPathname = caseSensitive ? pathname : pathname.toLowerCase()
+
   switch (true) {
     // default behaviour is to serve app from the root - pathname
     // left untouched
-    case basepath === '/':
+    case normalizedBasepath === '/':
       return pathname
 
-    // shortcut for removing the basepath from the equal pathname
-    case pathname === basepath:
+    // shortcut for removing the basepath if it matches the pathname
+    case normalizedPathname === normalizedBasepath:
       return ''
 
     // in case pathname is shorter than basepath - there is
@@ -280,11 +290,11 @@ export function removeBasepath(basepath: string, pathname: string) {
     // earlier, otherwise, basepath separated from pathname with
     // separator, therefore lack of separator means partial
     // segment match (`/app` should not match `/application`)
-    case pathname[basepath.length] !== '/':
+    case normalizedPathname[normalizedBasepath.length] !== '/':
       return pathname
 
-    // remove the basepath from the pathname in case there is any
-    case pathname.startsWith(basepath):
+    // remove the basepath from the pathname if it starts with it
+    case normalizedPathname.startsWith(normalizedBasepath):
       return pathname.slice(basepath.length)
 
     // otherwise, return the pathname as is
@@ -303,9 +313,13 @@ export function matchByPath(
     return undefined
   }
   // Remove the base path from the pathname
-  from = removeBasepath(basepath, from)
+  from = removeBasepath(basepath, from, matchLocation.caseSensitive)
   // Default to to $ (wildcard)
-  const to = removeBasepath(basepath, `${matchLocation.to ?? '$'}`)
+  const to = removeBasepath(
+    basepath,
+    `${matchLocation.to ?? '$'}`,
+    matchLocation.caseSensitive,
+  )
 
   // Parse the from and to
   const baseSegments = parsePathname(from)
diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts
index 52f1150af..5e2ae9616 100644
--- a/packages/react-router/src/router.ts
+++ b/packages/react-router/src/router.ts
@@ -722,6 +722,7 @@ export class Router<
       defaultPendingMinMs: 500,
       context: undefined!,
       ...options,
+      caseSensitive: options.caseSensitive ?? false,
       notFoundMode: options.notFoundMode ?? 'fuzzy',
       stringifySearch: options.stringifySearch ?? defaultStringifySearch,
       parseSearch: options.parseSearch ?? defaultParseSearch,
@@ -1007,6 +1008,7 @@ export class Router<
       base: from,
       to: cleanPath(path),
       trailingSlash: this.options.trailingSlash,
+      caseSensitive: this.options.caseSensitive,
     })
     return resolvedPath
   }


Test patch:
diff --git a/packages/react-router/tests/path.test.ts b/packages/react-router/tests/path.test.ts
index 0e9212524..5e3f559b4 100644
--- a/packages/react-router/tests/path.test.ts
+++ b/packages/react-router/tests/path.test.ts
@@ -49,6 +49,45 @@ describe('removeBasepath', () => {
   ])('$name', ({ basepath, pathname, expected }) => {
     expect(removeBasepath(basepath, pathname)).toBe(expected)
   })
+  describe('case sensitivity', () => {
+    describe('caseSensitive = true', () => {
+      it.each([
+        {
+          name: 'should not remove basepath from the beginning of the pathname',
+          basepath: '/app',
+          pathname: '/App/path/App',
+          expected: '/App/path/App',
+        },
+        {
+          name: 'should not remove basepath from the beginning of the pathname with multiple segments',
+          basepath: '/app/New',
+          pathname: '/App/New/path/App',
+          expected: '/App/New/path/App',
+        },
+      ])('$name', ({ basepath, pathname, expected }) => {
+        expect(removeBasepath(basepath, pathname, true)).toBe(expected)
+      })
+    })
+
+    describe('caseSensitive = false', () => {
+      it.each([
+        {
+          name: 'should remove basepath from the beginning of the pathname',
+          basepath: '/App',
+          pathname: '/app/path/app',
+          expected: '/path/app',
+        },
+        {
+          name: 'should remove multisegment basepath from the beginning of the pathname',
+          basepath: '/App/New',
+          pathname: '/app/new/path/app',
+          expected: '/path/app',
+        },
+      ])('$name', ({ basepath, pathname, expected }) => {
+        expect(removeBasepath(basepath, pathname, false)).toBe(expected)
+      })
+    })
+  })
 })
 
 describe.each([{ basepath: '/' }, { basepath: '/app' }, { basepath: '/app/' }])(