diff --git a/docs/framework/react/api/router/RouterOptionsType.md b/docs/framework/react/api/router/RouterOptionsType.md index 394d367aaab..b19943321e0 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 351d3a0f548..453c6da8a6e 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 { diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 885718f542f..edb7dca2504 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 + 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() { diff --git a/packages/react-router/tests/index.test.ts b/packages/react-router/tests/index.test.ts index db702f5dc3e..906988e846b 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') + }) }) }) })