fix(studio): guard unsaved edits in TanStack

This commit is contained in:
Alaister Young committed 2026-09-29 16:07:05 -07:00
1 parent 07c75e84fc
commit bb082e5429
8 files changed
+411 -13

No files matched your search

+4 -3
View File
@@ -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`.
+2 -3
View File
@@ -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
@@ -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 }) => (
<RouterContextProvider router={router}>{children}</RouterContextProvider>
),
}
)
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<AnyRouter, string>({ 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<AnyRouter, string>({ 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<AnyRouter, string>({ 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<AnyRouter, string>({ 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)
})
})
@@ -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',
}
}
@@ -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)
})
})
+6
View File
@@ -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
+8 -7
View File
@@ -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
+2
View File
@@ -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