Repository: TanStack/router
Pull request #1464 (2024-04-12 20:52:21)

feat: allow to configure trailing slash behavior

nx-cloud[bot]: # ☁️ Nx Cloud Report

CI is running for commit 96c2aa84fc6c7c5cb200ec6390f691f66b914c95.

[📂 Click to track the progress, see the status, the terminal output, and the build insights.](https://cloud.nx.app/cipes/66199f1ca1393226f85c7222?utm_source=pull-request&utm_medium=comment)


---

Sent with 💌 from [NxCloud.](https://nx.app?utm_source=pull-request&utm_medium=comment)

Patch:
diff --git a/docs/framework/react/api/router/RouterOptionsType.md b/docs/framework/react/api/router/RouterOptionsType.md
index 394d367aa..b19943321 100644
--- a/docs/framework/react/api/router/RouterOptionsType.md
+++ b/docs/framework/react/api/router/RouterOptionsType.md
@@ -190,3 +190,10 @@ const router = createRouter({
 - Type: [`RouterTransformer`](../RouterTransformerType)
 - Optional
 - The transformer that will be used when sending data between the server and the client during SSR.
+
+### `trailingSlash` property
+
+- Type: `'always' | 'never' | 'preserve'`
+- Optional
+- Defaults to `never`
+- Configures how trailing slashes are treated. `'always'` will add a trailing slash if not present, `'never'` will remove the trailing slash if present and `'preserve'` will not modify the trailing slash.
diff --git a/packages/react-router/src/path.ts b/packages/react-router/src/path.ts
index 351d3a0f5..453c6da8a 100644
--- a/packages/react-router/src/path.ts
+++ b/packages/react-router/src/path.ts
@@ -54,7 +54,18 @@ export function trimPath(path: string) {
 // /a/b/c + d = /a/b/c/d
 // /a/b/c + d/ = /a/b/c/d
 // /a/b/c + d/e = /a/b/c/d/e
-export function resolvePath(basepath: string, base: string, to: string) {
+interface ResolvePathOptions {
+  basepath: string
+  base: string
+  to: string
+  trailingSlash?: 'always' | 'never' | 'preserve'
+}
+export function resolvePath({
+  basepath,
+  base,
+  to,
+  trailingSlash = 'never',
+}: ResolvePathOptions) {
   base = base.replace(new RegExp(`^${basepath}`), '/')
   to = to.replace(new RegExp(`^${basepath}`), '/')
 
@@ -85,9 +96,18 @@ export function resolvePath(basepath: string, base: string, to: string) {
     }
   })
 
-  const joined = joinPaths([basepath, ...baseSegments.map((d) => d.value)])
+  if (baseSegments.length > 1) {
+    if (last(baseSegments)?.value === '/') {
+      if (trailingSlash === 'never') {
+        baseSegments.pop()
+      }
+    } else if (trailingSlash === 'always') {
+      baseSegments.push({ type: 'pathname', value: '/' })
+    }
+  }
 
-  return cleanPath(trimPathRight(joined))
+  const joined = joinPaths([basepath, ...baseSegments.map((d) => d.value)])
+  return cleanPath(joined)
 }
 
 export function parsePathname(pathname?: string): Array<Segment> {
diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts
index 885718f54..edb7dca25 100644
--- a/packages/react-router/src/router.ts
+++ b/packages/react-router/src/router.ts
@@ -150,6 +150,7 @@ export interface RouterOptions<
   defaultNotFoundComponent?: NotFoundRouteComponent
   transformer?: RouterTransformer
   errorSerializer?: RouterErrorSerializer<TSerializedError>
+  trailingSlash?: 'always' | 'never' | 'preserve'
 }
 
 export interface RouterTransformer {
@@ -591,7 +592,13 @@ export class Router<
   }
 
   resolvePathWithBase = (from: string, path: string) => {
-    return resolvePath(this.basepath, from, cleanPath(path))
+    const resolvedPath = resolvePath({
+      basepath: this.basepath,
+      base: from,
+      to: cleanPath(path),
+      trailingSlash: this.options.trailingSlash,
+    })
+    return resolvedPath
   }
 
   get looseRoutesById() {


Test patch:
diff --git a/packages/react-router/tests/index.test.ts b/packages/react-router/tests/index.test.ts
index db702f5dc..906988e84 100644
--- a/packages/react-router/tests/index.test.ts
+++ b/packages/react-router/tests/index.test.ts
@@ -572,13 +572,83 @@ describe('resolvePath', () => {
     ] as const
   ).forEach(([base, a, b, eq]) => {
     test(`Base: ${base} - ${a} to ${b} === ${eq}`, () => {
-      expect(resolvePath(base, a, b)).toEqual(eq)
+      expect(resolvePath({ basepath: base, base: a, to: b })).toEqual(eq)
     })
     test(`Base: ${base} - ${a}/ to ${b} === ${eq} (trailing slash)`, () => {
-      expect(resolvePath(base, a + '/', b)).toEqual(eq)
+      expect(resolvePath({ basepath: base, base: a + '/', to: b })).toEqual(eq)
     })
     test(`Base: ${base} - ${a}/ to ${b}/ === ${eq} (trailing slash + trailing slash)`, () => {
-      expect(resolvePath(base, a + '/', b + '/')).toEqual(eq)
+      expect(
+        resolvePath({ basepath: base, base: a + '/', to: b + '/' }),
+      ).toEqual(eq)
+    })
+  })
+  describe('trailingSlash', () => {
+    describe(`'always'`, () => {
+      test('keeps trailing slash', () => {
+        expect(
+          resolvePath({
+            basepath: '/',
+            base: '/a/b/c',
+            to: 'd/',
+            trailingSlash: 'always',
+          }),
+        ).toBe('/a/b/c/d/')
+      })
+      test('adds trailing slash', () => {
+        expect(
+          resolvePath({
+            basepath: '/',
+            base: '/a/b/c',
+            to: 'd',
+            trailingSlash: 'always',
+          }),
+        ).toBe('/a/b/c/d/')
+      })
+    })
+    describe(`'never'`, () => {
+      test('removes trailing slash', () => {
+        expect(
+          resolvePath({
+            basepath: '/',
+            base: '/a/b/c',
+            to: 'd/',
+            trailingSlash: 'never',
+          }),
+        ).toBe('/a/b/c/d')
+      })
+      test('does not add trailing slash', () => {
+        expect(
+          resolvePath({
+            basepath: '/',
+            base: '/a/b/c',
+            to: 'd',
+            trailingSlash: 'never',
+          }),
+        ).toBe('/a/b/c/d')
+      })
+    })
+    describe(`'preserve'`, () => {
+      test('keeps trailing slash', () => {
+        expect(
+          resolvePath({
+            basepath: '/',
+            base: '/a/b/c',
+            to: 'd/',
+            trailingSlash: 'preserve',
+          }),
+        ).toBe('/a/b/c/d/')
+      })
+      test('does not add trailing slash', () => {
+        expect(
+          resolvePath({
+            basepath: '/',
+            base: '/a/b/c',
+            to: 'd',
+            trailingSlash: 'preserve',
+          }),
+        ).toBe('/a/b/c/d')
+      })
     })
   })
 })