From 1682b3e610cf07fbb3e9e0f14874f27967e05758 Mon Sep 17 00:00:00 2001 From: thenglong Date: Fri, 26 Sep 2025 08:52:42 +0700 Subject: [PATCH 1/2] fix(router): `rewriteBasepath` not working without trailing slash --- packages/react-router/tests/router.test.tsx | 256 ++++++++++++++++++++ packages/router-core/src/rewrite.ts | 25 +- 2 files changed, 276 insertions(+), 5 deletions(-) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index d58dfe2126c..c89ab6d13e3 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -2670,6 +2670,262 @@ describe('rewriteBasepath utility', () => { expect(router.state.location.pathname).toBe('/users') }) + it('should handle basepath with leading slash but without trailing slash', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const usersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/users', + component: () =>
Users
, + }) + + const routeTree = rootRoute.addChildren([usersRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/api/v1/users'], + }), + rewrite: rewriteBasepath({ basepath: '/api/v1' }), // With leading slash but no trailing slash + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('users')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/users') + }) + + it('should handle basepath without leading slash but with trailing slash', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const usersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/users', + component: () =>
Users
, + }) + + const routeTree = rootRoute.addChildren([usersRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/api/v1/users'], + }), + rewrite: rewriteBasepath({ basepath: 'api/v1/' }), // Without leading slash but with trailing slash + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('users')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/users') + }) + + it('should handle basepath without leading and trailing slashes', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const usersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/users', + component: () =>
Users
, + }) + + const routeTree = rootRoute.addChildren([usersRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/api/v1/users'], + }), + rewrite: rewriteBasepath({ basepath: 'api/v1' }), // Without leading and trailing slashes + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('users')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/users') + }) + + it('should not resolve to 404 when basepath has trailing slash and URL matches', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + + const usersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/users', + component: () =>
Users
, + }) + + const routeTree = rootRoute.addChildren([homeRoute, usersRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/my-app/'], + }), + rewrite: rewriteBasepath({ basepath: '/my-app/' }), // With trailing slash + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('home')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/') + expect(router.state.statusCode).toBe(200) + }) + + it('should not resolve to 404 when basepath has no trailing slash and URL matches', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + + const usersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/users', + component: () =>
Users
, + }) + + const routeTree = rootRoute.addChildren([homeRoute, usersRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/my-app'], + }), + rewrite: rewriteBasepath({ basepath: '/my-app' }), // Without trailing slash + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('home')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/') + expect(router.state.statusCode).toBe(200) + }) + + it('should handle basepath with trailing slash when navigating to root path', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+ + About + +
+ ), + }) + + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () =>
About
, + }) + + const routeTree = rootRoute.addChildren([homeRoute, aboutRoute]) + + const history = createMemoryHistory({ initialEntries: ['/my-app/'] }) + + const router = createRouter({ + routeTree, + history, + rewrite: rewriteBasepath({ basepath: '/my-app/' }), // With trailing slash + }) + + render() + + const aboutLink = await screen.findByTestId('about-link') + fireEvent.click(aboutLink) + + await waitFor(() => { + expect(screen.getByTestId('about')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/about') + expect(history.location.pathname).toBe('/my-app/about') + }) + + it('should handle basepath without trailing slash when navigating to root path', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+ + About + +
+ ), + }) + + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () =>
About
, + }) + + const routeTree = rootRoute.addChildren([homeRoute, aboutRoute]) + + const history = createMemoryHistory({ initialEntries: ['/my-app'] }) + + const router = createRouter({ + routeTree, + history, + rewrite: rewriteBasepath({ basepath: '/my-app' }), // Without trailing slash + }) + + render() + + const aboutLink = await screen.findByTestId('about-link') + fireEvent.click(aboutLink) + + await waitFor(() => { + expect(screen.getByTestId('about')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/about') + expect(history.location.pathname).toBe('/my-app/about') + }) + it('should handle empty basepath gracefully', async () => { const rootRoute = createRootRoute({ component: () => , diff --git a/packages/router-core/src/rewrite.ts b/packages/router-core/src/rewrite.ts index c0cef0cc879..1e80cf96f28 100644 --- a/packages/router-core/src/rewrite.ts +++ b/packages/router-core/src/rewrite.ts @@ -23,13 +23,28 @@ export function rewriteBasepath(opts: { caseSensitive?: boolean }) { const trimmedBasepath = trimPath(opts.basepath) - const regex = new RegExp( - `^/${trimmedBasepath}/`, - opts.caseSensitive ? '' : 'i', - ) + const normalizedBasepath = `/${trimmedBasepath}` + const normalizedBasepathWithSlash = `${normalizedBasepath}/` + return { input: ({ url }) => { - url.pathname = url.pathname.replace(regex, '/') + const pathname = opts.caseSensitive + ? url.pathname + : url.pathname.toLowerCase() + const checkBasepath = opts.caseSensitive + ? normalizedBasepath + : normalizedBasepath.toLowerCase() + const checkBasepathWithSlash = opts.caseSensitive + ? normalizedBasepathWithSlash + : normalizedBasepathWithSlash.toLowerCase() + + // Handle exact basepath match (e.g., /my-app -> /) + if (pathname === checkBasepath) { + url.pathname = '/' + } else if (pathname.startsWith(checkBasepathWithSlash)) { + // Handle basepath with trailing content (e.g., /my-app/users -> /users) + url.pathname = url.pathname.slice(normalizedBasepath.length) + } return url }, output: ({ url }) => { From b31414ce6f59b284c6638091a692c9d122f60ffc Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Tue, 30 Sep 2025 22:17:59 +0200 Subject: [PATCH 2/2] remove test duplication, cache case sensitive baespath --- packages/react-router/tests/router.test.tsx | 302 ++++++-------------- packages/router-core/src/rewrite.ts | 12 +- 2 files changed, 98 insertions(+), 216 deletions(-) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index c89ab6d13e3..2214e9001eb 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -2670,67 +2670,20 @@ describe('rewriteBasepath utility', () => { expect(router.state.location.pathname).toBe('/users') }) - it('should handle basepath with leading slash but without trailing slash', async () => { - const rootRoute = createRootRoute({ - component: () => , - }) - - const usersRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/users', - component: () =>
Users
, - }) - - const routeTree = rootRoute.addChildren([usersRoute]) - - const router = createRouter({ - routeTree, - history: createMemoryHistory({ - initialEntries: ['/api/v1/users'], - }), - rewrite: rewriteBasepath({ basepath: '/api/v1' }), // With leading slash but no trailing slash - }) - - render() - - await waitFor(() => { - expect(screen.getByTestId('users')).toBeInTheDocument() - }) - - expect(router.state.location.pathname).toBe('/users') - }) - - it('should handle basepath without leading slash but with trailing slash', async () => { - const rootRoute = createRootRoute({ - component: () => , - }) - - const usersRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/users', - component: () =>
Users
, - }) - - const routeTree = rootRoute.addChildren([usersRoute]) - - const router = createRouter({ - routeTree, - history: createMemoryHistory({ - initialEntries: ['/api/v1/users'], - }), - rewrite: rewriteBasepath({ basepath: 'api/v1/' }), // Without leading slash but with trailing slash - }) - - render() - - await waitFor(() => { - expect(screen.getByTestId('users')).toBeInTheDocument() - }) - - expect(router.state.location.pathname).toBe('/users') - }) - - it('should handle basepath without leading and trailing slashes', async () => { + it.each([ + { + description: 'basepath with leading slash but without trailing slash', + basepath: '/api/v1', + }, + { + description: 'basepath without leading slash but with trailing slash', + basepath: 'api/v1/', + }, + { + description: 'basepath without leading and trailing slashes', + basepath: 'api/v1', + }, + ])('should handle $description', async ({ basepath }) => { const rootRoute = createRootRoute({ component: () => , }) @@ -2748,7 +2701,7 @@ describe('rewriteBasepath utility', () => { history: createMemoryHistory({ initialEntries: ['/api/v1/users'], }), - rewrite: rewriteBasepath({ basepath: 'api/v1' }), // Without leading and trailing slashes + rewrite: rewriteBasepath({ basepath }), }) render() @@ -2760,171 +2713,100 @@ describe('rewriteBasepath utility', () => { expect(router.state.location.pathname).toBe('/users') }) - it('should not resolve to 404 when basepath has trailing slash and URL matches', async () => { - const rootRoute = createRootRoute({ - component: () => , - }) - - const homeRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () =>
Home
, - }) - - const usersRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/users', - component: () =>
Users
, - }) - - const routeTree = rootRoute.addChildren([homeRoute, usersRoute]) - - const router = createRouter({ - routeTree, - history: createMemoryHistory({ - initialEntries: ['/my-app/'], - }), - rewrite: rewriteBasepath({ basepath: '/my-app/' }), // With trailing slash - }) - - render() - - await waitFor(() => { - expect(screen.getByTestId('home')).toBeInTheDocument() - }) - - expect(router.state.location.pathname).toBe('/') - expect(router.state.statusCode).toBe(200) - }) - - it('should not resolve to 404 when basepath has no trailing slash and URL matches', async () => { - const rootRoute = createRootRoute({ - component: () => , - }) - - const homeRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () =>
Home
, - }) - - const usersRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/users', - component: () =>
Users
, - }) - - const routeTree = rootRoute.addChildren([homeRoute, usersRoute]) - - const router = createRouter({ - routeTree, - history: createMemoryHistory({ - initialEntries: ['/my-app'], - }), - rewrite: rewriteBasepath({ basepath: '/my-app' }), // Without trailing slash - }) - - render() - - await waitFor(() => { - expect(screen.getByTestId('home')).toBeInTheDocument() - }) - - expect(router.state.location.pathname).toBe('/') - expect(router.state.statusCode).toBe(200) - }) - - it('should handle basepath with trailing slash when navigating to root path', async () => { - const rootRoute = createRootRoute({ - component: () => , - }) - - const homeRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () => ( -
- - About - -
- ), - }) - - const aboutRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/about', - component: () =>
About
, - }) + it.each([ + { description: 'has trailing slash', basepath: '/my-app/' }, + { description: 'has no trailing slash', basepath: '/my-app' }, + ])( + 'should not resolve to 404 when basepath $description and URL matches', + async ({ basepath }) => { + const rootRoute = createRootRoute({ + component: () => , + }) - const routeTree = rootRoute.addChildren([homeRoute, aboutRoute]) + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) - const history = createMemoryHistory({ initialEntries: ['/my-app/'] }) + const usersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/users', + component: () =>
Users
, + }) - const router = createRouter({ - routeTree, - history, - rewrite: rewriteBasepath({ basepath: '/my-app/' }), // With trailing slash - }) + const routeTree = rootRoute.addChildren([homeRoute, usersRoute]) - render() + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/my-app/'], + }), + rewrite: rewriteBasepath({ basepath }), + }) - const aboutLink = await screen.findByTestId('about-link') - fireEvent.click(aboutLink) + render() - await waitFor(() => { - expect(screen.getByTestId('about')).toBeInTheDocument() - }) + await waitFor(() => { + expect(screen.getByTestId('home')).toBeInTheDocument() + }) - expect(router.state.location.pathname).toBe('/about') - expect(history.location.pathname).toBe('/my-app/about') - }) + expect(router.state.location.pathname).toBe('/') + expect(router.state.statusCode).toBe(200) + }, + ) - it('should handle basepath without trailing slash when navigating to root path', async () => { - const rootRoute = createRootRoute({ - component: () => , - }) + it.each([ + { description: 'with trailing slash', basepath: '/my-app/' }, + { description: 'without trailing slash', basepath: '/my-app' }, + ])( + 'should handle basepath $description when navigating to root path', + async ({ basepath }) => { + const rootRoute = createRootRoute({ + component: () => , + }) - const homeRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () => ( -
- - About - -
- ), - }) + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+ + About + +
+ ), + }) - const aboutRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/about', - component: () =>
About
, - }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () =>
About
, + }) - const routeTree = rootRoute.addChildren([homeRoute, aboutRoute]) + const routeTree = rootRoute.addChildren([homeRoute, aboutRoute]) - const history = createMemoryHistory({ initialEntries: ['/my-app'] }) + const history = createMemoryHistory({ initialEntries: ['/my-app/'] }) - const router = createRouter({ - routeTree, - history, - rewrite: rewriteBasepath({ basepath: '/my-app' }), // Without trailing slash - }) + const router = createRouter({ + routeTree, + history, + rewrite: rewriteBasepath({ basepath }), + }) - render() + render() - const aboutLink = await screen.findByTestId('about-link') - fireEvent.click(aboutLink) + const aboutLink = await screen.findByTestId('about-link') + fireEvent.click(aboutLink) - await waitFor(() => { - expect(screen.getByTestId('about')).toBeInTheDocument() - }) + await waitFor(() => { + expect(screen.getByTestId('about')).toBeInTheDocument() + }) - expect(router.state.location.pathname).toBe('/about') - expect(history.location.pathname).toBe('/my-app/about') - }) + expect(router.state.location.pathname).toBe('/about') + expect(history.location.pathname).toBe('/my-app/about') + }, + ) it('should handle empty basepath gracefully', async () => { const rootRoute = createRootRoute({ diff --git a/packages/router-core/src/rewrite.ts b/packages/router-core/src/rewrite.ts index 1e80cf96f28..7ffb49b8e15 100644 --- a/packages/router-core/src/rewrite.ts +++ b/packages/router-core/src/rewrite.ts @@ -25,18 +25,18 @@ export function rewriteBasepath(opts: { const trimmedBasepath = trimPath(opts.basepath) const normalizedBasepath = `/${trimmedBasepath}` const normalizedBasepathWithSlash = `${normalizedBasepath}/` + const checkBasepath = opts.caseSensitive + ? normalizedBasepath + : normalizedBasepath.toLowerCase() + const checkBasepathWithSlash = opts.caseSensitive + ? normalizedBasepathWithSlash + : normalizedBasepathWithSlash.toLowerCase() return { input: ({ url }) => { const pathname = opts.caseSensitive ? url.pathname : url.pathname.toLowerCase() - const checkBasepath = opts.caseSensitive - ? normalizedBasepath - : normalizedBasepath.toLowerCase() - const checkBasepathWithSlash = opts.caseSensitive - ? normalizedBasepathWithSlash - : normalizedBasepathWithSlash.toLowerCase() // Handle exact basepath match (e.g., /my-app -> /) if (pathname === checkBasepath) {