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