diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index c1d42e2184f..94374548202 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -126,24 +126,29 @@ export function useLinkProps< [router, _options], ) + // Use publicHref - it contains the correct href for display + // When a rewrite changes the origin, publicHref is the full URL + // Otherwise it's the origin-stripped path + // This avoids constructing URL objects in the hot path + const hrefOptionPublicHref = next.maskedLocation + ? next.maskedLocation.publicHref + : next.publicHref + const hrefOptionExternal = next.maskedLocation + ? next.maskedLocation.external + : next.external const hrefOption = React.useMemo(() => { - if (disabled) { - return undefined + if (disabled) return undefined + + // Full URL means rewrite changed the origin - treat as external-like + if (hrefOptionExternal) { + return { href: hrefOptionPublicHref, external: true } } - let href = next.maskedLocation - ? next.maskedLocation.url.href - : next.url.href - - let external = false - if (router.origin) { - if (href.startsWith(router.origin)) { - href = router.history.createHref(href.replace(router.origin, '')) || '/' - } else { - external = true - } + + return { + href: router.history.createHref(hrefOptionPublicHref) || '/', + external: false, } - return { href, external } - }, [disabled, next.maskedLocation, next.url, router.origin, router.history]) + }, [disabled, hrefOptionExternal, hrefOptionPublicHref, router.history]) const externalLink = React.useMemo(() => { if (hrefOption?.external) { diff --git a/packages/react-router/tests/redirect.test.tsx b/packages/react-router/tests/redirect.test.tsx index 70dd6918ce2..b8b42d5e0e3 100644 --- a/packages/react-router/tests/redirect.test.tsx +++ b/packages/react-router/tests/redirect.test.tsx @@ -357,6 +357,7 @@ describe('redirect', () => { expect(currentRedirect.headers.get('Location')).toEqual('/about') expect(currentRedirect.options).toEqual({ _fromLocation: { + external: false, hash: '', href: '/', publicHref: '/', @@ -368,7 +369,6 @@ describe('redirect', () => { __TSR_key: currentRedirect.options._fromLocation!.state.__TSR_key, key: currentRedirect.options._fromLocation!.state.key, }, - 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 8d747c8e65b..585c641297a 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.href.endsWith('/posts/tanner')).toBe(true) + expect(router.state.location.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,7 @@ describe('encoding: path params', () => { await act(() => router.load()) - expect(router.state.location.url.href.endsWith('/posts/%F0%9F%9A%80')).toBe( + expect(router.state.location.href.endsWith('/posts/%F0%9F%9A%80')).toBe( true, ) expect(router.state.location.href).toBe('/posts/%F0%9F%9A%80') @@ -383,9 +383,7 @@ describe('encoding: path params', () => { }), ) - expect(router.state.location.url.href.endsWith('/posts/100%2525')).toBe( - true, - ) + expect(router.state.location.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') }) @@ -410,9 +408,7 @@ describe('encoding: path params', () => { ) expect( - router.state.location.url.href.endsWith( - `/posts/${encodedValue}jane%25`, - ), + router.state.location.href.endsWith(`/posts/${encodedValue}jane%25`), ).toBe(true) expect(router.state.location.href).toBe(`/posts/${encodedValue}jane%25`) expect(router.state.location.pathname).toBe( @@ -447,7 +443,7 @@ describe('encoding: path params', () => { ) expect( - router.state.location.url.href.endsWith(`/posts/${character}jane%25`), + router.state.location.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`) @@ -461,7 +457,7 @@ describe('encoding: path params', () => { await act(() => router.load()) - expect(router.state.location.url.href.endsWith('/posts/%F0%9F%9A%80')).toBe( + expect(router.state.location.href.endsWith('/posts/%F0%9F%9A%80')).toBe( true, ) expect(router.state.location.href).toBe('/posts/%F0%9F%9A%80') @@ -480,7 +476,7 @@ describe('encoding: path params', () => { await act(() => router.load()) expect( - router.state.location.url.href.endsWith( + router.state.location.href.endsWith( '/posts/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', ), ).toBe(true) @@ -627,7 +623,7 @@ describe('encoding/decoding: wildcard routes/params', () => { await router.load() - expect(router.state.location.url.href.endsWith('/tanner')).toBe(true) + expect(router.state.location.href.endsWith('/tanner')).toBe(true) expect(router.state.location.href).toBe('/tanner') expect(router.state.location.pathname).toBe('/tanner') }) @@ -639,7 +635,7 @@ describe('encoding/decoding: wildcard routes/params', () => { await router.load() - expect(router.state.location.url.href.endsWith('/%F0%9F%9A%80')).toBe(true) + expect(router.state.location.href.endsWith('/%F0%9F%9A%80')).toBe(true) expect(router.state.location.href).toBe('/%F0%9F%9A%80') expect(router.state.location.pathname).toBe('/🚀') }) @@ -657,7 +653,7 @@ describe('encoding/decoding: wildcard routes/params', () => { await router.load() expect( - router.state.location.url.href.endsWith(`/100${encodedValue}100`), + router.state.location.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`) @@ -672,7 +668,7 @@ describe('encoding/decoding: wildcard routes/params', () => { await router.load() - expect(router.state.location.url.href.endsWith('/%F0%9F%9A%80')).toBe(true) + expect(router.state.location.href.endsWith('/%F0%9F%9A%80')).toBe(true) expect(router.state.location.href).toBe('/%F0%9F%9A%80') expect(router.state.location.pathname).toBe('/🚀') }) @@ -689,7 +685,7 @@ describe('encoding/decoding: wildcard routes/params', () => { await router.load() expect( - router.state.location.url.href.endsWith( + router.state.location.href.endsWith( '/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', ), ).toBe(true) @@ -711,7 +707,7 @@ describe('encoding/decoding: wildcard routes/params', () => { await router.load() expect( - router.state.location.url.href.endsWith( + router.state.location.href.endsWith( '/framework/react/guide/file-based-routing%20tanstack', ), ).toBe(true) @@ -863,7 +859,6 @@ describe('encoding/decoding: URL path segment', () => { expect(router.state.location.pathname).toBe(path) expect(router.state.location.href).toBe(url) - expect(new URL(router.state.location.url).pathname).toBe(url) }) }) diff --git a/packages/router-core/src/location.ts b/packages/router-core/src/location.ts index 2e2c8042cfd..8e0d4dd2b70 100644 --- a/packages/router-core/src/location.ts +++ b/packages/router-core/src/location.ts @@ -44,8 +44,7 @@ export interface ParsedLocation { publicHref: string /** * @private - * @description The full URL of the location. - * @private + * @description Whether the publicHref is external (different origin from rewrite). */ - url: URL + external: boolean } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index b7a8defc186..77e010fcb88 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -4,6 +4,7 @@ import { createControlledPromise, decodePath, deepEqual, + encodeNonAscii, findLast, functionalUpdate, isDangerousProtocol, @@ -1091,7 +1092,8 @@ export class RouterCore< this.basepath = nextBasepath const rewrites: Array = [] - if (trimPath(nextBasepath) !== '') { + const trimmed = trimPath(nextBasepath) + if (trimmed && trimmed !== '/') { rewrites.push( rewriteBasepath({ basepath: nextBasepath, @@ -1237,8 +1239,8 @@ export class RouterCore< return { href: fullPath, publicHref: href, - url: url, pathname: decodePath(url.pathname), + external: !!this.rewrite && url.origin !== this.origin, searchStr, search: replaceEqualDeep(previousLocation?.search, parsedSearch) as any, hash: decodePath(url.hash.split('#').reverse()[0] ?? ''), @@ -1878,22 +1880,43 @@ export class RouterCore< // Create the full path of the location const fullPath = `${nextPathname}${searchStr}${hashStr}` - // Create the new href with full origin - const url = new URL(fullPath, this.origin) - - // If a rewrite function is provided, use it to rewrite the URL - const rewrittenUrl = executeRewriteOutput(this.rewrite, url) + // Compute href and publicHref without URL construction when no rewrite + let href: string + let publicHref: string + let external = false + + if (this.rewrite) { + // With rewrite, we need to construct URL to apply the rewrite + const url = new URL(fullPath, this.origin) + const rewrittenUrl = executeRewriteOutput(this.rewrite, url) + href = url.href.replace(url.origin, '') + // If rewrite changed the origin, publicHref needs full URL + // Otherwise just use the path components + if (rewrittenUrl.origin !== this.origin) { + publicHref = rewrittenUrl.href + external = true + } else { + publicHref = + rewrittenUrl.pathname + rewrittenUrl.search + rewrittenUrl.hash + } + } else { + // Fast path: no rewrite, skip URL construction entirely + // fullPath is already the correct href (origin-stripped) + // We need to encode non-ASCII (unicode) characters for the href + // since decodePath decoded them from the interpolated path + href = encodeNonAscii(fullPath) + publicHref = href + } return { - publicHref: - rewrittenUrl.pathname + rewrittenUrl.search + rewrittenUrl.hash, - href: fullPath, - url: rewrittenUrl, + publicHref, + href, pathname: nextPathname, search: nextSearch, searchStr, state: nextState as any, hash: hash ?? '', + external, unmaskOnReload: dest.unmaskOnReload, } } @@ -2006,9 +2029,6 @@ export class RouterCore< 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 @@ -2154,8 +2174,9 @@ export class RouterCore< // be a complete path (possibly with basepath) if (to !== undefined || !href) { const location = this.buildLocation({ to, ...rest } as any) - href = href ?? location.url.href - publicHref = publicHref ?? location.url.href + // Use publicHref which contains the path (origin-stripped is fine for reload) + href = href ?? location.publicHref + publicHref = publicHref ?? location.publicHref } // Use publicHref when available and href is not a full URL, @@ -2226,10 +2247,9 @@ export class RouterCore< _includeValidateSearch: true, }) - if ( - this.latestLocation.publicHref !== nextLocation.publicHref || - nextLocation.url.origin !== this.origin - ) { + // Check if location changed - origin check is unnecessary since buildLocation + // always uses this.origin when constructing URLs + if (this.latestLocation.publicHref !== nextLocation.publicHref) { const href = this.getParsedLocationHref(nextLocation) throw redirect({ href }) @@ -2553,11 +2573,9 @@ export class RouterCore< } getParsedLocationHref = (location: ParsedLocation) => { - let href = location.url.href - if (this.origin && location.url.origin === this.origin) { - href = href.replace(this.origin, '') || '/' - } - return href + // For redirects and external use, we need publicHref (with rewrite output applied) + // href is the internal path after rewrite input, publicHref is user-facing + return location.publicHref || '/' } resolveRedirect = (redirect: AnyRedirect): AnyRedirect => { diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index e932eff25fa..cafe47047aa 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -613,6 +613,21 @@ export function decodePath(path: string, decodeIgnore?: Array): string { return result } +/** + * Encodes non-ASCII (unicode) characters in a path while preserving + * already percent-encoded sequences. This is used to generate proper + * href values without constructing URL objects. + * + * Unlike encodeURI, this won't double-encode percent-encoded sequences + * like %2F or %25 because it only targets non-ASCII characters. + */ +export function encodeNonAscii(path: string): string { + // eslint-disable-next-line no-control-regex + if (!/[^\u0000-\u007F]/.test(path)) return path + // eslint-disable-next-line no-control-regex + return path.replace(/[^\u0000-\u007F]/gu, encodeURIComponent) +} + /** * Builds the dev-mode CSS styles URL for route-scoped CSS collection. * Used by HeadContent components in all framework implementations to construct diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 5c266063a45..4c70bc15e76 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -134,25 +134,23 @@ export function useLinkProps< }) const hrefOption = Solid.createMemo(() => { - if (_options().disabled) { - return undefined + if (_options().disabled) return undefined + // Use publicHref - it contains the correct href for display + // When a rewrite changes the origin, publicHref is the full URL + // Otherwise it's the origin-stripped path + // This avoids constructing URL objects in the hot path + const location = next().maskedLocation ?? next() + const publicHref = location.publicHref + const external = location.external + + if (external) { + return { href: publicHref, external: true } } - let href - const maskedLocation = next().maskedLocation - if (maskedLocation) { - href = maskedLocation.url.href - } else { - href = next().url.href - } - let external = false - if (router.origin) { - if (href.startsWith(router.origin)) { - href = router.history.createHref(href.replace(router.origin, '')) - } else { - external = true - } + + return { + href: router.history.createHref(publicHref) || '/', + external: false, } - return { href, external } }) const externalLink = Solid.createMemo(() => { diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx index f9f7c28085a..df3326ada03 100644 --- a/packages/solid-router/tests/redirect.test.tsx +++ b/packages/solid-router/tests/redirect.test.tsx @@ -351,8 +351,8 @@ describe('redirect', () => { expect(currentRedirect.headers.get('Location')).toEqual('/about') expect(currentRedirect.options).toEqual({ _fromLocation: { + external: false, publicHref: '/', - url: new URL('http://localhost/'), hash: '', href: '/', pathname: '/', diff --git a/packages/solid-router/tests/router.test.tsx b/packages/solid-router/tests/router.test.tsx index 524ee1910bb..789bc48cca5 100644 --- a/packages/solid-router/tests/router.test.tsx +++ b/packages/solid-router/tests/router.test.tsx @@ -804,7 +804,7 @@ describe('encoding: URL path segment', () => { await router.load() expect(router.state.location.pathname).toBe(path) - expect(new URL(router.state.location.url).pathname).toBe(url) + expect(router.state.location.href).toBe(url) }) }) diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index db53f65f752..43ed59bc332 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -471,23 +471,19 @@ export function useLinkProps< return undefined } const nextLocation = next.value - const maskedLocation = nextLocation?.maskedLocation + const location = nextLocation?.maskedLocation ?? nextLocation - let hrefValue: string - if (maskedLocation) { - hrefValue = maskedLocation.url.href - } else { - hrefValue = nextLocation?.url.href - } + // Use publicHref - it contains the correct href for display + // When a rewrite changes the origin, publicHref is the full URL + // Otherwise it's the origin-stripped path + // This avoids constructing URL objects in the hot path + const publicHref = location?.publicHref + if (!publicHref) return undefined - // Handle origin stripping like Solid does - if (router.origin && hrefValue?.startsWith(router.origin)) { - hrefValue = router.history.createHref( - hrefValue.replace(router.origin, ''), - ) - } + const external = location?.external + if (external) return publicHref - return hrefValue + return router.history.createHref(publicHref) || '/' }) // Create static event handlers that don't change between renders diff --git a/packages/vue-router/tests/router.test.tsx b/packages/vue-router/tests/router.test.tsx index 7b58b4fa58c..28dc860e83e 100644 --- a/packages/vue-router/tests/router.test.tsx +++ b/packages/vue-router/tests/router.test.tsx @@ -806,7 +806,7 @@ describe('encoding: URL path segment', () => { await router.load() expect(router.state.location.pathname).toBe(path) - expect(new URL(router.state.location.url).pathname).toBe(url) + expect(router.state.location.href).toBe(url) }) })