Skip to content
Open
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
10 changes: 9 additions & 1 deletion packages/history/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,8 +438,16 @@ export function createBrowserHistory(opts?: {
action,
})
if (isBlocked) {
// The browser has already traversed. Without a usable delta, the
// previous entry cannot be restored without guessing a direction.
// Accept the traversal instead of calling go(0), which reloads.
if (!Number.isSafeInteger(delta) || delta === 0) {
currentLocation = nextLocation
history.notify(notify)
return
}
ignoreNextPop = true
win.history.go(1)
win.history.go(-delta)
history.notify(notify)
return
}
Expand Down
142 changes: 142 additions & 0 deletions packages/history/tests/createBrowserHistory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { afterEach, describe, expect, test, vi } from 'vitest'

import { createBrowserHistory } from '../src'
import type { BlockerFn, RouterHistory } from '../src'

describe('createBrowserHistory', () => {
let history: RouterHistory | undefined

afterEach(() => {
history?.destroy()
history = undefined
vi.restoreAllMocks()
})

function setupEntries(
firstPath: string,
...remainingPaths: Array<string>
): RouterHistory {
window.history.replaceState(
{ __TSR_index: 0, __TSR_key: 'entry-0' },
'',
firstPath,
)
remainingPaths.forEach((path, index) => {
const historyIndex = index + 1
window.history.pushState(
{
__TSR_index: historyIndex,
__TSR_key: `entry-${historyIndex}`,
},
'',
path,
)
})

history = createBrowserHistory()
return history
}

async function expectLocation(pathname: string) {
await vi.waitFor(() => {
expect(window.location.pathname).toBe(pathname)
expect(history?.location.pathname).toBe(pathname)
})
}

test('restores the current entry when a forward navigation is blocked', async () => {
const history = setupEntries('/one', '/two')
history.back()
await expectLocation('/one')
const blockerFn = vi.fn(() => true)
history.block({ blockerFn })

history.forward()

await vi.waitFor(() => expect(blockerFn).toHaveBeenCalledOnce())
await expectLocation('/one')
})

test('restores the exact current entry when a multi-entry navigation is blocked', async () => {
const history = setupEntries('/one', '/two', '/three')
const blockerFn = vi.fn(() => true)
history.block({ blockerFn })

history.go(-2)

await vi.waitFor(() => expect(blockerFn).toHaveBeenCalledOnce())
await expectLocation('/three')
})

test('accepts a blocked pop with zero delta instead of reloading', async () => {
window.history.replaceState(
{ __TSR_index: 0, __TSR_key: 'entry-0' },
'',
'/one',
)
window.history.pushState(
{ __TSR_index: 0, __TSR_key: 'entry-1' },
'',
'/two',
)
history = createBrowserHistory()
const blockerFn = vi.fn(() => true)
history.block({ blockerFn })
const goSpy = vi.spyOn(window.history, 'go')
const subscriber = vi.fn()
history.subscribe(subscriber)

history.back()

await vi.waitFor(() => expect(blockerFn).toHaveBeenCalledOnce())
await expectLocation('/one')
expect(goSpy).not.toHaveBeenCalled()
expect(subscriber).toHaveBeenLastCalledWith({
location: expect.objectContaining({ pathname: '/one' }),
action: { type: 'GO', index: 0 },
})
})

test('accepts a blocked pop into a legacy history entry without reloading', async () => {
// A hard reload after switching routers preserves older pushState entries.
// The active entry is already TanStack history, while the previous entry
// still has the history@4 `{ key, state }` shape and no `__TSR_index`.
const legacyState = {
key: 'history-v4-entry',
state: { source: 'history@4' },
}
window.history.replaceState(legacyState, '', '/legacy')
window.history.pushState(
{ __TSR_index: 1, __TSR_key: 'tanstack-entry' },
'',
'/current',
)
history = createBrowserHistory()
let observedDelta: number | undefined
const blockerFn = vi.fn<BlockerFn>(({ currentLocation, nextLocation }) => {
observedDelta =
Reflect.get(nextLocation.state, '__TSR_index') -
currentLocation.state.__TSR_index
return true
})
history.block({ blockerFn })
const goSpy = vi.spyOn(window.history, 'go')
const subscriber = vi.fn()
history.subscribe(subscriber)

history.back()

await vi.waitFor(() => expect(blockerFn).toHaveBeenCalledOnce())
expect(observedDelta).toBeNaN()
await expectLocation('/legacy')
expect(history.location.state).toEqual(legacyState)
expect(goSpy).not.toHaveBeenCalled()
expect(subscriber).toHaveBeenLastCalledWith({
location: expect.objectContaining({
pathname: '/legacy',
state: legacyState,
}),
action: expect.objectContaining({ type: 'GO' }),
})
})
})