From bb082e5429a85acf58e06e2ddde564eeb2684fba Mon Sep 17 00:00:00 2001
From: Alaister Young <10985857+alaister@users.noreply.github.com>
Date: Tue, 29 Sep 2026 13:29:01 -0700
Subject: [PATCH] fix(studio): guard unsaved edits in TanStack
---
apps/studio/TANSTACK_MIGRATION.md | 7 +-
apps/studio/compat/next/_router-events.ts | 5 +-
...PreventNavigationOnUnsavedChanges.test.tsx | 303 ++++++++++++++++++
.../usePreventNavigationOnUnsavedChanges.ts | 18 ++
...PreventNavigationOnUnsavedChanges.test.tsx | 68 ++++
apps/studio/vite.config.ts | 6 +
pnpm-lock.yaml | 15 +-
pnpm-workspace.yaml | 2 +
8 files changed, 411 insertions(+), 13 deletions(-)
create mode 100644 apps/studio/compat/usePreventNavigationOnUnsavedChanges.test.tsx
create mode 100644 apps/studio/compat/usePreventNavigationOnUnsavedChanges.ts
create mode 100644 apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.test.tsx
diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md
index b07f589a098..a7559530678 100644
--- a/apps/studio/TANSTACK_MIGRATION.md
+++ b/apps/studio/TANSTACK_MIGRATION.md
@@ -469,8 +469,9 @@ inline.
`beforeHistoryChange` / `hashChangeStart` / `hashChangeComplete`.
**Known gap:** Next's throw-from-`routeChangeStart`-to-cancel pattern
isn't supportable — `subscribe` is fire-and-forget.
- `usePreventNavigationOnUnsavedChanges` relies on it and needs
- migrating to TanStack's `useBlocker` separately.
+ `usePreventNavigationOnUnsavedChanges` uses a Vite alias to
+ `compat/usePreventNavigationOnUnsavedChanges.ts`, which blocks through
+ TanStack's native `useBlocker`. Next keeps the original hook.
- `api.ts` — `toWebHandler(nextHandler)`. See **API routes → Shim
coverage** above.
- `link.tsx`, `navigation.ts`, `dynamic.tsx`, `image.tsx`,
@@ -616,7 +617,7 @@ for the Vite pipeline:
- Switch `routes/index.tsx` redirects from `href` to `to` — all targets
now live in the TanStack tree.
-- Migrate `usePreventNavigationOnUnsavedChanges` from `router.events.on('routeChangeStart', …)` (throw-to-cancel pattern) to TanStack's `useBlocker`.
+- Remove the Vite alias for `usePreventNavigationOnUnsavedChanges` and replace the original Next hook with the native implementation in `compat/usePreventNavigationOnUnsavedChanges.ts`.
- Drop the `_splat` / `routeSlug` normalisation block from
`pages/org/_/[[...routeSlug]].tsx` + `pages/project/_/[[...routeSlug]].tsx` (only there to keep both runtimes mounting the same body).
- Remove `RouteValidationWrapper` + `next/router` compat shim usage from `__root.tsx`.
diff --git a/apps/studio/compat/next/_router-events.ts b/apps/studio/compat/next/_router-events.ts
index 87c9d3ca83d..28dba104e80 100644
--- a/apps/studio/compat/next/_router-events.ts
+++ b/apps/studio/compat/next/_router-events.ts
@@ -16,9 +16,8 @@
//
// Known gap: Next's `routeChangeStart` lets handlers throw to cancel the
// navigation. TanStack's `subscribe` is fire-and-forget; cancellation
-// requires `useBlocker` instead. `usePreventNavigationOnUnsavedChanges`
-// relies on the throw-to-cancel pattern and will need migrating to
-// `useBlocker` separately.
+// requires `useBlocker` instead. Vite selects a native implementation of
+// `usePreventNavigationOnUnsavedChanges` so that guard bypasses this adapter.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyRouter = any
diff --git a/apps/studio/compat/usePreventNavigationOnUnsavedChanges.test.tsx b/apps/studio/compat/usePreventNavigationOnUnsavedChanges.test.tsx
new file mode 100644
index 00000000000..76222f07aac
--- /dev/null
+++ b/apps/studio/compat/usePreventNavigationOnUnsavedChanges.test.tsx
@@ -0,0 +1,303 @@
+import {
+ createBrowserHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ RouterContextProvider,
+ type AnyRouter,
+} from '@tanstack/react-router'
+import { act, renderHook, waitFor } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { afterEach, describe, expect, it } from 'vitest'
+
+import { usePreventNavigationOnUnsavedChanges } from './usePreventNavigationOnUnsavedChanges'
+import { parseSearch, stringifySearch } from '@/lib/router-search-params'
+
+let destroyHistory: (() => void) | undefined
+
+function renderNavigationGuard(hasChanges = true, basepath = '/') {
+ const editorPath = `${basepath === '/' ? '' : basepath}/editor`
+ window.history.replaceState(null, '', editorPath)
+ const history = createBrowserHistory()
+ destroyHistory = () => history.destroy()
+ const rootRoute = createRootRoute()
+ const routeTree = rootRoute.addChildren([
+ createRoute({ getParentRoute: () => rootRoute, path: '/editor' }),
+ createRoute({ getParentRoute: () => rootRoute, path: '/overview' }),
+ createRoute({ getParentRoute: () => rootRoute, path: '/other' }),
+ ])
+ const router = createRouter({ routeTree, history, basepath, parseSearch, stringifySearch })
+ const hook = renderHook(
+ ({ hasChanges }) => usePreventNavigationOnUnsavedChanges({ hasChanges }),
+ {
+ initialProps: { hasChanges },
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ }
+ )
+ return { ...hook, router, history, editorPath }
+}
+
+function dispatchBeforeUnload() {
+ const event = new Event('beforeunload', { cancelable: true })
+ window.dispatchEvent(event)
+ return event
+}
+
+afterEach(() => {
+ destroyHistory?.()
+ destroyHistory = undefined
+})
+
+describe('TanStack unsaved-change navigation guard', () => {
+ it('allows clean navigation without confirmation', async () => {
+ const { result, router, history } = renderNavigationGuard(false)
+
+ await act(async () => {
+ await router.navigate({ to: '/overview' })
+ })
+
+ expect(history.location.pathname).toBe('/overview')
+ expect(window.location.pathname).toBe('/overview')
+ expect(result.current.shouldConfirmNavigation).toBe(false)
+ })
+
+ it('keeps the URL and history unchanged until a dirty navigation is confirmed', async () => {
+ const { result, router, history } = renderNavigationGuard()
+ const initialLength = history.length
+
+ act(() => {
+ void router.navigate({ to: '/overview' })
+ })
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+
+ expect(history.location.pathname).toBe('/editor')
+ expect(window.location.pathname).toBe('/editor')
+ expect(history.length).toBe(initialLength)
+
+ await act(async () => result.current.handleConfirmNavigation())
+ await waitFor(() => expect(window.location.pathname).toBe('/overview'))
+ expect(history.location.pathname).toBe('/overview')
+ expect(history.length).toBe(initialLength + 1)
+ expect(result.current.shouldConfirmNavigation).toBe(false)
+ })
+
+ it('cancels dirty navigation and can cancel another destination afterward', async () => {
+ const { result, history } = renderNavigationGuard()
+ const initialLength = history.length
+
+ for (const target of ['/overview', '/other']) {
+ act(() => history.push(target))
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+ await act(async () => result.current.handleCancelNavigation())
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(false))
+
+ expect(history.location.pathname).toBe('/editor')
+ expect(window.location.pathname).toBe('/editor')
+ expect(history.length).toBe(initialLength)
+ }
+ })
+
+ it('confirms the new destination on the first click after canceling a previous attempt', async () => {
+ const { result, history } = renderNavigationGuard()
+
+ act(() => history.push('/overview'))
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+ await act(async () => result.current.handleCancelNavigation())
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(false))
+
+ act(() => history.push('/other?tab=details#section'))
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+ await act(async () => result.current.handleConfirmNavigation())
+
+ await waitFor(() => expect(history.location.href).toBe('/other?tab=details#section'))
+ expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe(
+ '/other?tab=details#section'
+ )
+ })
+
+ it('preserves replace navigation and base paths when confirming', async () => {
+ const { result, router, history } = renderNavigationGuard(true, '/dashboard')
+ const initialLength = history.length
+
+ act(() => {
+ void router.navigate({ to: '/overview', replace: true })
+ })
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+ expect(window.location.pathname).toBe('/dashboard/editor')
+ await act(async () => result.current.handleConfirmNavigation())
+
+ await waitFor(() => expect(window.location.pathname).toBe('/dashboard/overview'))
+ expect(history.length).toBe(initialLength)
+ })
+
+ it('preserves encoded query values, array values, and fragments through browser history', async () => {
+ const { result, router, history } = renderNavigationGuard()
+ const search = {
+ s: 'select timestamp\nfrom logs\nlimit 5',
+ filter: ['name:eq:日本語', 'count:gt:2'],
+ q: 'space + plus % percent',
+ }
+
+ act(() => {
+ void router.navigate({ to: '/overview', search, hash: 'details' })
+ })
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+ expect(window.location.pathname).toBe('/editor')
+ await act(async () => result.current.handleConfirmNavigation())
+
+ await waitFor(() => expect(window.location.pathname).toBe('/overview'))
+ expect(history.location.href).toBe(`/overview${stringifySearch(search)}#details`)
+ expect(window.location.search).toContain('%0A')
+ expect(parseSearch(window.location.search)).toEqual(search)
+ expect(window.location.hash).toBe('#details')
+ })
+
+ it('guards the next navigation after confirming one while the editor remains mounted', async () => {
+ const { result, history } = renderNavigationGuard()
+
+ act(() => history.push('/editor?tab=second'))
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+ await act(async () => result.current.handleConfirmNavigation())
+ await waitFor(() => expect(history.location.href).toBe('/editor?tab=second'))
+
+ act(() => history.push('/overview'))
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+ await act(async () => result.current.handleCancelNavigation())
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(false))
+ expect(history.location.href).toBe('/editor?tab=second')
+ })
+
+ it('uses the latest dirty state and allows navigation after saving', async () => {
+ const { result, rerender, history } = renderNavigationGuard(false)
+ rerender({ hasChanges: true })
+
+ act(() => history.push('/overview'))
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+ await act(async () => result.current.handleCancelNavigation())
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(false))
+ rerender({ hasChanges: false })
+
+ act(() => history.push('/overview'))
+ await waitFor(() => expect(window.location.pathname).toBe('/overview'))
+ expect(result.current.shouldConfirmNavigation).toBe(false)
+ })
+
+ it('cancels browser Back and subsequently confirms it without adding a history entry', async () => {
+ const { result, rerender, history } = renderNavigationGuard(false)
+ act(() => history.push('/overview'))
+ await waitFor(() => expect(window.location.pathname).toBe('/overview'))
+ const initialLength = history.length
+ rerender({ hasChanges: true })
+
+ act(() => window.history.back())
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+ expect(history.location.pathname).toBe('/overview')
+ await act(async () => result.current.handleCancelNavigation())
+ await waitFor(() => expect(window.location.pathname).toBe('/overview'))
+ expect(history.location.pathname).toBe('/overview')
+
+ act(() => window.history.back())
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+ await act(async () => result.current.handleConfirmNavigation())
+ await waitFor(() => expect(history.location.pathname).toBe('/editor'))
+ expect(window.location.pathname).toBe('/editor')
+ expect(history.length).toBe(initialLength)
+ })
+
+ it('confirms browser Forward without adding a history entry', async () => {
+ const { result, rerender, history } = renderNavigationGuard(false)
+ act(() => history.push('/overview'))
+ await waitFor(() => expect(window.location.pathname).toBe('/overview'))
+ act(() => window.history.back())
+ await waitFor(() => expect(history.location.pathname).toBe('/editor'))
+ const initialLength = history.length
+ rerender({ hasChanges: true })
+
+ act(() => window.history.forward())
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+ expect(history.location.pathname).toBe('/editor')
+ await act(async () => result.current.handleConfirmNavigation())
+ await waitFor(() => expect(history.location.pathname).toBe('/overview'))
+ expect(window.location.pathname).toBe('/overview')
+ expect(history.length).toBe(initialLength)
+ })
+
+ it('restores the current editor URL when browser Forward is canceled', async () => {
+ const { result, rerender, history } = renderNavigationGuard(false)
+ act(() => history.push('/overview'))
+ await waitFor(() => expect(window.location.pathname).toBe('/overview'))
+ act(() => window.history.back())
+ await waitFor(() => expect(history.location.pathname).toBe('/editor'))
+ rerender({ hasChanges: true })
+
+ act(() => window.history.forward())
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+ await act(async () => result.current.handleCancelNavigation())
+
+ await waitFor(() => expect(window.location.pathname).toBe('/editor'))
+ expect(history.location.pathname).toBe('/editor')
+ expect(result.current.shouldConfirmNavigation).toBe(false)
+ })
+
+ it('restores the current editor URL when jumping multiple history entries is canceled', async () => {
+ const { result, rerender, history } = renderNavigationGuard(false)
+ act(() => history.push('/overview'))
+ await waitFor(() => expect(window.location.pathname).toBe('/overview'))
+ act(() => history.push('/other'))
+ await waitFor(() => expect(window.location.pathname).toBe('/other'))
+ rerender({ hasChanges: true })
+
+ act(() => window.history.go(-2))
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+ await act(async () => result.current.handleCancelNavigation())
+
+ await waitFor(() => expect(window.location.pathname).toBe('/other'))
+ expect(history.location.pathname).toBe('/other')
+ })
+
+ it('only prompts before unloading while dirty, and removes protection on unmount', () => {
+ const { rerender, unmount } = renderNavigationGuard(false)
+ expect(dispatchBeforeUnload().defaultPrevented).toBe(false)
+
+ rerender({ hasChanges: true })
+ expect(dispatchBeforeUnload().defaultPrevented).toBe(true)
+
+ rerender({ hasChanges: false })
+ expect(dispatchBeforeUnload().defaultPrevented).toBe(false)
+
+ rerender({ hasChanges: true })
+ unmount()
+ expect(dispatchBeforeUnload().defaultPrevented).toBe(false)
+ })
+
+ it('retains unload protection after canceling and confirming an in-app history traversal', async () => {
+ const { result, rerender, history } = renderNavigationGuard(false)
+ act(() => history.push('/overview'))
+ await waitFor(() => expect(window.location.pathname).toBe('/overview'))
+ rerender({ hasChanges: true })
+
+ act(() => history.back())
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+ await act(async () => result.current.handleCancelNavigation())
+ await waitFor(() => expect(window.location.pathname).toBe('/overview'))
+ expect(dispatchBeforeUnload().defaultPrevented).toBe(true)
+
+ act(() => history.back())
+ await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true))
+ await act(async () => result.current.handleConfirmNavigation())
+ await waitFor(() => expect(history.location.pathname).toBe('/editor'))
+ expect(dispatchBeforeUnload().defaultPrevented).toBe(true)
+ })
+
+ it('ignores confirmation controls when there is no blocked navigation', async () => {
+ const { result, history } = renderNavigationGuard()
+ await act(async () => {
+ result.current.handleCancelNavigation()
+ result.current.handleConfirmNavigation()
+ })
+ expect(history.location.pathname).toBe('/editor')
+ expect(result.current.shouldConfirmNavigation).toBe(false)
+ })
+})
diff --git a/apps/studio/compat/usePreventNavigationOnUnsavedChanges.ts b/apps/studio/compat/usePreventNavigationOnUnsavedChanges.ts
new file mode 100644
index 00000000000..0add6c634b9
--- /dev/null
+++ b/apps/studio/compat/usePreventNavigationOnUnsavedChanges.ts
@@ -0,0 +1,18 @@
+import { useBlocker } from '@tanstack/react-router'
+import { useCallback } from 'react'
+
+// Vite selects this implementation; Next keeps the pages-router hook.
+export const usePreventNavigationOnUnsavedChanges = ({ hasChanges }: { hasChanges: boolean }) => {
+ const shouldBlockFn = useCallback(() => hasChanges, [hasChanges])
+ const { reset, proceed, status } = useBlocker({
+ shouldBlockFn,
+ withResolver: true,
+ enableBeforeUnload: hasChanges,
+ })
+
+ return {
+ handleCancelNavigation: () => reset?.(),
+ handleConfirmNavigation: () => proceed?.(),
+ shouldConfirmNavigation: status === 'blocked',
+ }
+}
diff --git a/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.test.tsx b/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.test.tsx
new file mode 100644
index 00000000000..bab31115c03
--- /dev/null
+++ b/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.test.tsx
@@ -0,0 +1,68 @@
+import { act, renderHook } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+import { usePreventNavigationOnUnsavedChanges } from './usePreventNavigationOnUnsavedChanges'
+import { routerMock } from '@/tests/lib/route-mock'
+
+vi.mock('@/lib/constants', () => ({ BASE_PATH: '/dashboard' }))
+
+describe('Next unsaved-change navigation guard', () => {
+ it('runs without a TanStack router provider and allows clean navigation', async () => {
+ const { result } = renderHook(() => usePreventNavigationOnUnsavedChanges({ hasChanges: false }))
+ await act(async () => {
+ await routerMock.push('/overview')
+ })
+ expect(routerMock.asPath).toBe('/overview')
+ expect(result.current.shouldConfirmNavigation).toBe(false)
+ })
+
+ it('retains the Next route-change cancellation and keep-editing behavior', () => {
+ const { result } = renderHook(() => usePreventNavigationOnUnsavedChanges({ hasChanges: true }))
+ const initialPath = routerMock.asPath
+
+ act(() => {
+ expect(() =>
+ routerMock.events.emit('routeChangeStart', '/overview', { shallow: false })
+ ).toThrow('Route change declined')
+ })
+ expect(result.current.shouldConfirmNavigation).toBe(true)
+ expect(routerMock.asPath).toBe(initialPath)
+
+ act(() => result.current.handleCancelNavigation())
+ expect(result.current.shouldConfirmNavigation).toBe(false)
+ })
+
+ it('keeps base-path normalization when confirming in the Next runtime', () => {
+ const push = vi.spyOn(routerMock, 'push').mockResolvedValue(true)
+ const { result } = renderHook(() => usePreventNavigationOnUnsavedChanges({ hasChanges: true }))
+ act(() => {
+ expect(() =>
+ routerMock.events.emit('routeChangeStart', '/dashboard/overview?tab=details', {
+ shallow: false,
+ })
+ ).toThrow('Route change declined')
+ })
+
+ act(() => result.current.handleConfirmNavigation())
+ expect(push).toHaveBeenCalledWith('/overview?tab=details')
+ expect(result.current.shouldConfirmNavigation).toBe(false)
+ push.mockRestore()
+ })
+
+ it('keeps unload protection conditional on dirty state', () => {
+ const { rerender, unmount } = renderHook(
+ ({ hasChanges }) => usePreventNavigationOnUnsavedChanges({ hasChanges }),
+ { initialProps: { hasChanges: false } }
+ )
+ const beforeUnload = () => {
+ const event = new Event('beforeunload', { cancelable: true })
+ window.dispatchEvent(event)
+ return event.defaultPrevented
+ }
+ expect(beforeUnload()).toBe(false)
+ rerender({ hasChanges: true })
+ expect(beforeUnload()).toBe(true)
+ unmount()
+ expect(beforeUnload()).toBe(false)
+ })
+})
diff --git a/apps/studio/vite.config.ts b/apps/studio/vite.config.ts
index 9ac9552668a..ebe19fbfe15 100644
--- a/apps/studio/vite.config.ts
+++ b/apps/studio/vite.config.ts
@@ -548,6 +548,12 @@ export default defineConfig(({ command, mode }) => {
resolve: {
tsconfigPaths: true,
alias: [
+ // Blocking requires TanStack's native history API. Keep the original
+ // Next hook for the rollback runtime, which has no TanStack provider.
+ {
+ find: /^@\/hooks\/ui\/usePreventNavigationOnUnsavedChanges$/,
+ replacement: path.resolve(rootDir, 'compat/usePreventNavigationOnUnsavedChanges.ts'),
+ },
// `@sentry/nextjs`'s client entry drags in Next runtime internals
// (`next/dist/shared/lib/constants`), whose module scope evaluates
// `process?.features?.typescript` — optional chaining doesn't guard
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f91fb8caedb..7e69209355e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -108,6 +108,7 @@ catalogs:
overrides:
'@ardatan/relay-compiler>immutable': ^3.8.3
+ '@tanstack/history@1.162.0': 1.162.4
monaco-editor: 0.52.2
'@mapbox/node-pre-gyp>tar': ^7.5.21
'@sentry/webpack-plugin>uuid': ^11.1.1
@@ -8777,8 +8778,8 @@ packages:
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
- '@tanstack/history@1.162.0':
- resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==}
+ '@tanstack/history@1.162.4':
+ resolution: {integrity: sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ==}
engines: {node: '>=20.19'}
'@tanstack/hotkeys@0.8.0':
@@ -25905,7 +25906,7 @@ snapshots:
- supports-color
- typescript
- '@tanstack/history@1.162.0': {}
+ '@tanstack/history@1.162.4': {}
'@tanstack/hotkeys@0.8.0':
dependencies:
@@ -25972,7 +25973,7 @@ snapshots:
'@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@tanstack/history': 1.162.0
+ '@tanstack/history': 1.162.4
'@tanstack/react-store': 0.9.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tanstack/router-core': 1.171.8
isbot: 5.1.36
@@ -26017,7 +26018,7 @@ snapshots:
'@tanstack/react-start-server@1.167.13(crossws@0.4.12(srvx@1.0.4))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@tanstack/history': 1.162.0
+ '@tanstack/history': 1.162.4
'@tanstack/react-router': 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tanstack/router-core': 1.171.8
'@tanstack/start-client-core': 1.170.6
@@ -26084,7 +26085,7 @@ snapshots:
'@tanstack/router-core@1.171.8':
dependencies:
- '@tanstack/history': 1.162.0
+ '@tanstack/history': 1.162.4
cookie-es: 3.1.1
seroval: 1.6.2
seroval-plugins: 1.5.4(seroval@1.6.2)
@@ -26209,7 +26210,7 @@ snapshots:
'@tanstack/start-server-core@1.169.8(crossws@0.4.12(srvx@1.0.4))':
dependencies:
- '@tanstack/history': 1.162.0
+ '@tanstack/history': 1.162.4
'@tanstack/router-core': 1.171.8
'@tanstack/start-client-core': 1.170.6
'@tanstack/start-storage-context': 1.167.10
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index e08fc8c6e9f..4c8bf56a340 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -107,6 +107,8 @@ minimumReleaseAgeExclude:
overrides:
'@ardatan/relay-compiler>immutable': ^3.8.3
+ # Canceled history traversals must restore the full delta and retain unload protection.
+ '@tanstack/history@1.162.0': 1.162.4
'monaco-editor': 'catalog:'
'@mapbox/node-pre-gyp>tar': ^7.5.21
'@sentry/webpack-plugin>uuid': ^11.1.1