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 diff --git a/docs/router/api/router/ParsedLocationType.md b/docs/router/api/router/ParsedLocationType.md index 321aa97a7f2..b7edb646a6d 100644 --- a/docs/router/api/router/ParsedLocationType.md +++ b/docs/router/api/router/ParsedLocationType.md @@ -15,5 +15,11 @@ interface ParsedLocation { hash: string maskedLocation?: ParsedLocation unmaskOnReload?: boolean + getUrl: () => URL + origin: string } ``` + +> [!NOTE] +> `getUrl()` returns a memoized `URL` that is created on demand. In hot loops, +> repeatedly calling this method may have a negative performance impact. diff --git a/packages/react-router/tests/redirect.test.tsx b/packages/react-router/tests/redirect.test.tsx index a391d02898f..8c861ddbc34 100644 --- a/packages/react-router/tests/redirect.test.tsx +++ b/packages/react-router/tests/redirect.test.tsx @@ -357,8 +357,10 @@ describe('redirect', () => { expect(redirectResponse.options).toEqual({ _fromLocation: { external: false, + getUrl: expect.any(Function), hash: '', href: '/', + origin: 'http://localhost', publicHref: '/', pathname: '/', search: {}, diff --git a/packages/router-core/src/location.ts b/packages/router-core/src/location.ts index 956b88a80a6..bedf98e627b 100644 --- a/packages/router-core/src/location.ts +++ b/packages/router-core/src/location.ts @@ -48,4 +48,13 @@ export interface ParsedLocation { * @description Whether the publicHref is external (different origin from rewrite). */ external: boolean + /** + * The origin used to resolve `publicHref` into `url`. + */ + origin: string + /** + * Returns a memoized `URL` object representation of the location. + * The `URL` is created lazily, so calling this method is not free. + */ + getUrl: () => URL } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 965fefda339..ab9f422dfac 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1277,11 +1277,12 @@ export class RouterCore< const parsedSearch = this.options.parseSearch(search) const searchStr = this.options.stringifySearch(parsedSearch) - return { + return initializeParsedLocation({ href: pathname + searchStr + hash, publicHref: href, pathname: decodePath(pathname).path, external: false, + origin: this.origin!, searchStr, search: nullReplaceEqualDeep( previousLocation?.search, @@ -1289,7 +1290,7 @@ export class RouterCore< ) as any, hash: decodePath(hash.slice(1)).path, state: replaceEqualDeep(previousLocation?.state, state), - } + }) } // Before we do any processing, we need to allow rewrites to modify the URL @@ -1306,11 +1307,12 @@ export class RouterCore< const fullPath = url.href.replace(url.origin, '') - return { + return initializeParsedLocation({ href: fullPath, publicHref: href, pathname: decodePath(url.pathname).path, external: !!this.rewrite && url.origin !== this.origin, + origin: this.origin!, searchStr, search: nullReplaceEqualDeep( previousLocation?.search, @@ -1318,7 +1320,7 @@ export class RouterCore< ) as any, hash: decodePath(url.hash.slice(1)).path, state: replaceEqualDeep(previousLocation?.state, state), - } + }) } const location = parse(locationToParse) @@ -1330,13 +1332,11 @@ 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 } @@ -1975,11 +1975,13 @@ export class RouterCore< let href: string let publicHref: string let external = false + let memoUrl: URL | null = null 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, '') // If rewrite changed the origin, publicHref needs full URL // Otherwise just use the path components @@ -1999,17 +2001,21 @@ export class RouterCore< publicHref = href } - return { - publicHref, - href, - pathname: nextPathname, - search: nextSearch, - searchStr, - state: nextState as any, - hash: hash ?? '', - external, - unmaskOnReload: dest.unmaskOnReload, - } + return initializeParsedLocation( + { + publicHref, + href, + pathname: nextPathname, + search: nextSearch, + searchStr, + state: nextState as any, + hash: hash ?? '', + external, + origin: memoUrl?.origin ?? this.origin!, + unmaskOnReload: dest.unmaskOnReload, + }, + memoUrl, + ) } const buildWithMatches = ( @@ -2125,22 +2131,20 @@ export class RouterCore< } = next if (maskedLocation) { + const tempLocation = parseHref(nextHistory.href, { + ...nextHistory.state, + __tempKey: undefined!, + __tempLocation: undefined!, + __TSR_key: undefined!, + key: undefined!, // TODO: Remove in v2 - use __TSR_key instead + }) + nextHistory = { ...maskedLocation, state: { ...maskedLocation.state, __tempKey: undefined, - __tempLocation: { - ...nextHistory, - search: nextHistory.searchStr, - state: { - ...nextHistory.state, - __tempKey: undefined!, - __tempLocation: undefined!, - __TSR_key: undefined!, - key: undefined!, // TODO: Remove in v2 - use __TSR_key instead - }, - }, + __tempLocation: tempLocation, }, } @@ -2949,6 +2953,35 @@ function comparePaths(a: string, b: string) { return normalize(a) === normalize(b) } +type ParsedLocationWithoutGetUrl = Omit< + ParsedLocation, + 'getUrl' +> + +const parsedLocationUrls = new WeakMap, URL>() + +function getParsedLocationUrl(this: ParsedLocation) { + let url = parsedLocationUrls.get(this) + + if (!url) { + url = new URL(this.publicHref, this.origin) + parsedLocationUrls.set(this, url) + } + + return url +} + +function initializeParsedLocation( + location: ParsedLocationWithoutGetUrl, + url?: URL | null, +): ParsedLocation { + const parsedLocation = location as ParsedLocation + parsedLocation.getUrl = + getParsedLocationUrl as ParsedLocation['getUrl'] + if (url) parsedLocationUrls.set(parsedLocation as any, url) + return parsedLocation +} + /** * Lazily import a module function and forward arguments to it, retaining * parameter and return types for the selected export key. diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index 6aca9b3b6a8..c3f129bfee1 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' +import { replaceEqualDeep } from '../src/utils' import { createTestRouter } from './routerTestUtils' describe('buildLocation - params function receives parsed params', () => { @@ -1207,8 +1208,10 @@ describe('buildLocation - location output structure', () => { // Verify all expected properties exist expect(location).toEqual({ external: false, + getUrl: expect.any(Function), hash: 'section', href: '/posts?page=1#section', + origin: 'http://localhost:3000', pathname: '/posts', publicHref: '/posts?page=1#section', search: { @@ -1246,6 +1249,93 @@ describe('buildLocation - location output structure', () => { expect(location.searchStr).toBe('') expect(location.href).toBe('/posts') }) + + test('location should expose a memoized lazy getUrl method', async () => { + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + + const routeTree = rootRoute.addChildren([postsRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/posts'] }), + }) + + await router.load() + + const location = router.buildLocation({ + to: '/posts', + search: { page: 1 }, + hash: 'section', + }) + + expect(location.getUrl).toBeDefined() + expect(location.getUrl()).toBe(location.getUrl()) + expect(location.getUrl().pathname).toBe('/posts') + expect(location.getUrl().search).toBe('?page=1') + expect(location.getUrl().hash).toBe('#section') + }) + + test('router state location should expose getUrl after parsing history', async () => { + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + + const routeTree = rootRoute.addChildren([postsRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/posts?page=1#section'], + }), + }) + + await router.load() + + expect(router.state.location.getUrl().pathname).toBe('/posts') + expect(router.state.location.getUrl().search).toBe('?page=1') + expect(router.state.location.getUrl().hash).toBe('#section') + }) + + test('replaceEqualDeep should preserve getUrl on structurally shared locations', async () => { + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + + const routeTree = rootRoute.addChildren([postsRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/posts?page=1#section'], + }), + }) + + await router.load() + + const prev = router.state.location + const next = router.buildLocation({ + to: '/posts', + search: { page: 2 }, + hash: 'section', + }) + + const shared = replaceEqualDeep(prev, next) + + expect(shared).not.toBe(prev) + expect(shared.getUrl).toBe(prev.getUrl) + expect(shared.search).not.toBe(prev.search) + expect(shared.getUrl().pathname).toBe('/posts') + expect(shared.getUrl().search).toBe('?page=2') + expect(shared.getUrl().hash).toBe('#section') + }) }) describe('buildLocation - optional params', () => { diff --git a/packages/router-core/tests/mask.test.ts b/packages/router-core/tests/mask.test.ts index 7e46303787f..a0b961275c3 100644 --- a/packages/router-core/tests/mask.test.ts +++ b/packages/router-core/tests/mask.test.ts @@ -90,6 +90,8 @@ describe('buildLocation - route masks', () => { expect(location.maskedLocation).toBeDefined() expect(location.maskedLocation!.pathname).toBe('/photos/123') expect(location.pathname).toBe('/photos/123/modal') + expect(location.maskedLocation!.getUrl().pathname).toBe('/photos/123') + expect(location.getUrl().pathname).toBe('/photos/123/modal') }) test('should set params to {} when maskParams is false', () => { diff --git a/packages/router-core/tests/rewrite.test.ts b/packages/router-core/tests/rewrite.test.ts new file mode 100644 index 00000000000..3f9adbb584f --- /dev/null +++ b/packages/router-core/tests/rewrite.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, RouterCore } from '../src' +import { createTestRouter } from './routerTestUtils' + +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 createTestRouter({ + 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.getUrl().href).toBe( + 'https://public.example.com/docs/about?lang=en#team', + ) + expect(router.state.location.getUrl().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.getUrl().href).toBe( + 'https://public.example.com/docs/about?lang=en#team', + ) + expect(location.getUrl().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.getUrl().href).toBe( + 'https://public.example.com/docs/about?lang=en#team', + ) + }) +}) diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx index 5d3ecc898ac..660eb3c97b5 100644 --- a/packages/solid-router/tests/redirect.test.tsx +++ b/packages/solid-router/tests/redirect.test.tsx @@ -351,9 +351,11 @@ describe('redirect', () => { expect(redirectResponse.options).toEqual({ _fromLocation: { external: false, + getUrl: expect.any(Function), publicHref: '/', hash: '', href: '/', + origin: 'http://localhost', pathname: '/', search: {}, searchStr: '',