Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-locations-unshared.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

`buildLocation` no longer structurally shares the built `search` and `state` with the current location. The observable `location.search` and `location.state` still preserve equal nested references across navigations, because `parseLocation` stabilizes them once a location is committed. A search whose contents equal the current search but list its keys in a different order now serializes in the requested order, so navigating to it creates a new history entry instead of being treated as the same location. A `state` object passed to `navigate` or `buildLocation` is never mutated.
35 changes: 16 additions & 19 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1881,7 +1881,9 @@ export class RouterCore<
/**
* Build the next ParsedLocation from navigation options without committing.
* Resolves `to`/`from`, params/search/hash/state, applies search validation
* and middlewares, and returns a stable, stringified location object.
* and middlewares, and returns a stringified location object. The built
* `search` and `state` are not structurally shared with the current
* location; `parseLocation` stabilizes them once the location is committed.
*
* @link https://tanstack.com/router/latest/docs/framework/react/api/router/RouterType#buildlocation-method
*/
Expand Down Expand Up @@ -2094,19 +2096,17 @@ export class RouterCore<
}
return search
}
// A literal search never reads the current one.
let nextSearch: Record<string, unknown> = middlewares.length
// A literal search never reads the current one. The result is not
// structurally shared with the current search: `parseLocation` keeps
// equal nested values stable once the location is committed.
const nextSearch: Record<string, unknown> = middlewares.length
? applySearchMiddleware(middlewares, fromSearch(), dest)
: dest.search === true
? fromSearch()
: typeof dest.search === 'function'
? dest.search(fromSearch())
: (dest.search as Record<string, unknown>) || EMPTY_RECORD

// Structural sharing only affects identity, so it does not make the
// location depend on the current one.
nextSearch = nullReplaceEqualDeep(lightweight[2 /* search */], nextSearch)

// Stringify the next search
const searchStr = this.options.stringifySearch(nextSearch)

Expand All @@ -2121,18 +2121,15 @@ export class RouterCore<
// Resolve the next hash string
const hashStr = hash ? `#${hash}` : ''

// Resolve the next state
let nextState: HistoryState = EMPTY_RECORD
if (dest.state) {
nextState =
dest.state === true
? current().state
: typeof dest.state === 'function'
? dest.state(current().state)
: dest.state
// Identity-only, as above.
nextState = replaceEqualDeep(currentLocation.state, nextState)
}
// Resolve the next state. A literal state never reads the current one
// and, like the search, is not shared with it here.
const nextState: HistoryState = !dest.state
? EMPTY_RECORD
: dest.state === true
? current().state
: typeof dest.state === 'function'
? dest.state(current().state)
: dest.state

// Create the full path of the location
const fullPath = `${nextPathname}${searchStr}${hashStr}`
Expand Down
178 changes: 149 additions & 29 deletions packages/router-core/tests/build-location.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1299,68 +1299,188 @@ describe('buildLocation - state', () => {
expect(location.state).not.toBe(emptyState)
})

