From d3867326013250e8bf8d417294c955297cf25094 Mon Sep 17 00:00:00 2001
From: Sheraff <fpellet@ensc.fr>
Date: Fri, 23 Jan 2026 17:26:00 +0100
Subject: [PATCH] refactor(router-core): improve interpolatePath performance by
 pre-compiling a single regex decoder

---
 packages/router-core/src/path.ts              | 52 +++++++++++++------
 packages/router-core/src/router.ts            | 19 +++----
 packages/router-core/tests/path.test.ts       |  9 ++--
 .../src/BaseTanStackRouterDevtoolsPanel.tsx   |  2 +-
 4 files changed, 49 insertions(+), 33 deletions(-)

diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts
index 859a900496..a8032d1fd7 100644
--- a/packages/router-core/src/path.ts
+++ b/packages/router-core/src/path.ts
@@ -197,11 +197,33 @@ export function resolvePath({
   return result
 }
 
+/**
+ * Create a pre-compiled decode config from allowed characters.
+ * This should be called once at router initialization.
+ */
+export function compileDecodeCharMap(
+  pathParamsAllowedCharacters: ReadonlyArray<string>,
+) {
+  const charMap = new Map(
+    pathParamsAllowedCharacters.map((char) => [encodeURIComponent(char), char]),
+  )
+  // Escape special regex characters and join with |
+  const pattern = Array.from(charMap.keys())
+    .map((key) => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+    .join('|')
+  const regex = new RegExp(pattern, 'g')
+  return (encoded: string) =>
+    encoded.replace(regex, (match) => charMap.get(match) ?? match)
+}
+
 interface InterpolatePathOptions {
   path?: string
   params: Record<string, unknown>
-  // Map of encoded chars to decoded chars (e.g. '%40' -> '@') that should remain decoded in path params
-  decodeCharMap?: Map<string, string>
+  /**
+   * A function that decodes a path parameter value.
+   * Obtained from `compileDecodeCharMap(pathParamsAllowedCharacters)`.
+   */
+  decoder?: (encoded: string) => string
 }
 
 type InterPolatePathResult = {
@@ -213,7 +235,7 @@ type InterPolatePathResult = {
 function encodeParam(
   key: string,
   params: InterpolatePathOptions['params'],
-  decodeCharMap: InterpolatePathOptions['decodeCharMap'],
+  decoder: InterpolatePathOptions['decoder'],
 ): any {
   const value = params[key]
   if (typeof value !== 'string') return value
@@ -222,7 +244,7 @@ function encodeParam(
     // the splat/catch-all routes shouldn't have the '/' encoded out
     return encodeURI(value)
   } else {
-    return encodePathParam(value, decodeCharMap)
+    return encodePathParam(value, decoder)
   }
 }
 
@@ -235,7 +257,7 @@ function encodeParam(
 export function interpolatePath({
   path,
   params,
-  decodeCharMap,
+  decoder,
 }: InterpolatePathOptions): InterPolatePathResult {
   // Tracking if any params are missing in the `params` object
   // when interpolating the path
@@ -286,7 +308,7 @@ export function interpolatePath({
         continue
       }
 
-      const value = encodeParam('_splat', params, decodeCharMap)
+      const value = encodeParam('_splat', params, decoder)
       joined += '/' + prefix + value + suffix
       continue
     }
@@ -300,7 +322,7 @@ export function interpolatePath({
 
       const prefix = path.substring(start, segment[1])
       const suffix = path.substring(segment[4], end)
-      const value = encodeParam(key, params, decodeCharMap) ?? 'undefined'
+      const value = encodeParam(key, params, decoder) ?? 'undefined'
       joined += '/' + prefix + value + suffix
       continue
     }
@@ -316,7 +338,7 @@ export function interpolatePath({
 
       const prefix = path.substring(start, segment[1])
       const suffix = path.substring(segment[4], end)
-      const value = encodeParam(key, params, decodeCharMap) ?? ''
+      const value = encodeParam(key, params, decoder) ?? ''
       joined += '/' + prefix + value + suffix
       continue
     }
@@ -329,12 +351,10 @@ export function interpolatePath({
   return { usedParams, interpolatedPath, isMissingParams }
 }
 
-function encodePathParam(value: string, decodeCharMap?: Map<string, string>) {
-  let encoded = encodeURIComponent(value)
-  if (decodeCharMap) {
-    for (const [encodedChar, char] of decodeCharMap) {
-      encoded = encoded.replaceAll(encodedChar, char)
-    }
-  }
-  return encoded
+function encodePathParam(
+  value: string,
+  decoder?: InterpolatePathOptions['decoder'],
+) {
+  const encoded = encodeURIComponent(value)
+  return decoder?.(encoded) ?? encoded
 }
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 4946c8aefb..0d224286ea 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -19,6 +19,7 @@ import {
 } from './new-process-route-tree'
 import {
   cleanPath,
+  compileDecodeCharMap,
   interpolatePath,
   resolvePath,
   trimPath,
@@ -923,7 +924,7 @@ export class RouterCore<
   routesByPath!: RoutesByPath<TRouteTree>
   processedTree!: ProcessedTree<TRouteTree, any, any>
   isServer!: boolean
-  pathParamsDecodeCharMap?: Map<string, string>
+  pathParamsDecoder?: (encoded: string) => string
 
   /**
    * @deprecated Use the `createRouter` function instead
@@ -992,14 +993,10 @@ export class RouterCore<
 
     this.isServer = this.options.isServer ?? typeof document === 'undefined'
 
-    this.pathParamsDecodeCharMap = this.options.pathParamsAllowedCharacters
-      ? new Map(
-          this.options.pathParamsAllowedCharacters.map((char) => [
-            encodeURIComponent(char),
-            char,
-          ]),
-        )
-      : undefined
+    if (this.options.pathParamsAllowedCharacters)
+      this.pathParamsDecoder = compileDecodeCharMap(
+        this.options.pathParamsAllowedCharacters,
+      )
 
     if (
       !this.history ||
@@ -1365,7 +1362,7 @@ export class RouterCore<
       const { interpolatedPath, usedParams } = interpolatePath({
         path: route.fullPath,
         params: routeParams,
-        decodeCharMap: this.pathParamsDecodeCharMap,
+        decoder: this.pathParamsDecoder,
       })
 
       // Waste not, want not. If we already have a match for this route,
@@ -1721,7 +1718,7 @@ export class RouterCore<
             interpolatePath({
               path: nextTo,
               params: nextParams,
-              decodeCharMap: this.pathParamsDecodeCharMap,
+              decoder: this.pathParamsDecoder,
             }).interpolatedPath,
           )
 
diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts
index 2d47c1bd76..f9467cac06 100644
--- a/packages/router-core/tests/path.test.ts
+++ b/packages/router-core/tests/path.test.ts
@@ -1,5 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import {
+  compileDecodeCharMap,
   exactPathTest,
   interpolatePath,
   removeTrailingSlash,
@@ -309,9 +310,7 @@ describe('interpolatePath', () => {
         path: '/users/$id',
         params: { id: '?#@john+smith' },
         result: '/users/%3F%23@john+smith',
-        decodeCharMap: new Map(
-          ['@', '+'].map((char) => [encodeURIComponent(char), char]),
-        ),
+        decoder: compileDecodeCharMap(['@', '+']),
       },
       {
         name: 'should interpolate the path with the splat param at the end',
@@ -348,12 +347,12 @@ describe('interpolatePath', () => {
         params: { _splat: 'sean/cassiere' },
         result: '/users/sean/cassiere',
       },
-    ])('$name', ({ path, params, decodeCharMap, result }) => {
+    ])('$name', ({ path, params, decoder, result }) => {
       expect(
         interpolatePath({
           path,
           params,
-          decodeCharMap,
+          decoder,
         }).interpolatedPath,
       ).toBe(result)
     })
diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx
index 67e67877d3..c306d82d80 100644
--- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx
+++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx
@@ -178,7 +178,7 @@ function RouteComp({
     const interpolated = interpolatePath({
       path: route.fullPath,
       params: allParams,
-      decodeCharMap: router().pathParamsDecodeCharMap,
+      decoder: router().pathParamsDecoder,
     })
 
     // only if `interpolated` is not missing params, return the path since this
