From 7ea4a1f0b2f7b16330621f9dc4fe98a22f687e1f Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:27:40 +1000 Subject: [PATCH 1/3] first commit --- apps/studio/compat/next/_router-events.ts | 7 +- .../EmailTemplates/TemplateEditor.test.tsx | 9 ++ ...ePreventNavigationOnUnsavedChanges.test.ts | 87 +++++++++++++++++++ .../usePreventNavigationOnUnsavedChanges.ts | 80 +++++++---------- 4 files changed, 130 insertions(+), 53 deletions(-) create mode 100644 apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.test.ts diff --git a/apps/studio/compat/next/_router-events.ts b/apps/studio/compat/next/_router-events.ts index 87c9d3ca83d..a42d8f84aa2 100644 --- a/apps/studio/compat/next/_router-events.ts +++ b/apps/studio/compat/next/_router-events.ts @@ -14,11 +14,8 @@ // { fromLocation, toLocation, pathChanged, hrefChanged, hashChanged } // — we forward `toLocation.href` as the URL arg. // -// 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. +// Next's `routeChangeStart` lets handlers throw to cancel navigation. TanStack's +// `subscribe` is fire-and-forget, so cancellation uses `useBlocker` instead. // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyRouter = any diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx index eff4d0e4d77..94758611b22 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx @@ -54,6 +54,15 @@ vi.mock('@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges', () PreventNavigationOnUnsavedChanges: () => null, })) +vi.mock('@/hooks/ui/usePreventNavigationOnUnsavedChanges', () => ({ + usePreventNavigationOnUnsavedChanges: () => ({ + handleCancelNavigation: vi.fn(), + handleConfirmNavigation: vi.fn(), + bypassNavigationGuard: vi.fn(), + shouldConfirmNavigation: false, + }), +})) + vi.mock('@/data/auth/auth-config-query', () => ({ useAuthConfigQuery: useAuthConfigQueryMock, })) diff --git a/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.test.ts b/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.test.ts new file mode 100644 index 00000000000..b444c2d1eba --- /dev/null +++ b/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.test.ts @@ -0,0 +1,87 @@ +import { act, renderHook } from '@testing-library/react' +import { useBlocker } from '@tanstack/react-router' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { usePreventNavigationOnUnsavedChanges } from './usePreventNavigationOnUnsavedChanges' + +vi.mock('@tanstack/react-router', () => ({ + useBlocker: vi.fn(), +})) + +const mockUseBlocker = vi.mocked(useBlocker) +const proceed = vi.fn() +const reset = vi.fn() + +const setBlockerStatus = (status: 'blocked' | 'unblocked') => { + mockUseBlocker.mockReturnValue({ + status, + proceed, + reset, + } as unknown as ReturnType) +} + +const getLastBlockerOptions = () => + mockUseBlocker.mock.lastCall?.[0] as unknown as { + shouldBlockFn: () => boolean + withResolver: boolean + enableBeforeUnload: boolean + disabled: boolean + } + +describe('usePreventNavigationOnUnsavedChanges', () => { + beforeEach(() => { + vi.clearAllMocks() + setBlockerStatus('unblocked') + }) + + it('guards in-app navigation and tab close while there are changes', () => { + renderHook(() => usePreventNavigationOnUnsavedChanges({ hasChanges: true })) + + expect(mockUseBlocker).toHaveBeenLastCalledWith( + expect.objectContaining({ + withResolver: true, + enableBeforeUnload: true, + disabled: false, + }) + ) + expect(getLastBlockerOptions().shouldBlockFn()).toBe(true) + }) + + it('reports and cancels a blocked navigation', () => { + setBlockerStatus('blocked') + const { result } = renderHook(() => + usePreventNavigationOnUnsavedChanges({ hasChanges: true }) + ) + + expect(result.current.shouldConfirmNavigation).toBe(true) + act(() => result.current.handleCancelNavigation()) + expect(reset).toHaveBeenCalledOnce() + }) + + it('proceeds after the user confirms navigation', () => { + setBlockerStatus('blocked') + const { result } = renderHook(() => + usePreventNavigationOnUnsavedChanges({ hasChanges: true }) + ) + + act(() => result.current.handleConfirmNavigation()) + + expect(proceed).toHaveBeenCalledOnce() + expect(mockUseBlocker).toHaveBeenLastCalledWith( + expect.objectContaining({ enableBeforeUnload: false, disabled: true }) + ) + }) + + it('allows an intentional navigation to bypass the guard', () => { + const { result } = renderHook(() => + usePreventNavigationOnUnsavedChanges({ hasChanges: true }) + ) + + act(() => result.current.bypassNavigationGuard()) + + expect(proceed).not.toHaveBeenCalled() + expect(mockUseBlocker).toHaveBeenLastCalledWith( + expect.objectContaining({ enableBeforeUnload: false, disabled: true }) + ) + }) +}) diff --git a/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.ts b/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.ts index bc8cf663fa5..3ba223477ef 100644 --- a/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.ts +++ b/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.ts @@ -1,7 +1,6 @@ -import { useRouter } from 'next/router' -import { useCallback, useEffect, useMemo, useState } from 'react' - -import { BASE_PATH } from '@/lib/constants' +import { useBlocker } from '@tanstack/react-router' +import { useCallback, useMemo, useState } from 'react' +import { flushSync } from 'react-dom' interface UsePreventNavigationOnUnsavedChangesOptions { /* @@ -17,9 +16,13 @@ interface UsePreventNavigationOnUnsavedChangesReturn { */ handleCancelNavigation: () => void /* - * Confirm the navigation and lose the changes + * Confirm the blocked navigation and lose the changes */ handleConfirmNavigation: () => void + /* + * Skip the guard before an intentional navigation that has already been confirmed + */ + bypassNavigationGuard: () => void /* * Boolean indicating whether UI to request users confirmation for the navigation should be * displayed @@ -28,63 +31,44 @@ interface UsePreventNavigationOnUnsavedChangesReturn { } /* - * Hook that prevents navigation when users could lose their changes. - * It prevents both NextJS and browser navigation (such as when closing the tab) + * Prevents in-app navigation and tab close when users could lose changes. + * + * Studio's `next/router` is a TanStack shim, so throwing from `routeChangeStart` cannot cancel + * navigation. TanStack's `useBlocker` handles both the block and its eventual resolution. */ export const usePreventNavigationOnUnsavedChanges = ({ hasChanges, }: UsePreventNavigationOnUnsavedChangesOptions): UsePreventNavigationOnUnsavedChangesReturn => { - const router = useRouter() - const [navigateUrl, setNavigateUrl] = useState() - const [confirmNavigate, setConfirmNavigate] = useState(false) + const [allowNavigation, setAllowNavigation] = useState(false) + const shouldGuard = hasChanges && !allowNavigation - useEffect(() => { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (hasChanges) { - e.preventDefault() - e.returnValue = '' // deprecated, but older browsers still require this - } - } - - const handleBrowseAway = (url: string) => { - if (hasChanges && !confirmNavigate) { - setNavigateUrl(url) - throw 'Route change declined' // Just to prevent the route change - return - } - setNavigateUrl(undefined) - } - window.addEventListener('beforeunload', handleBeforeUnload) - router.events.on('routeChangeStart', handleBrowseAway) - - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload) - router.events.off('routeChangeStart', handleBrowseAway) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [confirmNavigate, hasChanges]) + const blocker = useBlocker({ + shouldBlockFn: () => shouldGuard, + withResolver: true, + enableBeforeUnload: shouldGuard, + disabled: !shouldGuard, + }) const handleCancelNavigation = useCallback(() => { - setNavigateUrl(undefined) - }, []) + blocker.reset?.() + }, [blocker]) const handleConfirmNavigation = useCallback(() => { - setConfirmNavigate(true) - let urlToNavigate = navigateUrl ?? '/' - if (BASE_PATH && urlToNavigate.startsWith(BASE_PATH)) { - urlToNavigate = urlToNavigate.slice(BASE_PATH.length) || '/' - } - if (!urlToNavigate.startsWith('/')) urlToNavigate = `/${urlToNavigate}` - setNavigateUrl(undefined) - router.push(urlToNavigate) - }, [navigateUrl, router]) + flushSync(() => setAllowNavigation(true)) + blocker.proceed?.() + }, [blocker]) + + const bypassNavigationGuard = useCallback(() => { + flushSync(() => setAllowNavigation(true)) + }, []) return useMemo( () => ({ handleCancelNavigation, handleConfirmNavigation, - shouldConfirmNavigation: !!navigateUrl, + bypassNavigationGuard, + shouldConfirmNavigation: blocker.status === 'blocked', }), - [navigateUrl, handleCancelNavigation, handleConfirmNavigation] + [blocker.status, handleCancelNavigation, handleConfirmNavigation, bypassNavigationGuard] ) } From 06e6a6035a259f07834c92c7c20310e3f69ee240 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:26:39 +1000 Subject: [PATCH 2/3] mark navigation blocker migration complete --- apps/studio/TANSTACK_MIGRATION.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index fb8ba8cc114..0adc8b813fe 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -453,10 +453,9 @@ inline. `router.subscribe(tsEvent, …)`. Forwards Next's `(url, { shallow })` args. Maps `routeChangeStart` / `routeChangeComplete` / `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. + **Cancellation:** Next's throw-from-`routeChangeStart`-to-cancel pattern + isn't supportable because `subscribe` is fire-and-forget. + `usePreventNavigationOnUnsavedChanges` uses TanStack's `useBlocker` instead. - `api.ts` — `toWebHandler(nextHandler)`. See **API routes → Shim coverage** above. - `link.tsx`, `navigation.ts`, `dynamic.tsx`, `image.tsx`, @@ -602,7 +601,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`. +- [x] Migrate `usePreventNavigationOnUnsavedChanges` from `router.events.on('routeChangeStart', …)` (throw-to-cancel pattern) to TanStack's `useBlocker`. - 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`. From 5a0d5a6d901f089332630bd301b713a26ae94425 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:39:10 +1000 Subject: [PATCH 3/3] support unsaved guards in both routers --- apps/studio/TANSTACK_MIGRATION.md | 4 +- apps/studio/compat/next/_router-events.ts | 3 +- .../EmailTemplates/TemplateEditor.test.tsx | 9 -- ...ePreventNavigationOnUnsavedChanges.test.ts | 145 +++++++++++------- .../usePreventNavigationOnUnsavedChanges.ts | 138 ++++++++++++----- 5 files changed, 193 insertions(+), 106 deletions(-) diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index f982064b36c..4346336461d 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -456,7 +456,7 @@ inline. `beforeHistoryChange` / `hashChangeStart` / `hashChangeComplete`. **Cancellation:** Next's throw-from-`routeChangeStart`-to-cancel pattern isn't supportable because `subscribe` is fire-and-forget. - `usePreventNavigationOnUnsavedChanges` uses TanStack's `useBlocker` instead. + `usePreventNavigationOnUnsavedChanges` blocks through TanStack history instead. - `api.ts` — `toWebHandler(nextHandler)`. See **API routes → Shim coverage** above. - `link.tsx`, `navigation.ts`, `dynamic.tsx`, `image.tsx`, @@ -602,7 +602,7 @@ for the Vite pipeline: - Switch `routes/index.tsx` redirects from `href` to `to` — all targets now live in the TanStack tree. -- [x] Migrate `usePreventNavigationOnUnsavedChanges` from `router.events.on('routeChangeStart', …)` (throw-to-cancel pattern) to TanStack's `useBlocker`. +- [x] Make `usePreventNavigationOnUnsavedChanges` block through TanStack history while retaining the Next implementation for the legacy runtime. - 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 a42d8f84aa2..71414397e97 100644 --- a/apps/studio/compat/next/_router-events.ts +++ b/apps/studio/compat/next/_router-events.ts @@ -15,7 +15,8 @@ // — we forward `toLocation.href` as the URL arg. // // Next's `routeChangeStart` lets handlers throw to cancel navigation. TanStack's -// `subscribe` is fire-and-forget, so cancellation uses `useBlocker` instead. +// `subscribe` is fire-and-forget, so the shared unsaved-changes hook blocks through +// TanStack history before navigation begins instead. // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyRouter = any diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx index 94758611b22..eff4d0e4d77 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx @@ -54,15 +54,6 @@ vi.mock('@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges', () PreventNavigationOnUnsavedChanges: () => null, })) -vi.mock('@/hooks/ui/usePreventNavigationOnUnsavedChanges', () => ({ - usePreventNavigationOnUnsavedChanges: () => ({ - handleCancelNavigation: vi.fn(), - handleConfirmNavigation: vi.fn(), - bypassNavigationGuard: vi.fn(), - shouldConfirmNavigation: false, - }), -})) - vi.mock('@/data/auth/auth-config-query', () => ({ useAuthConfigQuery: useAuthConfigQueryMock, })) diff --git a/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.test.ts b/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.test.ts index b444c2d1eba..6c5c4114382 100644 --- a/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.test.ts +++ b/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.test.ts @@ -1,87 +1,120 @@ -import { act, renderHook } from '@testing-library/react' -import { useBlocker } from '@tanstack/react-router' +import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { usePreventNavigationOnUnsavedChanges } from './usePreventNavigationOnUnsavedChanges' -vi.mock('@tanstack/react-router', () => ({ - useBlocker: vi.fn(), +const mocks = vi.hoisted(() => ({ + tanStackRouter: undefined as undefined | { history: { block: ReturnType } }, + nextEvents: { on: vi.fn(), off: vi.fn() }, + nextPush: vi.fn(), })) -const mockUseBlocker = vi.mocked(useBlocker) -const proceed = vi.fn() -const reset = vi.fn() +vi.mock('@tanstack/react-router', () => ({ + useRouter: () => mocks.tanStackRouter, +})) -const setBlockerStatus = (status: 'blocked' | 'unblocked') => { - mockUseBlocker.mockReturnValue({ - status, - proceed, - reset, - } as unknown as ReturnType) -} - -const getLastBlockerOptions = () => - mockUseBlocker.mock.lastCall?.[0] as unknown as { - shouldBlockFn: () => boolean - withResolver: boolean - enableBeforeUnload: boolean - disabled: boolean - } +vi.mock('next/router', () => ({ + useRouter: () => ({ events: mocks.nextEvents, push: mocks.nextPush }), +})) describe('usePreventNavigationOnUnsavedChanges', () => { beforeEach(() => { vi.clearAllMocks() - setBlockerStatus('unblocked') + mocks.tanStackRouter = undefined }) - it('guards in-app navigation and tab close while there are changes', () => { + it('retains the legacy Next navigation guard', () => { renderHook(() => usePreventNavigationOnUnsavedChanges({ hasChanges: true })) + const routeChangeHandler = mocks.nextEvents.on.mock.calls.find( + ([event]) => event === 'routeChangeStart' + )?.[1] - expect(mockUseBlocker).toHaveBeenLastCalledWith( - expect.objectContaining({ - withResolver: true, - enableBeforeUnload: true, - disabled: false, - }) - ) - expect(getLastBlockerOptions().shouldBlockFn()).toBe(true) + expect(() => act(() => routeChangeHandler('/settings'))).toThrow('Route change declined') }) - it('reports and cancels a blocked navigation', () => { - setBlockerStatus('blocked') - const { result } = renderHook(() => - usePreventNavigationOnUnsavedChanges({ hasChanges: true }) - ) + it('blocks TanStack navigation before the route changes and can cancel it', async () => { + let blockerFn: (() => Promise) | undefined + const unblock = vi.fn() + mocks.tanStackRouter = { + history: { + block: vi.fn(({ blockerFn: nextBlockerFn }) => { + blockerFn = nextBlockerFn + return unblock + }), + }, + } - expect(result.current.shouldConfirmNavigation).toBe(true) + const { result } = renderHook(() => usePreventNavigationOnUnsavedChanges({ hasChanges: true })) + let navigationResult: Promise + act(() => { + navigationResult = blockerFn!() + }) + + await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true)) act(() => result.current.handleCancelNavigation()) - expect(reset).toHaveBeenCalledOnce() + + let resolvedNavigation: boolean | undefined + await act(async () => { + resolvedNavigation = await navigationResult! + }) + expect(resolvedNavigation).toBe(true) + await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(false)) + expect(mocks.nextEvents.on).not.toHaveBeenCalled() }) - it('proceeds after the user confirms navigation', () => { - setBlockerStatus('blocked') - const { result } = renderHook(() => - usePreventNavigationOnUnsavedChanges({ hasChanges: true }) - ) + it('allows confirmed TanStack navigation to proceed', async () => { + let blockerFn: (() => Promise) | undefined + mocks.tanStackRouter = { + history: { + block: vi.fn(({ blockerFn: nextBlockerFn }) => { + blockerFn = nextBlockerFn + return vi.fn() + }), + }, + } + const { result } = renderHook(() => usePreventNavigationOnUnsavedChanges({ hasChanges: true })) + let navigationResult: Promise + act(() => { + navigationResult = blockerFn!() + }) + + await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true)) act(() => result.current.handleConfirmNavigation()) - expect(proceed).toHaveBeenCalledOnce() - expect(mockUseBlocker).toHaveBeenLastCalledWith( - expect.objectContaining({ enableBeforeUnload: false, disabled: true }) - ) + let resolvedNavigation: boolean | undefined + await act(async () => { + resolvedNavigation = await navigationResult! + }) + expect(resolvedNavigation).toBe(false) }) - it('allows an intentional navigation to bypass the guard', () => { - const { result } = renderHook(() => - usePreventNavigationOnUnsavedChanges({ hasChanges: true }) - ) + it('bypasses only the next intentional TanStack navigation', async () => { + let blockerFn: (() => Promise) | undefined + mocks.tanStackRouter = { + history: { + block: vi.fn(({ blockerFn: nextBlockerFn }) => { + blockerFn = nextBlockerFn + return vi.fn() + }), + }, + } + const { result } = renderHook(() => usePreventNavigationOnUnsavedChanges({ hasChanges: true })) act(() => result.current.bypassNavigationGuard()) - expect(proceed).not.toHaveBeenCalled() - expect(mockUseBlocker).toHaveBeenLastCalledWith( - expect.objectContaining({ enableBeforeUnload: false, disabled: true }) - ) + await expect(blockerFn!()).resolves.toBe(false) + + let secondNavigation: Promise + act(() => { + secondNavigation = blockerFn!() + }) + await waitFor(() => expect(result.current.shouldConfirmNavigation).toBe(true)) + act(() => result.current.handleCancelNavigation()) + let resolvedNavigation: boolean | undefined + await act(async () => { + resolvedNavigation = await secondNavigation! + }) + expect(resolvedNavigation).toBe(true) }) }) diff --git a/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.ts b/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.ts index 3ba223477ef..c38ef6e5d74 100644 --- a/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.ts +++ b/apps/studio/hooks/ui/usePreventNavigationOnUnsavedChanges.ts @@ -1,65 +1,121 @@ -import { useBlocker } from '@tanstack/react-router' -import { useCallback, useMemo, useState } from 'react' -import { flushSync } from 'react-dom' +import { useRouter as useTanStackRouter } from '@tanstack/react-router' +import { useRouter as useNextRouter } from 'next/router' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { BASE_PATH } from '@/lib/constants' interface UsePreventNavigationOnUnsavedChangesOptions { - /* - * Boolean indicating whether there are changes that would be lost if users navigate to another - * page or close the browser tab - */ hasChanges: boolean } interface UsePreventNavigationOnUnsavedChangesReturn { - /* - * Cancel the navigation and keep the changes - */ handleCancelNavigation: () => void - /* - * Confirm the blocked navigation and lose the changes - */ handleConfirmNavigation: () => void - /* - * Skip the guard before an intentional navigation that has already been confirmed - */ bypassNavigationGuard: () => void - /* - * Boolean indicating whether UI to request users confirmation for the navigation should be - * displayed - */ shouldConfirmNavigation: boolean } +interface PendingTanStackNavigation { + proceed: () => void + reset: () => void +} + /* * Prevents in-app navigation and tab close when users could lose changes. * - * Studio's `next/router` is a TanStack shim, so throwing from `routeChangeStart` cannot cancel - * navigation. TanStack's `useBlocker` handles both the block and its eventual resolution. + * Studio ships Next and TanStack Router side by side. TanStack navigation must be blocked through + * its history API before the route starts rendering; Next continues to use routeChangeStart. */ export const usePreventNavigationOnUnsavedChanges = ({ hasChanges, }: UsePreventNavigationOnUnsavedChangesOptions): UsePreventNavigationOnUnsavedChangesReturn => { - const [allowNavigation, setAllowNavigation] = useState(false) - const shouldGuard = hasChanges && !allowNavigation + const nextRouter = useNextRouter() + const tanStackRouter = useTanStackRouter({ warn: false }) + const [navigateUrl, setNavigateUrl] = useState() + const [confirmNavigate, setConfirmNavigate] = useState(false) + const [pendingTanStackNavigation, setPendingTanStackNavigation] = + useState() + const bypassNavigationGuardRef = useRef(false) - const blocker = useBlocker({ - shouldBlockFn: () => shouldGuard, - withResolver: true, - enableBeforeUnload: shouldGuard, - disabled: !shouldGuard, - }) + useEffect(() => { + if (!tanStackRouter || !hasChanges) return + + return tanStackRouter.history.block({ + enableBeforeUnload: true, + blockerFn: async () => { + if (bypassNavigationGuardRef.current) { + bypassNavigationGuardRef.current = false + return false + } + + const shouldCancelNavigation = await new Promise((resolve) => { + setPendingTanStackNavigation({ + proceed: () => resolve(false), + reset: () => resolve(true), + }) + }) + setPendingTanStackNavigation(undefined) + return shouldCancelNavigation + }, + }) + }, [hasChanges, tanStackRouter]) + + useEffect(() => { + if (tanStackRouter) return + + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (hasChanges) { + event.preventDefault() + event.returnValue = '' + } + } + + const handleBrowseAway = (url: string) => { + if (bypassNavigationGuardRef.current) { + bypassNavigationGuardRef.current = false + setNavigateUrl(undefined) + return + } + + if (hasChanges && !confirmNavigate) { + setNavigateUrl(url) + throw 'Route change declined' + } + setNavigateUrl(undefined) + } + + window.addEventListener('beforeunload', handleBeforeUnload) + nextRouter.events.on('routeChangeStart', handleBrowseAway) + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + nextRouter.events.off('routeChangeStart', handleBrowseAway) + } + }, [confirmNavigate, hasChanges, nextRouter.events, tanStackRouter]) const handleCancelNavigation = useCallback(() => { - blocker.reset?.() - }, [blocker]) + pendingTanStackNavigation?.reset() + setNavigateUrl(undefined) + }, [pendingTanStackNavigation]) const handleConfirmNavigation = useCallback(() => { - flushSync(() => setAllowNavigation(true)) - blocker.proceed?.() - }, [blocker]) + if (pendingTanStackNavigation) { + pendingTanStackNavigation.proceed() + return + } + + setConfirmNavigate(true) + let urlToNavigate = navigateUrl ?? '/' + if (BASE_PATH && urlToNavigate.startsWith(BASE_PATH)) { + urlToNavigate = urlToNavigate.slice(BASE_PATH.length) || '/' + } + if (!urlToNavigate.startsWith('/')) urlToNavigate = `/${urlToNavigate}` + setNavigateUrl(undefined) + nextRouter.push(urlToNavigate) + }, [navigateUrl, nextRouter, pendingTanStackNavigation]) const bypassNavigationGuard = useCallback(() => { - flushSync(() => setAllowNavigation(true)) + bypassNavigationGuardRef.current = true }, []) return useMemo( @@ -67,8 +123,14 @@ export const usePreventNavigationOnUnsavedChanges = ({ handleCancelNavigation, handleConfirmNavigation, bypassNavigationGuard, - shouldConfirmNavigation: blocker.status === 'blocked', + shouldConfirmNavigation: pendingTanStackNavigation !== undefined || navigateUrl !== undefined, }), - [blocker.status, handleCancelNavigation, handleConfirmNavigation, bypassNavigationGuard] + [ + handleCancelNavigation, + handleConfirmNavigation, + bypassNavigationGuard, + navigateUrl, + pendingTanStackNavigation, + ] ) }