From 09c1d1e5b969aa7ddc08aba50ed1a5204183b662 Mon Sep 17 00:00:00 2001 From: Nikita Date: Sun, 26 Jul 2026 21:56:48 +0200 Subject: [PATCH 1/3] fix(history): restore blocked pop navigation by delta --- packages/history/src/index.ts | 2 +- .../tests/createBrowserHistory.test.ts | 69 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 packages/history/tests/createBrowserHistory.test.ts diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts index 0f3be8242e..81cc814868 100644 --- a/packages/history/src/index.ts +++ b/packages/history/src/index.ts @@ -439,7 +439,7 @@ export function createBrowserHistory(opts?: { }) if (isBlocked) { ignoreNextPop = true - win.history.go(1) + win.history.go(-delta) history.notify(notify) return } diff --git a/packages/history/tests/createBrowserHistory.test.ts b/packages/history/tests/createBrowserHistory.test.ts new file mode 100644 index 0000000000..892fb93c2c --- /dev/null +++ b/packages/history/tests/createBrowserHistory.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { createBrowserHistory } from '../src' +import type { RouterHistory } from '../src' + +describe('createBrowserHistory', () => { + let history: RouterHistory | undefined + + afterEach(() => { + history?.destroy() + history = undefined + }) + + function setupEntries( + firstPath: string, + ...remainingPaths: Array + ): 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') + }) +}) From 667d8fe6597fdad1d4a32a0fcb8f6862cf3bb484 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 28 Jul 2026 12:29:34 +0200 Subject: [PATCH 2/3] fix(history): avoid reload for unusable pop delta --- packages/history/src/index.ts | 8 +++++ .../tests/createBrowserHistory.test.ts | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts index 81cc814868..3a3aa5425c 100644 --- a/packages/history/src/index.ts +++ b/packages/history/src/index.ts @@ -438,6 +438,14 @@ 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(-delta) history.notify(notify) diff --git a/packages/history/tests/createBrowserHistory.test.ts b/packages/history/tests/createBrowserHistory.test.ts index 892fb93c2c..3858064e32 100644 --- a/packages/history/tests/createBrowserHistory.test.ts +++ b/packages/history/tests/createBrowserHistory.test.ts @@ -9,6 +9,7 @@ describe('createBrowserHistory', () => { afterEach(() => { history?.destroy() history = undefined + vi.restoreAllMocks() }) function setupEntries( @@ -66,4 +67,33 @@ describe('createBrowserHistory', () => { 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 }, + }) + }) }) From b8bdb1fc31bf9ce2f92984d4c6dca207eeaa9b79 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 28 Jul 2026 13:18:21 +0200 Subject: [PATCH 3/3] test(history): cover hybrid legacy entries --- .../tests/createBrowserHistory.test.ts | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/history/tests/createBrowserHistory.test.ts b/packages/history/tests/createBrowserHistory.test.ts index 3858064e32..c2efeb2fa2 100644 --- a/packages/history/tests/createBrowserHistory.test.ts +++ b/packages/history/tests/createBrowserHistory.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest' import { createBrowserHistory } from '../src' -import type { RouterHistory } from '../src' +import type { BlockerFn, RouterHistory } from '../src' describe('createBrowserHistory', () => { let history: RouterHistory | undefined @@ -96,4 +96,47 @@ describe('createBrowserHistory', () => { 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(({ 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' }), + }) + }) })