test('explicit state structurally shares unchanged nested values', async () => {
test('state can contain complex nested objects', async () => {
const rootRoute = new BaseRootRoute({})
const postsRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/posts',
})
const history = createMemoryHistory({ initialEntries: ['/posts'] })
history.replace('/posts', {

const routeTree = rootRoute.addChildren([postsRoute])

const router = createTestRouter({
routeTree,
history: createMemoryHistory({ initialEntries: ['/posts'] }),
})

await router.load()

const complexState = {
user: { id: 1, name: 'Test' },
count: 1,
items: [1, 2, 3],
nested: { deep: { value: true } },
}

const location = router.buildLocation({
to: '/posts',
state: complexState as any,
})
const router = createTestRouter({

expect(location.state).toEqual(complexState)
})
})

describe('buildLocation - no structural sharing with the current location', () => {
function createPostsRouter(
history = createMemoryHistory({ initialEntries: ['/posts'] }),
) {
const rootRoute = new BaseRootRoute({})
const postsRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/posts',
})
return createTestRouter({
routeTree: rootRoute.addChildren([postsRoute]),
history,
})
}

test('explicit state is returned as-is and equal nested values are shared only after navigation', async () => {
const history = createMemoryHistory({ initialEntries: ['/posts'] })
history.replace('/posts', {
user: { id: 1, name: 'Test' },
count: 1,
})
const router = createPostsRouter(history)
await router.load()

const currentState = router.state.location.state as any
const previousState = router.state.location.state as any
const nextState = {
user: { id: 1, name: 'Test' },
count: 2,
}
const location = router.buildLocation({
to: '/posts',
state: {
user: { id: 1, name: 'Test' },
count: 2,
} as any,
state: nextState as any,
})

expect(location.state).toEqual({
// The built location carries the caller's object untouched.
expect(location.state).toBe(nextState)
expect((location.state as any).user).not.toBe(previousState.user)

await router.navigate({ to: '/posts', state: nextState as any })

// parseLocation still stabilizes the committed state against the
// previous one, which is what location selectors rely on.
const committedState = router.state.location.state as any
expect(committedState).toMatchObject({
user: { id: 1, name: 'Test' },
count: 2,
})
expect((location.state as any).user).toBe(currentState.user)
expect(location.state).not.toBe(currentState)
expect(committedState).not.toBe(previousState)
expect(committedState.user).toBe(previousState.user)
expect(nextState.user).not.toBe(previousState.user)
})

test('state can contain complex nested objects', async () => {
const rootRoute = new BaseRootRoute({})
const postsRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/posts',
test('explicit search is returned as-is and equal nested values are shared only after navigation', async () => {
const router = createPostsRouter()
await router.load()

await router.navigate({
to: '/posts',
search: { page: 1, filter: { tags: ['a'] } } as any,
})
const previousSearch = router.state.location.search as any
expect(previousSearch).toEqual({ page: 1, filter: { tags: ['a'] } })

const routeTree = rootRoute.addChildren([postsRoute])
const nextSearch = { page: 2, filter: { tags: ['a'] } }
const location = router.buildLocation({
to: '/posts',
search: nextSearch as any,
})

const router = createTestRouter({
routeTree,
history: createMemoryHistory({ initialEntries: ['/posts'] }),
expect(location.search).toBe(nextSearch)
expect((location.search as any).filter).not.toBe(previousSearch.filter)

await router.navigate({ to: '/posts', search: nextSearch as any })

const committedSearch = router.state.location.search as any
expect(committedSearch).toEqual({ page: 2, filter: { tags: ['a'] } })
expect(committedSearch).not.toBe(previousSearch)
expect(committedSearch.filter).toBe(previousSearch.filter)
expect(nextSearch.filter).not.toBe(previousSearch.filter)
})

test('navigate does not mutate a caller-supplied state object', async () => {
const history = createMemoryHistory({ initialEntries: ['/posts'] })
const router = createPostsRouter(history)
await router.load()

const state = { user: { id: 1 } }
await router.navigate({
to: '/posts',
state: state as any,
hashScrollIntoView: true,
})

expect(state).toEqual({ user: { id: 1 } })
expect(Object.keys(state)).toEqual(['user'])
const committedState = router.state.location.state as any
expect(committedState).not.toBe(state)
expect(committedState.user).toBe(state.user)
expect(committedState.__hashScrollIntoViewOptions).toBe(true)
expect(committedState.__TSR_key).toBeTypeOf('string')
expect(committedState.key).toBe(committedState.__TSR_key)

// A frozen state (for example produced by an immutable store) commits
// without any write hitting it: in strict mode such a write would throw.
const frozenState = Object.freeze({ user: Object.freeze({ id: 2 }) })
await router.navigate({ to: '/posts', state: frozenState as any })

expect(router.state.location.state).toMatchObject({ user: { id: 2 } })
expect(router.state.location.state).not.toBe(frozenState)
expect(history.length).toBe(3)
})

test('an equal search in a different key order serializes in the requested order', async () => {
const router = createPostsRouter(
createMemoryHistory({ initialEntries: ['/posts?a=1&b=2'] }),
)
await router.load()

const complexState = {
user: { id: 1, name: 'Test' },
items: [1, 2, 3],
nested: { deep: { value: true } },
}
expect(router.state.location.href).toBe('/posts?a=1&b=2')

const location = router.buildLocation({
to: '/posts',
state: complexState as any,
search: { b: 2, a: 1 } as any,
})

expect(location.state).toEqual(complexState)
expect(location.search).toEqual({ a: 1, b: 2 })
expect(Object.keys(location.search)).toEqual(['b', 'a'])
expect(location.searchStr).toBe('?b=2&a=1')
expect(location.href).toBe('/posts?b=2&a=1')
})

test('navigating to an equal search in a different key order pushes a new history entry', async () => {
const history = createMemoryHistory({ initialEntries: ['/posts?a=1&b=2'] })
const router = createPostsRouter(history)
await router.load()

expect(history.length).toBe(1)

// Same contents and order: nothing to commit.
await router.navigate({ to: '/posts', search: { a: 1, b: 2 } as any })

expect(history.length).toBe(1)
expect(router.state.location.href).toBe('/posts?a=1&b=2')

// Same contents, different order: the URL changes, so history grows.
await router.navigate({ to: '/posts', search: { b: 2, a: 1 } as any })

expect(history.length).toBe(2)
expect(history.location.href).toBe('/posts?b=2&a=1')
expect(router.state.location.href).toBe('/posts?b=2&a=1')
expect(router.state.location.search).toEqual({ a: 1, b: 2 })
})
})

Expand Down
Loading