From 26f665575096a826b271b15429cbbf416a9725d7 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 16 Mar 2026 20:38:58 +0100 Subject: [PATCH 1/4] refactor(router-core): restore 'url' field on ParsedLocation --- docs/router/api/router/ParsedLocationType.md | 5 + packages/router-core/src/location.ts | 5 + packages/router-core/src/router.ts | 103 ++++++++++++------- 3 files changed, 76 insertions(+), 37 deletions(-) diff --git a/docs/router/api/router/ParsedLocationType.md b/docs/router/api/router/ParsedLocationType.md index 321aa97a7f2..036b933c834 100644 --- a/docs/router/api/router/ParsedLocationType.md +++ b/docs/router/api/router/ParsedLocationType.md @@ -15,5 +15,10 @@ interface ParsedLocation { hash: string maskedLocation?: ParsedLocation unmaskOnReload?: boolean + url: URL } ``` + +> [!NOTE] +> The `url` property of a `ParsedLocation` is a getter, and the `URL` may be computed +> on demand. In hot loops, relying on this property may have a negative performance impact. diff --git a/packages/router-core/src/location.ts b/packages/router-core/src/location.ts index 956b88a80a6..5d02f84e4f1 100644 --- a/packages/router-core/src/location.ts +++ b/packages/router-core/src/location.ts @@ -48,4 +48,9 @@ export interface ParsedLocation { * @description Whether the publicHref is external (different origin from rewrite). */ external: boolean + /** + * A `URL` object representation of the location. This object may be created dynamically, so + * reading this property if not "free". + */ + url: URL } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 117a591ca7e..fc83d1d2135 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1302,19 +1302,22 @@ export class RouterCore< const parsedSearch = this.options.parseSearch(search) const searchStr = this.options.stringifySearch(parsedSearch) - return { - href: pathname + searchStr + hash, - publicHref: href, - pathname: decodePath(pathname).path, - external: false, - searchStr, - search: nullReplaceEqualDeep( - previousLocation?.search, - parsedSearch, - ) as any, - hash: decodePath(hash.slice(1)).path, - state: replaceEqualDeep(previousLocation?.state, state), - } + return augmentLocationWithUrl( + { + href: pathname + searchStr + hash, + publicHref: href, + pathname: decodePath(pathname).path, + external: false, + searchStr, + search: nullReplaceEqualDeep( + previousLocation?.search, + parsedSearch, + ) as any, + hash: decodePath(hash.slice(1)).path, + state: replaceEqualDeep(previousLocation?.state, state), + }, + this.origin!, + ) } // Before we do any processing, we need to allow rewrites to modify the URL @@ -1331,19 +1334,23 @@ export class RouterCore< const fullPath = url.href.replace(url.origin, '') - return { - href: fullPath, - publicHref: href, - pathname: decodePath(url.pathname).path, - external: !!this.rewrite && url.origin !== this.origin, - searchStr, - search: nullReplaceEqualDeep( - previousLocation?.search, - parsedSearch, - ) as any, - hash: decodePath(url.hash.slice(1)).path, - state: replaceEqualDeep(previousLocation?.state, state), - } + return augmentLocationWithUrl( + { + href: fullPath, + publicHref: href, + pathname: decodePath(url.pathname).path, + external: !!this.rewrite && url.origin !== this.origin, + searchStr, + search: nullReplaceEqualDeep( + previousLocation?.search, + parsedSearch, + ) as any, + hash: decodePath(url.hash.slice(1)).path, + state: replaceEqualDeep(previousLocation?.state, state), + }, + url.origin, + url, + ) } const location = parse(locationToParse) @@ -1989,12 +1996,16 @@ export class RouterCore< let href: string let publicHref: string let external = false + let memoUrl: URL | null = null + let origin: string 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) + memoUrl = rewrittenUrl href = url.href.replace(url.origin, '') + origin = rewrittenUrl.origin // If rewrite changed the origin, publicHref needs full URL // Otherwise just use the path components if (rewrittenUrl.origin !== this.origin) { @@ -2011,19 +2022,24 @@ export class RouterCore< // since decodePath decoded them from the interpolated path href = encodePathLikeUrl(fullPath) publicHref = href + origin = this.origin! } - return { - publicHref, - href, - pathname: nextPathname, - search: nextSearch, - searchStr, - state: nextState as any, - hash: hash ?? '', - external, - unmaskOnReload: dest.unmaskOnReload, - } + return augmentLocationWithUrl( + { + publicHref, + href, + pathname: nextPathname, + search: nextSearch, + searchStr, + state: nextState as any, + hash: hash ?? '', + external, + unmaskOnReload: dest.unmaskOnReload, + }, + origin, + memoUrl, + ) } const buildWithMatches = ( @@ -2949,6 +2965,19 @@ export class RouterCore< } } +function augmentLocationWithUrl( + location: + | ParsedLocation + | Omit, 'url'>, + origin: string, + url?: URL | null, +): ParsedLocation { + return Object.defineProperty(location, 'url', { + enumerable: false, + get: () => (url ??= new URL(location.href, origin)), + }) as ParsedLocation +} + /** Error thrown when search parameter validation fails. */ export class SearchParamError extends Error {} From 6dd532f3d73394137137c3aa8e05bc8b3f29316d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 16 Mar 2026 20:40:33 +0100 Subject: [PATCH 2/4] changeset --- .changeset/five-otters-feel.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/five-otters-feel.md diff --git a/.changeset/five-otters-feel.md b/.changeset/five-otters-feel.md new file mode 100644 index 00000000000..3f66ba6e46b --- /dev/null +++ b/.changeset/five-otters-feel.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': minor +--- + +restore url property on ParsedLocation objects From 447c9f8eca396f908cbaa4e3e5c66fc7c66381cf Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 16 Mar 2026 21:08:32 +0100 Subject: [PATCH 3/4] remove unnecessary spread, non-enumerable url prop now forwarded properly --- packages/router-core/src/router.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index fc83d1d2135..60476cc7328 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1362,13 +1362,10 @@ export class RouterCore< const parsedTempLocation = parse(__tempLocation) as any parsedTempLocation.state.key = location.state.key // TODO: Remove in v2 - use __TSR_key instead parsedTempLocation.state.__TSR_key = location.state.__TSR_key - + parsedTempLocation.maskedLocation = location delete parsedTempLocation.state.__tempLocation - return { - ...parsedTempLocation, - maskedLocation: location, - } + return parsedTempLocation } return location } From 7b3861b08257a7a177ecdc041eca2c1bfcf9c0d5 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 17 Mar 2026 09:03:12 +0100 Subject: [PATCH 4/4] tests and fix --- packages/router-core/src/router.ts | 5 +- packages/router-core/tests/rewrite.test.ts | 132 +++++++++++++++++++++ 2 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 packages/router-core/tests/rewrite.test.ts diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 60476cc7328..2b69dca48e9 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1348,8 +1348,7 @@ export class RouterCore< hash: decodePath(url.hash.slice(1)).path, state: replaceEqualDeep(previousLocation?.state, state), }, - url.origin, - url, + this.origin!, ) } @@ -2971,7 +2970,7 @@ function augmentLocationWithUrl( ): ParsedLocation { return Object.defineProperty(location, 'url', { enumerable: false, - get: () => (url ??= new URL(location.href, origin)), + get: () => (url ??= new URL(location.publicHref, origin)), }) as ParsedLocation } diff --git a/packages/router-core/tests/rewrite.test.ts b/packages/router-core/tests/rewrite.test.ts new file mode 100644 index 00000000000..2cee2f7b3ed --- /dev/null +++ b/packages/router-core/tests/rewrite.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, RouterCore } from '../src' + +const createAboutRouter = (opts: { + initialEntries: Array + origin: string + rewrite: NonNullable[0]['rewrite']> +}) => { + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + + return new RouterCore({ + routeTree, + history: createMemoryHistory({ initialEntries: opts.initialEntries }), + origin: opts.origin, + rewrite: opts.rewrite, + }) +} + +describe('rewrite origin behavior', () => { + test('parseLocation keeps a public url when input rewrite changes origin', async () => { + const router = createAboutRouter({ + initialEntries: ['/docs/about?lang=en#team'], + origin: 'https://public.example.com', + rewrite: { + input: ({ url }) => { + if (url.origin === 'https://public.example.com') { + url.pathname = url.pathname.replace(/^\/docs/, '') + return new URL( + `${url.pathname}${url.search}${url.hash}`, + 'https://internal.example.com', + ) + } + + return url + }, + }, + }) + + await router.load() + + expect(router.state.location.pathname).toBe('/about') + expect(router.state.location.href).toBe('/about?lang=en#team') + expect(router.state.location.publicHref).toBe('/docs/about?lang=en#team') + expect(router.state.location.url.href).toBe( + 'https://public.example.com/docs/about?lang=en#team', + ) + expect(router.state.location.url.origin).toBe('https://public.example.com') + }) + + test('buildLocation exposes the current origin to output rewrites', async () => { + const router = createAboutRouter({ + initialEntries: ['/'], + origin: 'https://internal.example.com', + rewrite: { + output: ({ url }) => { + if (url.origin === 'https://internal.example.com') { + url.pathname = `/docs${url.pathname}` + return new URL( + `${url.pathname}${url.search}${url.hash}`, + 'https://public.example.com', + ) + } + + return url + }, + }, + }) + + await router.load() + + const location = router.buildLocation({ + to: '/about', + search: { lang: 'en' }, + hash: 'team', + }) + + expect(location.href).toBe('/docs/about?lang=en#team') + expect(location.publicHref).toBe( + 'https://public.example.com/docs/about?lang=en#team', + ) + expect(location.external).toBe(true) + expect(location.url.href).toBe( + 'https://public.example.com/docs/about?lang=en#team', + ) + expect(location.url.origin).toBe('https://public.example.com') + }) + + test('buildAndCommitLocation uses origin-aware rewrites when href is provided', async () => { + const router = createAboutRouter({ + initialEntries: ['/docs'], + origin: 'https://public.example.com', + rewrite: { + input: ({ url }) => { + if (url.origin === 'https://public.example.com') { + url.pathname = url.pathname.replace(/^\/docs/, '') || '/' + } + + return url + }, + output: ({ url }) => { + if (url.origin === 'https://public.example.com') { + url.pathname = `/docs${url.pathname === '/' ? '' : url.pathname}` + } + + return url + }, + }, + }) + + await router.load() + await router.buildAndCommitLocation({ href: '/docs/about?lang=en#team' }) + + expect(router.history.location.href).toBe('/docs/about?lang=en#team') + expect(router.state.location.pathname).toBe('/about') + expect(router.state.location.href).toBe('/about?lang=en#team') + expect(router.state.location.publicHref).toBe('/docs/about?lang=en#team') + expect(router.state.location.url.href).toBe( + 'https://public.example.com/docs/about?lang=en#team', + ) + }) +})