diff --git a/.changeset/native-tanstack-navigation.md b/.changeset/native-tanstack-navigation.md new file mode 100644 index 0000000..8f6456c --- /dev/null +++ b/.changeset/native-tanstack-navigation.md @@ -0,0 +1,13 @@ +--- +"@okyrychenko-dev/react-action-guard-router": minor +--- + +Restore TanStack navigation blocking using native useBlocker. Confirmed transitions keep protection armed, failed confirmations deny navigation, and superseded or unmounted attempts cannot allow navigation. Require TanStack Router >=1.170.41 within v1, the verified compatibility floor, and use native unload protection with a one-shot bypass after accepted document navigation, avoiding a duplicate browser prompt. + +Invalidate pending confirmations when when or scope changes, including replacements that leave the computed blocking state active. + +Preserve pending confirmations when observer callbacks onBlock or onAllow change during a rerender. + +Compare scopes by normalized contents so equivalent arrays, including reordered or duplicated entries, preserve pending confirmations while effective scope changes invalidate them. + +Document and test the native not-found limitation at TanStack Router 1.170.41: unmatched-to-matched navigation skips adapter callbacks even with an active condition or scope; subsequent matched-route navigation remains guarded. diff --git a/packages/core/src/hooks/useConfirmableBlocker/__tests__/useConfirmableBlocker.test.tsx b/packages/core/src/hooks/useConfirmableBlocker/__tests__/useConfirmableBlocker.test.tsx index e9c222e..58c93e2 100644 --- a/packages/core/src/hooks/useConfirmableBlocker/__tests__/useConfirmableBlocker.test.tsx +++ b/packages/core/src/hooks/useConfirmableBlocker/__tests__/useConfirmableBlocker.test.tsx @@ -1,8 +1,8 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useConfirmableBlocker } from ".."; import { uiBlockingStoreApi } from "../../../store"; import { actAsync } from "../../__tests__/test.utils"; +import { useConfirmableBlocker } from "../../useConfirmableBlocker"; import { useIsBlocked } from "../../useIsBlocked"; describe("useConfirmableBlocker", () => { @@ -31,6 +31,7 @@ describe("useConfirmableBlocker", () => { act(() => { result.current.confirmation.execute(); }); + await act(async () => { outcomes.push(result.current.confirmation.onConfirm()); outcomes.push(result.current.confirmation.onConfirm()); @@ -91,6 +92,7 @@ describe("useConfirmableBlocker", () => { finish(); await Promise.all(outcomes); }); + expect(result.current.blocked).toBe(false); }); @@ -121,6 +123,7 @@ describe("useConfirmableBlocker", () => { { status: "rejected", reason: failure }, ]); }); + expect(onConfirm).toHaveBeenCalledTimes(1); expect(result.current.blocked).toBe(false); expect(result.current.confirmation.isExecuting).toBe(false); @@ -212,6 +215,7 @@ describe("useConfirmableBlocker", () => { await act(async () => { await expect(result.current.onConfirm()).rejects.toBe(failure); }); + expect(onConfirm).toHaveBeenCalledTimes(2); }); @@ -367,6 +371,7 @@ describe("useConfirmableBlocker", () => { act(() => { result.current.execute(); }); + expect(result.current.isDialogOpen).toBe(true); await actAsync(async () => { @@ -390,6 +395,7 @@ describe("useConfirmableBlocker", () => { act(() => { result.current.execute(); }); + expect(result.current.isDialogOpen).toBe(true); act(() => { diff --git a/packages/router/README.md b/packages/router/README.md index d60917d..5229c95 100644 --- a/packages/router/README.md +++ b/packages/router/README.md @@ -34,7 +34,7 @@ This package requires the following peer dependencies: - [React](https://react.dev/) ^18.0.0 || ^19.0.0 - One of: - [react-router-dom](https://reactrouter.com/) ^6.0.0 - For React Router or Remix - - [@tanstack/react-router](https://tanstack.com/router) ^1.0.0 - For TanStack Router + - [@tanstack/react-router](https://tanstack.com/router) ^1.170.41 - For TanStack Router - [next](https://nextjs.org/) ^13.4.0 - For Next.js Pages Router and best-effort App Router support - [Zustand](https://zustand-demo.pmnd.rs/) - State management (peer dependency of react-action-guard) @@ -280,7 +280,7 @@ function MyComponent() { ### TanStack Router -Full support with TanStack Router's history blocking. +Uses TanStack Router's native `useBlocker` for in-app transitions. Requires TanStack Router 1.170.41 or newer; earlier versions are no longer declared supported. ```tsx import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/tanstack-router"; @@ -293,7 +293,13 @@ function MyComponent() { } ``` -Async `onConfirm` is supported and evaluated once per blocked navigation attempt. +Sync and async `onConfirm` are evaluated once per blocked navigation attempt. Active blocking without a message denies silently and emits `onBlock`. Denial, rejection, and thrown confirmation errors retain the current location. Acceptance permits that transition once and emits `onAllow`; subsequent transitions remain protected. Superseded confirmations and results received after unmount or authorization option changes cannot authorize navigation. Replacing `when` or changing the effective `scope` contents invalidates a pending confirmation even when the computed blocking condition remains true. Equivalent scope arrays preserve the attempt: array identity, order, and duplicates do not affect scope comparison. Changes to observer callbacks `onBlock` and `onAllow` do not invalidate a pending confirmation; the attempt retains the callbacks it started with. + +`scope` and `when` retain their existing OR behavior. `blockBrowserUnload` controls native TanStack unload protection. Its one-shot bypass suppresses a second browser prompt after accepted document navigation; later unloads remain protected. The adapter does not register a separate shared unload handler. Unload uses a browser-controlled prompt, not asynchronous custom confirmation. + +**Not-found limitation:** In the verified TanStack Router 1.170.41, navigation from an unmatched URL (`__notFound__`) to a matched route bypasses native `useBlocker` before this adapter's callback runs, even with `when: true` or an active scope. `onBlock`, `onConfirm`, and `onAllow` are not called for that transition. A blocker mounted above the not-found UI therefore cannot protect this exit; do not rely on it to guard unsaved work there. Subsequent matched-to-matched navigation remains guarded. This is upstream behavior introduced by [TanStack router #4917](https://github.com/TanStack/router/pull/4917), covered by real-router tests for both condition sources. The adapter does not compensate for the bypass. + +Compatibility tests exercise the public hook at 1.170.41 with real memory history for in-app navigation and browser history in the DOM test environment for unload protection. The tests intercept document location assignment and dispatch beforeunload events; they do not establish real-browser back/forward or prompt UI behavior. The verified floor was raised from 1.170.28 because the older locked router-core/history combination skipped blockers for external navigation. The native API is documented in [TanStack navigation blocking](https://tanstack.com/router/latest/docs/guide/navigation-blocking). ### Next.js Pages Router @@ -350,7 +356,7 @@ For full navigation blocking support, use Pages Router. | Adapter | `isBlocking` meaning | `isIntercepting` | Async `onConfirm` | Caveats | | -------------------- | --------------------------- | ---------------- | ----------------- | -------------------------------------------------------- | | React Router | Blocking condition is armed | Yes | Yes | Best semantic fidelity | -| TanStack Router | Blocking condition is armed | No | Yes | Depends on history.block integration | +| TanStack Router | Blocking condition is armed | No | Yes | Native useBlocker; memory-history transitions verified | | Next.js Pages Router | Blocking condition is armed | No | Yes | Re-attempts confirmed navigation with `router.push(url)` | | Next.js App Router | Blocking condition is armed | No | Best effort only | No official blocker API from Next.js | diff --git a/packages/router/package.json b/packages/router/package.json index cebe4ce..43a6efc 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -82,7 +82,7 @@ }, "peerDependencies": { "@okyrychenko-dev/react-action-guard": "^1.0.4", - "@tanstack/react-router": "^1.0.0", + "@tanstack/react-router": "^1.170.41", "next": "^13.4.0 || ^14.0.0 || ^15.0.0", "react": "^18.0.0 || ^19.0.0", "react-router-dom": "^6.0.0 || ^7.0.0" @@ -103,7 +103,7 @@ "@storybook/addon-a11y": "^10.3.6", "@storybook/addon-docs": "^10.3.6", "@storybook/react-vite": "^10.3.6", - "@tanstack/react-router": "^1.94.4", + "@tanstack/react-router": "^1.170.41", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", diff --git a/packages/router/src/core/confirmationOwner.types.ts b/packages/router/src/core/confirmationOwner.types.ts new file mode 100644 index 0000000..3a5ba2c --- /dev/null +++ b/packages/router/src/core/confirmationOwner.types.ts @@ -0,0 +1,4 @@ +export interface ConfirmationOwner { + begin: () => () => boolean; + invalidate: VoidFunction; +} diff --git a/packages/router/src/core/confirmationOwner.utils.ts b/packages/router/src/core/confirmationOwner.utils.ts new file mode 100644 index 0000000..f4c7b22 --- /dev/null +++ b/packages/router/src/core/confirmationOwner.utils.ts @@ -0,0 +1,26 @@ +import type { ConfirmationOwner } from "./confirmationOwner.types"; + +export function createConfirmationOwner(): ConfirmationOwner { + let currentAttempt = 0; + + function invalidate(): void { + currentAttempt += 1; + } + + function begin(): () => boolean { + const attempt = ++currentAttempt; + + function settle(): boolean { + if (attempt !== currentAttempt) { + return false; + } + invalidate(); + + return true; + } + + return settle; + } + + return { begin, invalidate }; +} diff --git a/packages/router/src/core/index.ts b/packages/router/src/core/index.ts index 6f76e92..532c760 100644 --- a/packages/router/src/core/index.ts +++ b/packages/router/src/core/index.ts @@ -27,3 +27,4 @@ export type { NavigationBlockerReturn, } from "./types"; export type { DialogState, UseDialogStateReturn } from "./useDialogState"; +export { createConfirmationOwner } from "./confirmationOwner.utils"; diff --git a/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.ts b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.ts deleted file mode 100644 index 3198e9e..0000000 --- a/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { useRouter } from "@tanstack/react-router"; -import { renderHook, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DEFAULT_UNLOAD_MESSAGE, useBeforeUnload, useShouldBlock } from "../../core"; -import { useNavigationBlocker } from "../useNavigationBlocker"; -import type { SafeTanStackRouter } from "../types"; - -type RouterMock = SafeTanStackRouter | { history: null }; - -declare module "@tanstack/react-router" { - export function useRouter(): RouterMock; -} - -// Mock dependencies -vi.mock("@tanstack/react-router", () => ({ - useRouter: vi.fn(), -})); - -vi.mock("../../core", async (importOriginal) => { - const actual = await importOriginal(); - - return { - ...actual, - useShouldBlock: vi.fn(), - useBeforeUnload: vi.fn(), - }; -}); - -const mockUseRouter = vi.mocked(useRouter); -const mockUseShouldBlock = vi.mocked(useShouldBlock); -const mockUseBeforeUnload = vi.mocked(useBeforeUnload); - -describe("useNavigationBlocker (TanStack Router)", () => { - let mockUnblock: () => void; - let mockBlock: ReturnType>; - - beforeEach(() => { - vi.clearAllMocks(); - mockUseShouldBlock.mockReturnValue(false); - mockUnblock = vi.fn(); - mockBlock = vi.fn(() => mockUnblock); - - const mockRouter: SafeTanStackRouter = { - history: { - block: mockBlock, - }, - }; - - mockUseRouter.mockReturnValue(mockRouter); - }); - - describe("Basic functionality", () => { - it("should integrate with useShouldBlock", () => { - renderHook(() => - useNavigationBlocker({ - when: true, - message: "Test message", - }) - ); - - expect(mockUseShouldBlock).toHaveBeenCalledWith(true, undefined); - }); - - it("should pass scope to useShouldBlock", () => { - renderHook(() => - useNavigationBlocker({ - scope: "test-scope", - }) - ); - - expect(mockUseShouldBlock).toHaveBeenCalledWith(undefined, "test-scope"); - }); - - it("should call useBeforeUnload when blockBrowserUnload is true", () => { - mockUseShouldBlock.mockReturnValue(true); - - renderHook(() => - useNavigationBlocker({ - when: true, - blockBrowserUnload: true, - message: "Test message", - }) - ); - - expect(mockUseBeforeUnload).toHaveBeenCalledWith(true, "Test message"); - }); - }); - - describe("TanStack Router history blocking", () => { - it("should call router.history.block when shouldBlock is true", () => { - mockUseShouldBlock.mockReturnValue(true); - - renderHook(() => - useNavigationBlocker({ - when: true, - }) - ); - - expect(mockBlock).toHaveBeenCalled(); - }); - - it("should not call router.history.block when shouldBlock is false", () => { - mockUseShouldBlock.mockReturnValue(false); - - renderHook(() => - useNavigationBlocker({ - when: false, - }) - ); - - expect(mockBlock).not.toHaveBeenCalled(); - }); - - it("should cleanup blocker on unmount", () => { - mockUseShouldBlock.mockReturnValue(true); - - const { unmount } = renderHook(() => - useNavigationBlocker({ - when: true, - }) - ); - - unmount(); - - expect(mockUnblock).toHaveBeenCalled(); - }); - - it("should cleanup and re-block when shouldBlock changes", () => { - mockUseShouldBlock.mockReturnValue(true); - - const { rerender } = renderHook(() => - useNavigationBlocker({ - when: true, - }) - ); - - const firstUnblock = mockUnblock; - - // Change condition - mockUseShouldBlock.mockReturnValue(false); - rerender(); - - expect(firstUnblock).toHaveBeenCalled(); - - // Change back - mockUseShouldBlock.mockReturnValue(true); - rerender(); - - expect(mockBlock).toHaveBeenCalledTimes(2); - }); - }); - - describe("Blocking behavior", () => { - it("should return isBlocking: false when not blocking", () => { - mockUseShouldBlock.mockReturnValue(false); - - const { result } = renderHook(() => - useNavigationBlocker({ - when: false, - }) - ); - - expect(result.current.isBlocking).toBe(false); - }); - - it("should return isBlocking: true when blocking", () => { - mockUseShouldBlock.mockReturnValue(true); - - const { result } = renderHook(() => - useNavigationBlocker({ - when: true, - }) - ); - - expect(result.current.isBlocking).toBe(true); - }); - }); - - describe("Callbacks", () => { - it("should pass blocker function to router.history.block", () => { - mockUseShouldBlock.mockReturnValue(true); - - renderHook(() => - useNavigationBlocker({ - when: true, - message: "Test", - }) - ); - - expect(mockBlock).toHaveBeenCalledWith(expect.any(Function)); - }); - - it("should call async onConfirm only once", async () => { - mockUseShouldBlock.mockReturnValue(true); - - const retry = vi.fn(); - - let resolveConfirm: (value: boolean) => void = () => undefined; - const confirmPromise = new Promise((resolve) => { - resolveConfirm = resolve; - }); - const onConfirm = vi.fn(() => confirmPromise); - const onAllow = vi.fn(); - - renderHook(() => - useNavigationBlocker({ - when: true, - message: "Confirm navigation?", - onConfirm, - onAllow, - }) - ); - - const blockerFn = mockBlock.mock.calls[0]?.[0]; - - expect(blockerFn).toBeTypeOf("function"); - - blockerFn?.({ retry }); - - resolveConfirm(true); - await confirmPromise; - - await waitFor(() => { - expect(onConfirm).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe("Message handling", () => { - it("should use default message for browser unload", () => { - mockUseShouldBlock.mockReturnValue(true); - - renderHook(() => - useNavigationBlocker({ - when: true, - }) - ); - - expect(mockUseBeforeUnload).toHaveBeenCalledWith(true, DEFAULT_UNLOAD_MESSAGE); - }); - - it("should use custom message", () => { - mockUseShouldBlock.mockReturnValue(true); - - renderHook(() => - useNavigationBlocker({ - when: true, - message: "Custom message", - }) - ); - - expect(mockUseBeforeUnload).toHaveBeenCalledWith(true, "Custom message"); - }); - }); - - describe("Edge cases", () => { - it("should handle missing router.history gracefully", () => { - mockUseShouldBlock.mockReturnValue(true); - mockUseRouter.mockReturnValue({ - history: null, - }); - - const { result } = renderHook(() => - useNavigationBlocker({ - when: true, - }) - ); - - // Should not crash - expect(result.current.isBlocking).toBe(true); - expect(mockBlock).not.toHaveBeenCalled(); - }); - - it("should update blocking when message changes", () => { - mockUseShouldBlock.mockReturnValue(true); - - let message = "Message 1"; - - const { rerender } = renderHook(() => - useNavigationBlocker({ - when: true, - message, - }) - ); - - message = "Message 2"; - rerender(); - - // Should re-block with new message - expect(mockBlock).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx new file mode 100644 index 0000000..1092c56 --- /dev/null +++ b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx @@ -0,0 +1,562 @@ +import { createContext, useContext } from "react"; +import { uiBlockingStoreApi } from "@okyrychenko-dev/react-action-guard"; +import { + createMemoryHistory, + createBrowserHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useNavigationBlocker } from "../../tanstack-router"; +import type { UseNavigationBlockerOptions } from "../../tanstack-router"; +import type { RouterHistory } from "@tanstack/react-router"; + +const OptionsContext = createContext({}); +const histories: Array = []; + +async function mountBlocker( + options: UseNavigationBlockerOptions, + history = createMemoryHistory({ initialEntries: ["/"] }) +) { + function Root() { + useNavigationBlocker(useContext(OptionsContext)); + + return ; + } + + const root = createRootRoute({ + component: Root, + notFoundComponent: () =>
Not found
, + }); + const routes = ["/", "/next", "/other"].map((path) => + createRoute({ getParentRoute: () => root, path, component: () =>
Destination
}) + ); + const router = createRouter({ + routeTree: root.addChildren(routes), + history, + }); + + histories.push(history); + await router.load(); + + const view = render( + + + + ); + + await waitFor(() => expect(view.getByText(/^(Destination|Not found)$/)).toBeTruthy()); + function update(next: UseNavigationBlockerOptions) { + view.rerender( + + + + ); + } + + return { router, update, ...view }; +} + +async function transition( + router: Awaited>["router"], + destination: string +) { + await act(async () => { + router.history.push(destination); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function deferred() { + let resolve: (value: boolean) => void = () => undefined; + let reject: (error: Error) => void = () => undefined; + const promise = new Promise((accept, deny) => { + resolve = accept; + reject = deny; + }); + + return { promise, resolve, reject }; +} + +afterEach(() => { + cleanup(); + histories.splice(0).forEach((history) => history.destroy()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + + const { clearAllBlockers } = uiBlockingStoreApi.getState(); + + clearAllBlockers(); +}); + +describe("TanStack navigation blocking with a real router", () => { + it.each(["when", "scope"])( + "should expose native not-found bypass with active %s and guard subsequent matched navigation", + async (condition) => { + const onBlock = vi.fn(); + const onConfirm = vi.fn(() => false); + const onAllow = vi.fn(); + const { addBlocker } = uiBlockingStoreApi.getState(); + + if (condition === "scope") { + addBlocker("editor", { scope: "editor" }); + } + + const { router, getByText } = await mountBlocker( + { + when: condition === "when", + scope: condition === "scope" ? "editor" : undefined, + message: "Leave?", + onBlock, + onConfirm, + onAllow, + }, + createMemoryHistory({ initialEntries: ["/missing"] }) + ); + + expect(getByText("Not found")).toBeTruthy(); + await transition(router, "/next"); + await waitFor(() => expect(router.state.location.pathname).toBe("/next")); + expect(onBlock).not.toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + expect(onAllow).not.toHaveBeenCalled(); + + await transition(router, "/other"); + expect(router.state.location.pathname).toBe("/next"); + expect(onBlock).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onAllow).not.toHaveBeenCalled(); + } + ); + + it("should skip one unload prompt after allowing external navigation", async () => { + const onConfirm = vi.fn(() => true); + const onAllow = vi.fn(); + const { router } = await mountBlocker( + { when: true, message: "Leave editor?", onConfirm, onAllow }, + createBrowserHistory() + ); + const unload = new Event("beforeunload", { cancelable: true }); + + vi.spyOn(window.location, "href", "set").mockImplementation(() => { + window.dispatchEvent(unload); + }); + + await act(async () => { + await router.navigate({ href: "https://external.example/next" }); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onAllow).toHaveBeenCalledTimes(1); + expect(unload.defaultPrevented).toBe(false); + + const laterUnload = new Event("beforeunload", { cancelable: true }); + + window.dispatchEvent(laterUnload); + expect(laterUnload.defaultPrevented).toBe(true); + }); + + it.each(["accept", "deny", "reject"])( + "should preserve unload protection after asynchronous external %s", + async (outcome) => { + const pending = deferred(); + const onAllow = vi.fn(); + const { router } = await mountBlocker( + { when: true, message: "Leave?", onConfirm: () => pending.promise, onAllow }, + createBrowserHistory() + ); + const unload = new Event("beforeunload", { cancelable: true }); + const assignLocation = vi.spyOn(window.location, "href", "set").mockImplementation(() => { + window.dispatchEvent(unload); + }); + let navigation = Promise.resolve(); + + await act(async () => { + navigation = router.navigate({ href: "https://external.example/next" }); + await Promise.resolve(); + }); + + expect(assignLocation).not.toHaveBeenCalled(); + await act(async () => { + if (outcome === "reject") { + pending.reject(new Error("Confirmation failed")); + } else { + pending.resolve(outcome === "accept"); + } + await navigation; + }); + + expect(assignLocation).toHaveBeenCalledTimes(outcome === "accept" ? 1 : 0); + expect(onAllow).toHaveBeenCalledTimes(outcome === "accept" ? 1 : 0); + expect(unload.defaultPrevented).toBe(false); + + const laterUnload = new Event("beforeunload", { cancelable: true }); + + window.dispatchEvent(laterUnload); + expect(laterUnload.defaultPrevented).toBe(true); + } + ); + + it("should retain unload protection after allowing internal navigation", async () => { + const { router } = await mountBlocker( + { when: true, message: "Leave?", onConfirm: () => true }, + createBrowserHistory() + ); + + await transition(router, "/next"); + await waitFor(() => expect(router.state.location.pathname).toBe("/next")); + + const unload = new Event("beforeunload", { cancelable: true }); + + window.dispatchEvent(unload); + expect(unload.defaultPrevented).toBe(true); + }); + + it.each([true, false])( + "should obey browser confirmation %s without a custom handler", + async (confirmed) => { + const confirm = vi.fn(() => confirmed); + + vi.stubGlobal("confirm", confirm); + + const onBlock = vi.fn(); + const onAllow = vi.fn(); + const { router } = await mountBlocker({ + when: true, + message: "Leave editor?", + onBlock, + onAllow, + }); + + await transition(router, "/next"); + await waitFor(() => expect(router.state.location.pathname).toBe(confirmed ? "/next" : "/")); + expect(confirm).toHaveBeenCalledExactlyOnceWith("Leave editor?"); + expect(onBlock).toHaveBeenCalledTimes(1); + expect(onAllow).toHaveBeenCalledTimes(confirmed ? 1 : 0); + } + ); + + it("should deny silent navigation and report the attempt", async () => { + const onBlock = vi.fn(); + const { router } = await mountBlocker({ when: true, onBlock }); + + await act(async () => { + router.history.push("/next"); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(router.state.location.pathname).toBe("/"); + expect(onBlock).toHaveBeenCalledTimes(1); + }); + + it("should allow inactive navigation without confirmation callbacks", async () => { + const onConfirm = vi.fn(() => false); + const onBlock = vi.fn(); + const onAllow = vi.fn(); + const { router } = await mountBlocker({ + when: false, + message: "Leave?", + onConfirm, + onBlock, + onAllow, + }); + + await transition(router, "/next"); + await waitFor(() => expect(router.state.location.pathname).toBe("/next")); + expect(onConfirm).not.toHaveBeenCalled(); + expect(onBlock).not.toHaveBeenCalled(); + expect(onAllow).not.toHaveBeenCalled(); + }); + + it.each([true, false])( + "should obey synchronous confirmation %s and protect later transitions", + async (confirmed) => { + const onConfirm = vi.fn(() => confirmed); + const onAllow = vi.fn(); + const { router } = await mountBlocker({ when: true, message: "Leave?", onConfirm, onAllow }); + + await transition(router, "/next"); + await waitFor(() => expect(router.state.location.pathname).toBe(confirmed ? "/next" : "/")); + onConfirm.mockReturnValue(false); + await transition(router, "/other"); + expect(router.state.location.pathname).toBe(confirmed ? "/next" : "/"); + expect(onConfirm).toHaveBeenCalledTimes(2); + expect(onAllow).toHaveBeenCalledTimes(confirmed ? 1 : 0); + } + ); + + it.each(["accept", "deny", "reject"])("should obey asynchronous %s", async (outcome) => { + const pending = deferred(); + const onAllow = vi.fn(); + const onConfirm = vi.fn(() => pending.promise); + const { router } = await mountBlocker({ when: true, message: "Leave?", onConfirm, onAllow }); + + await transition(router, "/next"); + expect(router.state.location.pathname).toBe("/"); + await act(async () => { + if (outcome === "reject") { + pending.reject(new Error("Dialog failed")); + } else { + pending.resolve(outcome === "accept"); + } + await Promise.resolve(); + }); + await waitFor(() => + expect(router.state.location.pathname).toBe(outcome === "accept" ? "/next" : "/") + ); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onAllow).toHaveBeenCalledTimes(outcome === "accept" ? 1 : 0); + }); + + it("should ignore a superseded confirmation", async () => { + const first = deferred(); + const second = deferred(); + const onConfirm = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const onAllow = vi.fn(); + const { router } = await mountBlocker({ when: true, message: "Leave?", onConfirm, onAllow }); + + await transition(router, "/next"); + await transition(router, "/other"); + await act(async () => { + first.resolve(true); + await Promise.resolve(); + }); + expect(router.state.location.pathname).toBe("/"); + expect(onAllow).not.toHaveBeenCalled(); + await act(async () => { + second.resolve(true); + await Promise.resolve(); + }); + await waitFor(() => expect(router.state.location.pathname).toBe("/other")); + expect(onAllow).toHaveBeenCalledTimes(1); + }); + + it("should deny a late confirmation after unmount", async () => { + const pending = deferred(); + const onAllow = vi.fn(); + const { router, unmount } = await mountBlocker({ + when: true, + message: "Leave?", + onConfirm: () => pending.promise, + onAllow, + }); + + await transition(router, "/next"); + unmount(); + await act(async () => { + pending.resolve(true); + await Promise.resolve(); + }); + expect(router.history.location.pathname).toBe("/"); + expect(onAllow).not.toHaveBeenCalled(); + }); + + it("should deny a thrown confirmation and retain protection", async () => { + const onConfirm = vi.fn(() => { + throw new Error("Dialog failed"); + }); + const { router } = await mountBlocker({ when: true, message: "Leave?", onConfirm }); + + await transition(router, "/next"); + await transition(router, "/other"); + expect(router.state.location.pathname).toBe("/"); + expect(onConfirm).toHaveBeenCalledTimes(2); + }); + + it.each(["onBlock", "onAllow"])( + "should accept pending confirmation when inline %s changes after opening confirmation UI", + async (observer) => { + const pending = deferred(); + const onBlock = vi.fn(); + const onAllow = vi.fn(); + const options: UseNavigationBlockerOptions = { + when: true, + message: "Leave?", + onBlock: () => onBlock(), + onAllow: () => onAllow(), + onConfirm: vi.fn(() => { + update({ + ...options, + [observer]: () => (observer === "onBlock" ? onBlock() : onAllow()), + }); + + return pending.promise; + }), + }; + const { router, update } = await mountBlocker(options); + + await transition(router, "/next"); + expect(router.state.location.pathname).toBe("/"); + + await act(async () => { + pending.resolve(true); + await Promise.resolve(); + }); + await waitFor(() => expect(router.state.location.pathname).toBe("/next")); + expect(options.onConfirm).toHaveBeenCalledTimes(1); + expect(onBlock).toHaveBeenCalledTimes(1); + expect(onAllow).toHaveBeenCalledTimes(1); + } + ); + + it.each([ + ["same contents", ["first", "second"]], + ["reordered and duplicated contents", ["second", "first", "second"]], + ])("should preserve pending confirmation for scope arrays with %s", async (_, scope) => { + const pending = deferred(); + const onAllow = vi.fn(); + const { addBlocker } = uiBlockingStoreApi.getState(); + + addBlocker("editor", { scope: "first" }); + + const options: UseNavigationBlockerOptions = { + scope: ["first", "second"], + message: "Leave?", + onAllow, + onConfirm: vi.fn(() => { + update({ ...options, scope }); + + return pending.promise; + }), + }; + const { router, update } = await mountBlocker(options); + + await transition(router, "/next"); + + await act(async () => { + pending.resolve(true); + await Promise.resolve(); + }); + await waitFor(() => expect(router.state.location.pathname).toBe("/next")); + expect(options.onConfirm).toHaveBeenCalledTimes(1); + expect(onAllow).toHaveBeenCalledTimes(1); + }); + + it("should invalidate pending confirmation when scope array contents change", async () => { + const pending = deferred(); + const onAllow = vi.fn(); + const { addBlocker } = uiBlockingStoreApi.getState(); + + addBlocker("first-editor", { scope: "first" }); + addBlocker("second-editor", { scope: "second" }); + + const options: UseNavigationBlockerOptions = { + scope: ["first"], + message: "Leave?", + onAllow, + onConfirm: () => pending.promise, + }; + const { router, update } = await mountBlocker(options); + + await transition(router, "/next"); + update({ ...options, scope: ["second"] }); + + await act(async () => { + pending.resolve(true); + await Promise.resolve(); + }); + expect(router.state.location.pathname).toBe("/"); + expect(onAllow).not.toHaveBeenCalled(); + + await transition(router, "/other"); + await waitFor(() => expect(router.state.location.pathname).toBe("/other")); + expect(onAllow).toHaveBeenCalledTimes(1); + }); + + it("should invalidate pending confirmation when options change", async () => { + const pending = deferred(); + const onAllow = vi.fn(); + const { router, update } = await mountBlocker({ + when: true, + message: "Old", + onConfirm: () => pending.promise, + onAllow, + }); + + await transition(router, "/next"); + update({ when: true, message: "New", onConfirm: () => false, onAllow }); + await act(async () => { + pending.resolve(true); + await Promise.resolve(); + }); + expect(router.state.location.pathname).toBe("/"); + expect(onAllow).not.toHaveBeenCalled(); + await transition(router, "/other"); + expect(router.state.location.pathname).toBe("/"); + }); + + it.each(["scope", "when"])( + "should invalidate pending confirmation when %s changes while blocking remains active", + async (source) => { + const pending = deferred(); + const onConfirm = vi.fn(() => pending.promise); + const onAllow = vi.fn(); + const { addBlocker } = uiBlockingStoreApi.getState(); + + addBlocker("first-editor", { scope: "first" }); + addBlocker("second-editor", { scope: "second" }); + + const options: UseNavigationBlockerOptions = { + scope: "first", + when: source === "when" ? () => true : false, + message: "Leave?", + onConfirm, + onAllow, + }; + const { router, update } = await mountBlocker(options); + + await transition(router, "/next"); + + if (source === "scope") { + update({ ...options, scope: "second" }); + } else { + update({ ...options, when: () => true }); + } + + await act(async () => { + pending.resolve(true); + await Promise.resolve(); + }); + + expect(router.state.location.pathname).toBe("/"); + expect(onAllow).not.toHaveBeenCalled(); + + await transition(router, "/other"); + await waitFor(() => expect(router.state.location.pathname).toBe("/other")); + + expect(onConfirm).toHaveBeenCalledTimes(2); + expect(onAllow).toHaveBeenCalledTimes(1); + } + ); + + it("should derive blocking from the selected scope", async () => { + const { addBlocker } = uiBlockingStoreApi.getState(); + + addBlocker("editor", { scope: "editor" }); + + const { router, update } = await mountBlocker({ scope: "editor" }); + + await transition(router, "/next"); + expect(router.state.location.pathname).toBe("/"); + update({ scope: "other" }); + await transition(router, "/next"); + await waitFor(() => expect(router.state.location.pathname).toBe("/next")); + }); + + it.each([true, false])("should honor browser unload option %s", async (blockBrowserUnload) => { + await mountBlocker({ when: true, blockBrowserUnload }, createBrowserHistory()); + + const event = new Event("beforeunload", { cancelable: true }); + + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(blockBrowserUnload); + }); +}); diff --git a/packages/router/src/tanstack-router/types.ts b/packages/router/src/tanstack-router/types.ts index 18d5434..1521a44 100644 --- a/packages/router/src/tanstack-router/types.ts +++ b/packages/router/src/tanstack-router/types.ts @@ -1,4 +1,4 @@ -import type { BaseNavigationBlockerOptions } from "../core/types"; +import type { BaseNavigationBlockerOptions } from "../core"; /** * Options for TanStack Router navigation blocker @@ -7,12 +7,3 @@ import type { BaseNavigationBlockerOptions } from "../core/types"; * future TanStack Router-specific options extension. */ export type UseNavigationBlockerOptions = BaseNavigationBlockerOptions; - -/** - * Minimal interface for TanStack Router to ensure type safety - */ -export interface SafeTanStackRouter { - history: { - block: (callback: (update: Record) => void) => () => void; - }; -} diff --git a/packages/router/src/tanstack-router/useNavigationBlocker.ts b/packages/router/src/tanstack-router/useNavigationBlocker.ts index 5fc1f9c..a9fb6ca 100644 --- a/packages/router/src/tanstack-router/useNavigationBlocker.ts +++ b/packages/router/src/tanstack-router/useNavigationBlocker.ts @@ -1,124 +1,68 @@ -import { isFunction, isObject } from "@okyrychenko-dev/type-utils"; -import { useRouter } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { useBlocker } from "@tanstack/react-router"; +import { useCallback, useEffect, useState } from "react"; import { - DEFAULT_UNLOAD_MESSAGE, + createConfirmationOwner, + normalizeScope, resolveConfirmResult, - useBeforeUnload, useShouldBlock, } from "../core"; -import type { NavigationBlockerReturn } from "../core/types"; -import type { SafeTanStackRouter, UseNavigationBlockerOptions } from "./types"; +import type { NavigationBlockerReturn } from "../core"; +import type { UseNavigationBlockerOptions } from "./types"; -interface RetryableUpdate { - [key: string]: unknown; - retry: () => void; -} - -const hasBlockingHistory = (value: unknown): value is SafeTanStackRouter => { - if (!isObject(value)) { - return false; - } - if (!("history" in value)) { - return false; - } - - const history = value.history; - - if (!isObject(history)) { - return false; - } - if (!("block" in history)) { - return false; - } - - return isFunction(history.block); -}; - -const hasRetry = (value: Record): value is RetryableUpdate => - isFunction(value.retry); - -const attemptRetry = (update: Record): void => { - if (hasRetry(update)) { - try { - update.retry(); - } catch { - // Swallow retry errors to avoid breaking blocking flow. - } - } -}; - -/** - * Blocks navigation in TanStack Router applications based on conditions or scope state. - */ +/** Blocks native TanStack transitions while retaining protection after confirmation. */ export function useNavigationBlocker( options: UseNavigationBlockerOptions ): NavigationBlockerReturn { const { when, scope, message, onBlock, onAllow, blockBrowserUnload = true, onConfirm } = options; - const router = useRouter(); - - // Use shared logic to determine if blocking should be active const shouldBlock = useShouldBlock(when, scope); + const scopeKey = JSON.stringify([...new Set(normalizeScope(scope))].sort()); + const [confirmationOwner] = useState(createConfirmationOwner); + + const { begin, invalidate } = confirmationOwner; - // Block navigation using TanStack Router's history useEffect(() => { - if (!shouldBlock || !hasBlockingHistory(router)) { - return; - } + return invalidate; + }, [when, scopeKey, shouldBlock, message, onConfirm, invalidate]); - // Block navigation with properly typed update parameter - type NavigationUpdate = Record & { retry?: () => void }; + const shouldBlockFn = useCallback(async () => { + const settle = begin(); - const unblock = router.history.block((update: NavigationUpdate) => { - // Early return if no message - if (!message) { - return; - } + onBlock?.(); - // Trigger onBlock callback - onBlock?.(); + if (!message) { + return true; + } + try { const confirmation = resolveConfirmResult(message, onConfirm, (value) => window.confirm(value) ); + let confirmed: boolean; if (confirmation.kind === "async") { - confirmation.promise - .then((confirmed) => { - if (confirmed) { - onAllow?.(); - unblock(); - attemptRetry(update); - } - }) - .catch((error: unknown) => { - // On error, unblock but don't retry - unblock(); - if (process.env.NODE_ENV !== "production") { - console.error("[react-action-guard-router] Confirmation error:", error); - } - }); - - return; + confirmed = await confirmation.promise; + } else { + confirmed = confirmation.confirmed; } - if (confirmation.confirmed) { - onAllow?.(); - unblock(); - attemptRetry(update); + if (!settle() || !confirmed) { + return true; } - }); - return () => { - unblock(); - }; - }, [shouldBlock, message, onBlock, onAllow, router, onConfirm]); + onAllow?.(); + + return false; + } catch { + return true; + } + }, [message, onBlock, onAllow, onConfirm, begin]); - // Also block browser unload if requested - useBeforeUnload(blockBrowserUnload && shouldBlock, message ?? DEFAULT_UNLOAD_MESSAGE); + useBlocker({ + shouldBlockFn, + enableBeforeUnload: blockBrowserUnload && shouldBlock, + disabled: !shouldBlock, + }); - return { - isBlocking: shouldBlock, - }; + return { isBlocking: shouldBlock }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9986ad9..6c2da6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,8 +230,8 @@ importers: specifier: ^10.3.6 version: 10.5.8(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) '@tanstack/react-router': - specifier: ^1.94.4 - version: 1.170.28(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^1.170.41 + version: 1.170.41(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -2105,8 +2105,8 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@tanstack/history@1.162.1': - resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==} + '@tanstack/history@1.162.4': + resolution: {integrity: sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ==} engines: {node: '>=20.19'} '@tanstack/query-core@5.90.10': @@ -2117,25 +2117,25 @@ packages: peerDependencies: react: ^18 || ^19 - '@tanstack/react-router@1.170.28': - resolution: {integrity: sha512-62GU/aV27w1O663K4L01+N5zw4hqwT5Gvq+nMG34ISxQlCgVCBxXff3IscZn2d/tUktnxc9USAdCcdWh00Oi/w==} + '@tanstack/react-router@1.170.41': + resolution: {integrity: sha512-Mpvw8Wm5MGDbTqI8Yc9flLKxbOHwoWocv0H2aGKcgw2a017oK5nZWqa954P+DrUx/8fI98YNywnHP5XcD3tQ9w==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-store@0.9.3': - resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + '@tanstack/react-store@0.11.2': + resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.171.23': - resolution: {integrity: sha512-jdo0oyJ5cj7kz04z7I2hvmWswmqLD+zOGDnT2VZlLk20sQ25vF1QVq5uTVi1Lnp/dlHZtZpAuA7fLJuwy+67/A==} + '@tanstack/router-core@1.171.34': + resolution: {integrity: sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw==} engines: {node: '>=20.19'} - '@tanstack/store@0.9.3': - resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tanstack/store@0.11.2': + resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==} '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} @@ -4626,14 +4626,14 @@ packages: engines: {node: '>=10'} hasBin: true - seroval-plugins@1.6.2: - resolution: {integrity: sha512-TfxuUjlbBESzUOWdTkTKqvSmav0ABym+itetDXLK6mDz8SmrpdI30aF8RTXE8Bvq+tH/1yIDkvy3W0lfQb1ipQ==} + seroval-plugins@1.6.8: + resolution: {integrity: sha512-N7mWAMydj89EnYTHtRpS3LBrAz3J9O6oVrqpuXuvlAHfAUG8WEKqtbAd7SBiJNY5NfCAFhSdyAh7wI4Shjqy4g==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 - seroval@1.6.2: - resolution: {integrity: sha512-mPT+SD2TrlB6wvte1KkYOYUkubaTbd6pZ/6Kk3C9nxzrHmCZyhxOO7XGAeL7f+yLKZglzGtM9odUVvg/EhO+vQ==} + seroval@1.6.8: + resolution: {integrity: sha512-HlSgSAkTk4EqHcje1ptJjfZi1YDv5KbhVJ/d3P7T/nAXua2VmDu+AKDX5VTdFfZf48nDkWB2TKYt0DrCSa+3wg==} engines: {node: '>=10'} set-cookie-parser@2.7.2: @@ -6763,7 +6763,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@tanstack/history@1.162.1': {} + '@tanstack/history@1.162.4': {} '@tanstack/query-core@5.90.10': {} @@ -6772,30 +6772,30 @@ snapshots: '@tanstack/query-core': 5.90.10 react: 19.2.8 - '@tanstack/react-router@1.170.28(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@tanstack/react-router@1.170.41(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@tanstack/history': 1.162.1 - '@tanstack/react-store': 0.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@tanstack/router-core': 1.171.23 + '@tanstack/history': 1.162.4 + '@tanstack/react-store': 0.11.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/router-core': 1.171.34 isbot: 5.2.1 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - '@tanstack/react-store@0.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@tanstack/react-store@0.11.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@tanstack/store': 0.9.3 + '@tanstack/store': 0.11.2 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) - '@tanstack/router-core@1.171.23': + '@tanstack/router-core@1.171.34': dependencies: - '@tanstack/history': 1.162.1 + '@tanstack/history': 1.162.4 cookie-es: 3.1.1 - seroval: 1.6.2 - seroval-plugins: 1.6.2(seroval@1.6.2) + seroval: 1.6.8 + seroval-plugins: 1.6.8(seroval@1.6.8) - '@tanstack/store@0.9.3': {} + '@tanstack/store@0.11.2': {} '@testing-library/dom@10.4.1': dependencies: @@ -9898,11 +9898,11 @@ snapshots: semver@7.8.5: {} - seroval-plugins@1.6.2(seroval@1.6.2): + seroval-plugins@1.6.8(seroval@1.6.8): dependencies: - seroval: 1.6.2 + seroval: 1.6.8 - seroval@1.6.2: {} + seroval@1.6.8: {} set-cookie-parser@2.7.2: {}