diff --git a/e2e/react-start/custom-basepath/tests/navigation.spec.ts b/e2e/react-start/custom-basepath/tests/navigation.spec.ts index c96cc384c88..e66f14be9e2 100644 --- a/e2e/react-start/custom-basepath/tests/navigation.spec.ts +++ b/e2e/react-start/custom-basepath/tests/navigation.spec.ts @@ -61,8 +61,16 @@ test('server-side redirect', async ({ page, baseURL }) => { expect(page.url()).toBe(`${baseURL}/posts/1`) // do not follow redirects since we want to test the Location header + // first go to the route WITHOUT the base path, this will just add the base path await page.request .get('/redirect/throw-it', { maxRedirects: 0 }) + .then((res) => { + const headers = new Headers(res.headers()) + expect(headers.get('location')).toBe('/custom/basepath/redirect/throw-it') + }) + // now go to the route WITH the base path, this will redirect to the final destination + await page.request + .get('/custom/basepath/redirect/throw-it', { maxRedirects: 0 }) .then((res) => { const headers = new Headers(res.headers()) expect(headers.get('location')).toBe('/custom/basepath/posts/1') diff --git a/e2e/solid-start/custom-basepath/tests/navigation.spec.ts b/e2e/solid-start/custom-basepath/tests/navigation.spec.ts index c96cc384c88..e66f14be9e2 100644 --- a/e2e/solid-start/custom-basepath/tests/navigation.spec.ts +++ b/e2e/solid-start/custom-basepath/tests/navigation.spec.ts @@ -61,8 +61,16 @@ test('server-side redirect', async ({ page, baseURL }) => { expect(page.url()).toBe(`${baseURL}/posts/1`) // do not follow redirects since we want to test the Location header + // first go to the route WITHOUT the base path, this will just add the base path await page.request .get('/redirect/throw-it', { maxRedirects: 0 }) + .then((res) => { + const headers = new Headers(res.headers()) + expect(headers.get('location')).toBe('/custom/basepath/redirect/throw-it') + }) + // now go to the route WITH the base path, this will redirect to the final destination + await page.request + .get('/custom/basepath/redirect/throw-it', { maxRedirects: 0 }) .then((res) => { const headers = new Headers(res.headers()) expect(headers.get('location')).toBe('/custom/basepath/posts/1') diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index fb63cd36a68..365464bf013 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -127,7 +127,9 @@ export function useLinkProps< if (disabled) { return undefined } - let href = next.maskedLocation ? next.maskedLocation.url : next.url + let href = next.maskedLocation + ? next.maskedLocation.url.href + : next.url.href let external = false if (router.origin) { diff --git a/packages/react-router/tests/redirect.test.tsx b/packages/react-router/tests/redirect.test.tsx index 3308c76a364..70dd6918ce2 100644 --- a/packages/react-router/tests/redirect.test.tsx +++ b/packages/react-router/tests/redirect.test.tsx @@ -368,7 +368,7 @@ describe('redirect', () => { __TSR_key: currentRedirect.options._fromLocation!.state.__TSR_key, key: currentRedirect.options._fromLocation!.state.key, }, - url: 'http://localhost/', + url: new URL('http://localhost/'), }, href: '/about', to: '/about', diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 32fc3efa071..1c80d79b895 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -350,7 +350,7 @@ describe('encoding: path params', () => { await act(() => router.load()) - expect(router.state.location.url.endsWith('/posts/tanner')).toBe(true) + expect(router.state.location.url.href.endsWith('/posts/tanner')).toBe(true) expect(router.state.location.href).toBe('/posts/tanner') expect(router.state.location.pathname).toBe('/posts/tanner') }) @@ -362,7 +362,9 @@ describe('encoding: path params', () => { await act(() => router.load()) - expect(router.state.location.url.endsWith('/posts/%F0%9F%9A%80')).toBe(true) + expect(router.state.location.url.href.endsWith('/posts/%F0%9F%9A%80')).toBe( + true, + ) expect(router.state.location.href).toBe('/posts/%F0%9F%9A%80') expect(router.state.location.pathname).toBe('/posts/🚀') }) @@ -381,7 +383,9 @@ describe('encoding: path params', () => { }), ) - expect(router.state.location.url.endsWith('/posts/100%2525')).toBe(true) + expect(router.state.location.url.href.endsWith('/posts/100%2525')).toBe( + true, + ) expect(router.state.location.href).toBe('/posts/100%2525') expect(router.state.location.pathname).toBe('/posts/100%2525') }) @@ -406,7 +410,9 @@ describe('encoding: path params', () => { ) expect( - router.state.location.url.endsWith(`/posts/${encodedValue}jane%25`), + router.state.location.url.href.endsWith( + `/posts/${encodedValue}jane%25`, + ), ).toBe(true) expect(router.state.location.href).toBe(`/posts/${encodedValue}jane%25`) expect(router.state.location.pathname).toBe( @@ -441,7 +447,7 @@ describe('encoding: path params', () => { ) expect( - router.state.location.url.endsWith(`/posts/${character}jane%25`), + router.state.location.url.href.endsWith(`/posts/${character}jane%25`), ).toBe(true) expect(router.state.location.href).toBe(`/posts/${character}jane%25`) expect(router.state.location.pathname).toBe(`/posts/${character}jane%25`) @@ -455,7 +461,9 @@ describe('encoding: path params', () => { await act(() => router.load()) - expect(router.state.location.url.endsWith('/posts/%F0%9F%9A%80')).toBe(true) + expect(router.state.location.url.href.endsWith('/posts/%F0%9F%9A%80')).toBe( + true, + ) expect(router.state.location.href).toBe('/posts/%F0%9F%9A%80') expect(router.state.location.pathname).toBe('/posts/🚀') }) @@ -472,7 +480,7 @@ describe('encoding: path params', () => { await act(() => router.load()) expect( - router.state.location.url.endsWith( + router.state.location.url.href.endsWith( '/posts/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', ), ).toBe(true) @@ -619,7 +627,7 @@ describe('encoding/decoding: wildcard routes/params', () => { await router.load() - expect(router.state.location.url.endsWith('/tanner')).toBe(true) + expect(router.state.location.url.href.endsWith('/tanner')).toBe(true) expect(router.state.location.href).toBe('/tanner') expect(router.state.location.pathname).toBe('/tanner') }) @@ -631,7 +639,7 @@ describe('encoding/decoding: wildcard routes/params', () => { await router.load() - expect(router.state.location.url.endsWith('/%F0%9F%9A%80')).toBe(true) + expect(router.state.location.url.href.endsWith('/%F0%9F%9A%80')).toBe(true) expect(router.state.location.href).toBe('/%F0%9F%9A%80') expect(router.state.location.pathname).toBe('/🚀') }) @@ -649,7 +657,7 @@ describe('encoding/decoding: wildcard routes/params', () => { await router.load() expect( - router.state.location.url.endsWith(`/100${encodedValue}100`), + router.state.location.url.href.endsWith(`/100${encodedValue}100`), ).toBe(true) expect(router.state.location.href).toBe(`/100${encodedValue}100`) expect(router.state.location.pathname).toBe(`/100${encodedValue}100`) @@ -664,7 +672,7 @@ describe('encoding/decoding: wildcard routes/params', () => { await router.load() - expect(router.state.location.url.endsWith('/%F0%9F%9A%80')).toBe(true) + expect(router.state.location.url.href.endsWith('/%F0%9F%9A%80')).toBe(true) expect(router.state.location.href).toBe('/%F0%9F%9A%80') expect(router.state.location.pathname).toBe('/🚀') }) @@ -681,7 +689,7 @@ describe('encoding/decoding: wildcard routes/params', () => { await router.load() expect( - router.state.location.url.endsWith( + router.state.location.url.href.endsWith( '/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', ), ).toBe(true) @@ -703,7 +711,7 @@ describe('encoding/decoding: wildcard routes/params', () => { await router.load() expect( - router.state.location.url.endsWith( + router.state.location.url.href.endsWith( '/framework/react/guide/file-based-routing%20tanstack', ), ).toBe(true) diff --git a/packages/router-core/src/location.ts b/packages/router-core/src/location.ts index 43b11473f99..2e2c8042cfd 100644 --- a/packages/router-core/src/location.ts +++ b/packages/router-core/src/location.ts @@ -38,14 +38,14 @@ export interface ParsedLocation { unmaskOnReload?: boolean /** * @private - * @description The public href of the location, including the origin before any rewrites. + * @description The public href of the location. * If a rewrite is applied, the `href` property will be the rewritten URL. */ publicHref: string /** * @private - * @description The full URL of the location, including the origin. + * @description The full URL of the location. * @private */ - url: string + url: URL } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 19b77531297..54da83a33ec 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1176,16 +1176,14 @@ export class RouterCore< const fullPath = url.href.replace(url.origin, '') - const { pathname, hash } = url - return { href: fullPath, publicHref: href, - url: url.href, - pathname: decodePath(pathname), + url: url, + pathname: decodePath(url.pathname), searchStr, search: replaceEqualDeep(previousLocation?.search, parsedSearch) as any, - hash: hash.split('#').reverse()[0] ?? '', + hash: url.hash.split('#').reverse()[0] ?? '', state: replaceEqualDeep(previousLocation?.state, state), } } @@ -1765,7 +1763,7 @@ export class RouterCore< publicHref: rewrittenUrl.pathname + rewrittenUrl.search + rewrittenUrl.hash, href: fullPath, - url: rewrittenUrl.href, + url: rewrittenUrl, pathname: nextPathname, search: nextSearch, searchStr, @@ -1878,8 +1876,16 @@ export class RouterCore< if (isSameUrl && isSameState()) { this.load() } else { - // eslint-disable-next-line prefer-const - let { maskedLocation, hashScrollIntoView, ...nextHistory } = next + let { + // eslint-disable-next-line prefer-const + maskedLocation, + // eslint-disable-next-line prefer-const + hashScrollIntoView, + // don't pass url into history since it is a URL instance that cannot be serialized + // eslint-disable-next-line prefer-const + url: _url, + ...nextHistory + } = next if (maskedLocation) { nextHistory = { @@ -2000,7 +2006,7 @@ export class RouterCore< if (reloadDocument) { if (!href) { const location = this.buildLocation({ to, ...rest } as any) - href = location.url + href = location.url.href } // Check blockers for external URLs unless ignoreBlocker is true @@ -2056,24 +2062,11 @@ export class RouterCore< _includeValidateSearch: true, }) - // Normalize URLs for comparison to handle encoding differences - // Browser history always stores encoded URLs while buildLocation may produce decoded URLs - const normalizeUrl = (url: string) => { - try { - return encodeURI(decodeURI(url)) - } catch { - return url - } - } - if ( - trimPath(normalizeUrl(this.latestLocation.href)) !== - trimPath(normalizeUrl(nextLocation.href)) + this.latestLocation.publicHref !== nextLocation.publicHref || + nextLocation.url.origin !== this.origin ) { - let href = nextLocation.url - if (this.origin && href.startsWith(this.origin)) { - href = href.replace(this.origin, '') || '/' - } + const href = this.getParsedLocationHref(nextLocation) throw redirect({ href }) } @@ -2395,13 +2388,18 @@ export class RouterCore< return this.load({ sync: opts?.sync }) } + getParsedLocationHref = (location: ParsedLocation) => { + let href = location.url.href + if (this.origin && location.url.origin === this.origin) { + href = href.replace(this.origin, '') || '/' + } + return href + } + resolveRedirect = (redirect: AnyRedirect): AnyRedirect => { if (!redirect.options.href) { const location = this.buildLocation(redirect.options) - let href = location.url - if (this.origin && href.startsWith(this.origin)) { - href = href.replace(this.origin, '') || '/' - } + const href = this.getParsedLocationHref(location) redirect.options.href = location.href redirect.headers.set('Location', href) } diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 348cd87fa78..dc9a6c478b9 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -139,9 +139,9 @@ export function useLinkProps< let href const maskedLocation = next().maskedLocation if (maskedLocation) { - href = maskedLocation.url + href = maskedLocation.url.href } else { - href = next().url + href = next().url.href } let external = false if (router.origin) { diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx index 88882c57a04..f9f7c28085a 100644 --- a/packages/solid-router/tests/redirect.test.tsx +++ b/packages/solid-router/tests/redirect.test.tsx @@ -352,7 +352,7 @@ describe('redirect', () => { expect(currentRedirect.options).toEqual({ _fromLocation: { publicHref: '/', - url: 'http://localhost/', + url: new URL('http://localhost/'), hash: '', href: '/', pathname: '/', diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 2565c3bdeff..45ec178cbda 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -431,9 +431,9 @@ export function useLinkProps< let hrefValue: string if (maskedLocation) { - hrefValue = maskedLocation.url + hrefValue = maskedLocation.url.href } else { - hrefValue = nextLocation?.url + hrefValue = nextLocation?.url.href } // Handle origin stripping like Solid does