From 0e68f0bbff82a97a6676bbdbd08e7a1694bacd7b Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Fri, 2 Oct 2026 22:52:18 +0300 Subject: [PATCH 1/8] fix(router): restore native TanStack navigation blocking (local ticket 01) --- .changeset/native-tanstack-navigation.md | 5 + .../__tests__/useConfirmableBlocker.test.tsx | 6 + packages/router/README.md | 12 +- packages/router/package.json | 4 +- .../src/core/confirmationOwner.types.ts | 4 + .../src/core/confirmationOwner.utils.ts | 27 ++ packages/router/src/core/index.ts | 1 + .../__tests__/useNavigationBlocker.test.ts | 293 ------------------ .../__tests__/useNavigationBlocker.test.tsx | 259 ++++++++++++++++ packages/router/src/tanstack-router/types.ts | 11 +- .../tanstack-router/useNavigationBlocker.ts | 129 +++----- pnpm-lock.yaml | 2 +- 12 files changed, 350 insertions(+), 403 deletions(-) create mode 100644 .changeset/native-tanstack-navigation.md create mode 100644 packages/router/src/core/confirmationOwner.types.ts create mode 100644 packages/router/src/core/confirmationOwner.utils.ts delete mode 100644 packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.ts create mode 100644 packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx diff --git a/.changeset/native-tanstack-navigation.md b/.changeset/native-tanstack-navigation.md new file mode 100644 index 0000000..66fd29f --- /dev/null +++ b/.changeset/native-tanstack-navigation.md @@ -0,0 +1,5 @@ +--- +"@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.28 within v1, the verified compatibility floor, and retain independent unload protection without duplicate handlers. diff --git a/packages/core/src/hooks/useConfirmableBlocker/__tests__/useConfirmableBlocker.test.tsx b/packages/core/src/hooks/useConfirmableBlocker/__tests__/useConfirmableBlocker.test.tsx index e9c222e..92e5854 100644 --- a/packages/core/src/hooks/useConfirmableBlocker/__tests__/useConfirmableBlocker.test.tsx +++ b/packages/core/src/hooks/useConfirmableBlocker/__tests__/useConfirmableBlocker.test.tsx @@ -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..a195fa3 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.28 - 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.28 or newer; earlier versions are no longer declared supported. ```tsx import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/tanstack-router"; @@ -293,7 +293,11 @@ 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 option changes cannot authorize navigation. + +`scope` and `when` retain their existing OR behavior. `blockBrowserUnload` independently controls the shared unload handler; native TanStack unload handling is disabled to avoid duplicate prompts. Unload uses a browser-controlled prompt, not asynchronous custom confirmation. + +Compatibility tests exercise the public hook with real memory-history routers at 1.170.28 and 1.170.41. Browser back/forward behavior has not been verified by these tests; memory history does not establish that guarantee. The native API is documented in [TanStack navigation blocking](https://tanstack.com/router/latest/docs/guide/navigation-blocking). ### Next.js Pages Router @@ -350,7 +354,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..cb2faa2 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.28", "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.28", "@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..9898621 --- /dev/null +++ b/packages/router/src/core/confirmationOwner.utils.ts @@ -0,0 +1,27 @@ +import type { ConfirmationOwner } from "./confirmationOwner.types"; + +/** Owns the right to settle only the latest navigation attempt, once. */ +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..f054591 --- /dev/null +++ b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx @@ -0,0 +1,259 @@ +import { createContext, useContext } from "react"; +import { uiBlockingStoreApi } from "@okyrychenko-dev/react-action-guard"; +import { + createMemoryHistory, + 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 type { UseNavigationBlockerOptions } from "../types"; +import { useNavigationBlocker } from "../useNavigationBlocker"; + +const OptionsContext = createContext({}); + +async function mountBlocker(options: UseNavigationBlockerOptions) { + function Root() { + useNavigationBlocker(useContext(OptionsContext)); + + return ; + } + + const root = createRootRoute({ component: Root }); + const routes = ["/", "/next", "/other"].map((path) => + createRoute({ getParentRoute: () => root, path, component: () =>
Destination
}) + ); + const router = createRouter({ + routeTree: root.addChildren(routes), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + + await router.load(); + + const view = render( + + + + ); + + await waitFor(() => expect(view.getByText("Destination")).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(); + + const { clearAllBlockers } = uiBlockingStoreApi.getState(); + + clearAllBlockers(); +}); + +describe("TanStack navigation blocking with a real router", () => { + 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("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("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 }); + + 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..505eff1 100644 --- a/packages/router/src/tanstack-router/useNavigationBlocker.ts +++ b/packages/router/src/tanstack-router/useNavigationBlocker.ts @@ -1,124 +1,67 @@ -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, 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 [confirmationOwner] = useState(createConfirmationOwner); + const { begin, invalidate } = confirmationOwner; - // Block navigation using TanStack Router's history useEffect(() => { - if (!shouldBlock || !hasBlockingHistory(router)) { - return; - } + return invalidate; + }, [shouldBlock, message, onBlock, onAllow, 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; - } + if (!shouldBlock) { + return false; + } + + 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; + } + }, [shouldBlock, message, onBlock, onAllow, onConfirm, begin]); - // Also block browser unload if requested + useBlocker({ shouldBlockFn, enableBeforeUnload: false, disabled: !shouldBlock }); useBeforeUnload(blockBrowserUnload && shouldBlock, message ?? DEFAULT_UNLOAD_MESSAGE); - return { - isBlocking: shouldBlock, - }; + return { isBlocking: shouldBlock }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9986ad9..41a3251 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,7 +230,7 @@ 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 + specifier: ^1.170.28 version: 1.170.28(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@testing-library/dom': specifier: ^10.4.1 From f2cdefbcfd61169943768054b1814c63368323c3 Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sat, 3 Oct 2026 13:18:44 +0300 Subject: [PATCH 2/8] test(router): cover native browser confirmation fallback --- .../__tests__/useConfirmableBlocker.test.tsx | 2 +- .../src/core/confirmationOwner.utils.ts | 1 - .../__tests__/useNavigationBlocker.test.tsx | 30 +++++++++++++++++-- .../tanstack-router/useNavigationBlocker.ts | 6 +--- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/core/src/hooks/useConfirmableBlocker/__tests__/useConfirmableBlocker.test.tsx b/packages/core/src/hooks/useConfirmableBlocker/__tests__/useConfirmableBlocker.test.tsx index 92e5854..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", () => { diff --git a/packages/router/src/core/confirmationOwner.utils.ts b/packages/router/src/core/confirmationOwner.utils.ts index 9898621..f4c7b22 100644 --- a/packages/router/src/core/confirmationOwner.utils.ts +++ b/packages/router/src/core/confirmationOwner.utils.ts @@ -1,6 +1,5 @@ import type { ConfirmationOwner } from "./confirmationOwner.types"; -/** Owns the right to settle only the latest navigation attempt, once. */ export function createConfirmationOwner(): ConfirmationOwner { let currentAttempt = 0; diff --git a/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx index f054591..91d1647 100644 --- a/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx +++ b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx @@ -10,8 +10,8 @@ import { } from "@tanstack/react-router"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { UseNavigationBlockerOptions } from "../types"; -import { useNavigationBlocker } from "../useNavigationBlocker"; +import { useNavigationBlocker } from "../../tanstack-router"; +import type { UseNavigationBlockerOptions } from "../../tanstack-router"; const OptionsContext = createContext({}); @@ -75,6 +75,8 @@ function deferred() { afterEach(() => { cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); const { clearAllBlockers } = uiBlockingStoreApi.getState(); @@ -82,6 +84,30 @@ afterEach(() => { }); describe("TanStack navigation blocking with a real router", () => { + 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 }); diff --git a/packages/router/src/tanstack-router/useNavigationBlocker.ts b/packages/router/src/tanstack-router/useNavigationBlocker.ts index 505eff1..91dd06c 100644 --- a/packages/router/src/tanstack-router/useNavigationBlocker.ts +++ b/packages/router/src/tanstack-router/useNavigationBlocker.ts @@ -26,10 +26,6 @@ export function useNavigationBlocker( const shouldBlockFn = useCallback(async () => { const settle = begin(); - if (!shouldBlock) { - return false; - } - onBlock?.(); if (!message) { @@ -58,7 +54,7 @@ export function useNavigationBlocker( } catch { return true; } - }, [shouldBlock, message, onBlock, onAllow, onConfirm, begin]); + }, [message, onBlock, onAllow, onConfirm, begin]); useBlocker({ shouldBlockFn, enableBeforeUnload: false, disabled: !shouldBlock }); useBeforeUnload(blockBrowserUnload && shouldBlock, message ?? DEFAULT_UNLOAD_MESSAGE); From 2b4bb885822863d1288ec6451ab82bb1fcc3e8d5 Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sat, 3 Oct 2026 13:50:12 +0300 Subject: [PATCH 3/8] fix(router): avoid duplicate unload prompt after external navigation --- .changeset/native-tanstack-navigation.md | 2 +- packages/router/README.md | 8 +- packages/router/package.json | 4 +- .../__tests__/useNavigationBlocker.test.tsx | 97 ++++++++++++++++++- .../tanstack-router/useNavigationBlocker.ts | 9 +- pnpm-lock.yaml | 62 ++++++------ 6 files changed, 137 insertions(+), 45 deletions(-) diff --git a/.changeset/native-tanstack-navigation.md b/.changeset/native-tanstack-navigation.md index 66fd29f..b4e13fa 100644 --- a/.changeset/native-tanstack-navigation.md +++ b/.changeset/native-tanstack-navigation.md @@ -2,4 +2,4 @@ "@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.28 within v1, the verified compatibility floor, and retain independent unload protection without duplicate handlers. +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. diff --git a/packages/router/README.md b/packages/router/README.md index a195fa3..891c0d1 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.170.28 - 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 -Uses TanStack Router's native `useBlocker` for in-app transitions. Requires TanStack Router 1.170.28 or newer; earlier versions are no longer declared supported. +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"; @@ -295,9 +295,9 @@ function MyComponent() { 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 option changes cannot authorize navigation. -`scope` and `when` retain their existing OR behavior. `blockBrowserUnload` independently controls the shared unload handler; native TanStack unload handling is disabled to avoid duplicate prompts. Unload uses a browser-controlled prompt, not asynchronous custom confirmation. +`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. -Compatibility tests exercise the public hook with real memory-history routers at 1.170.28 and 1.170.41. Browser back/forward behavior has not been verified by these tests; memory history does not establish that guarantee. The native API is documented in [TanStack navigation blocking](https://tanstack.com/router/latest/docs/guide/navigation-blocking). +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 diff --git a/packages/router/package.json b/packages/router/package.json index cb2faa2..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.170.28", + "@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.170.28", + "@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/tanstack-router/__tests__/useNavigationBlocker.test.tsx b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx index 91d1647..36c7cef 100644 --- a/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx +++ b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx @@ -2,6 +2,7 @@ import { createContext, useContext } from "react"; import { uiBlockingStoreApi } from "@okyrychenko-dev/react-action-guard"; import { createMemoryHistory, + createBrowserHistory, createRootRoute, createRoute, createRouter, @@ -12,10 +13,15 @@ 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) { +async function mountBlocker( + options: UseNavigationBlockerOptions, + history = createMemoryHistory({ initialEntries: ["/"] }) +) { function Root() { useNavigationBlocker(useContext(OptionsContext)); @@ -28,9 +34,10 @@ async function mountBlocker(options: UseNavigationBlockerOptions) { ); const router = createRouter({ routeTree: root.addChildren(routes), - history: createMemoryHistory({ initialEntries: ["/"] }), + history, }); + histories.push(history); await router.load(); const view = render( @@ -75,6 +82,7 @@ function deferred() { afterEach(() => { cleanup(); + histories.splice(0).forEach((history) => history.destroy()); vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -84,6 +92,89 @@ afterEach(() => { }); describe("TanStack navigation blocking with a real router", () => { + 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) => { @@ -275,7 +366,7 @@ describe("TanStack navigation blocking with a real router", () => { }); it.each([true, false])("should honor browser unload option %s", async (blockBrowserUnload) => { - await mountBlocker({ when: true, blockBrowserUnload }); + await mountBlocker({ when: true, blockBrowserUnload }, createBrowserHistory()); const event = new Event("beforeunload", { cancelable: true }); diff --git a/packages/router/src/tanstack-router/useNavigationBlocker.ts b/packages/router/src/tanstack-router/useNavigationBlocker.ts index 91dd06c..5695b81 100644 --- a/packages/router/src/tanstack-router/useNavigationBlocker.ts +++ b/packages/router/src/tanstack-router/useNavigationBlocker.ts @@ -1,10 +1,8 @@ import { useBlocker } from "@tanstack/react-router"; import { useCallback, useEffect, useState } from "react"; import { - DEFAULT_UNLOAD_MESSAGE, createConfirmationOwner, resolveConfirmResult, - useBeforeUnload, useShouldBlock, } from "../core"; import type { NavigationBlockerReturn } from "../core"; @@ -56,8 +54,11 @@ export function useNavigationBlocker( } }, [message, onBlock, onAllow, onConfirm, begin]); - useBlocker({ shouldBlockFn, enableBeforeUnload: false, disabled: !shouldBlock }); - useBeforeUnload(blockBrowserUnload && shouldBlock, message ?? DEFAULT_UNLOAD_MESSAGE); + useBlocker({ + shouldBlockFn, + enableBeforeUnload: blockBrowserUnload && shouldBlock, + disabled: !shouldBlock, + }); return { isBlocking: shouldBlock }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41a3251..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.170.28 - 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: {} From 142e5eb49f00aacf1e61d6a2d6bff1a2e8da5684 Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sat, 3 Oct 2026 13:57:45 +0300 Subject: [PATCH 4/8] fix format issue --- packages/router/src/tanstack-router/useNavigationBlocker.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/router/src/tanstack-router/useNavigationBlocker.ts b/packages/router/src/tanstack-router/useNavigationBlocker.ts index 5695b81..3ace504 100644 --- a/packages/router/src/tanstack-router/useNavigationBlocker.ts +++ b/packages/router/src/tanstack-router/useNavigationBlocker.ts @@ -1,10 +1,6 @@ import { useBlocker } from "@tanstack/react-router"; import { useCallback, useEffect, useState } from "react"; -import { - createConfirmationOwner, - resolveConfirmResult, - useShouldBlock, -} from "../core"; +import { createConfirmationOwner, resolveConfirmResult, useShouldBlock } from "../core"; import type { NavigationBlockerReturn } from "../core"; import type { UseNavigationBlockerOptions } from "./types"; From cc84849e7890fa5f8598272f84f9190f84d73ccf Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sat, 3 Oct 2026 14:02:07 +0300 Subject: [PATCH 5/8] fix(router): invalidate confirmations when guard sources change --- .changeset/native-tanstack-navigation.md | 2 + packages/router/README.md | 2 +- .../__tests__/useNavigationBlocker.test.tsx | 44 +++++++++++++++++++ .../tanstack-router/useNavigationBlocker.ts | 2 +- 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/.changeset/native-tanstack-navigation.md b/.changeset/native-tanstack-navigation.md index b4e13fa..d85d0c1 100644 --- a/.changeset/native-tanstack-navigation.md +++ b/.changeset/native-tanstack-navigation.md @@ -3,3 +3,5 @@ --- 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. diff --git a/packages/router/README.md b/packages/router/README.md index 891c0d1..d690bd4 100644 --- a/packages/router/README.md +++ b/packages/router/README.md @@ -293,7 +293,7 @@ function MyComponent() { } ``` -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 option changes cannot authorize navigation. +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 option changes cannot authorize navigation. Replacing `when` or changing `scope` invalidates a pending confirmation even when the computed blocking condition remains true. `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. diff --git a/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx index 36c7cef..32a9300 100644 --- a/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx +++ b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx @@ -351,6 +351,50 @@ describe("TanStack navigation blocking with a real router", () => { 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(); diff --git a/packages/router/src/tanstack-router/useNavigationBlocker.ts b/packages/router/src/tanstack-router/useNavigationBlocker.ts index 3ace504..cbfccc9 100644 --- a/packages/router/src/tanstack-router/useNavigationBlocker.ts +++ b/packages/router/src/tanstack-router/useNavigationBlocker.ts @@ -15,7 +15,7 @@ export function useNavigationBlocker( useEffect(() => { return invalidate; - }, [shouldBlock, message, onBlock, onAllow, onConfirm, invalidate]); + }, [when, scope, shouldBlock, message, onBlock, onAllow, onConfirm, invalidate]); const shouldBlockFn = useCallback(async () => { const settle = begin(); From c1afa882d1f48aa40483271eb399810e7e6afca5 Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sat, 3 Oct 2026 14:14:15 +0300 Subject: [PATCH 6/8] fix(router): preserve confirmations when observer callbacks change --- .changeset/native-tanstack-navigation.md | 2 ++ packages/router/README.md | 2 +- .../__tests__/useNavigationBlocker.test.tsx | 36 +++++++++++++++++++ .../tanstack-router/useNavigationBlocker.ts | 2 +- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.changeset/native-tanstack-navigation.md b/.changeset/native-tanstack-navigation.md index d85d0c1..4a6251e 100644 --- a/.changeset/native-tanstack-navigation.md +++ b/.changeset/native-tanstack-navigation.md @@ -5,3 +5,5 @@ 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. diff --git a/packages/router/README.md b/packages/router/README.md index d690bd4..f2c934c 100644 --- a/packages/router/README.md +++ b/packages/router/README.md @@ -293,7 +293,7 @@ function MyComponent() { } ``` -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 option changes cannot authorize navigation. Replacing `when` or changing `scope` invalidates a pending confirmation even when the computed blocking condition remains true. +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 `scope` invalidates a pending confirmation even when the computed blocking condition remains true. 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. diff --git a/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx index 32a9300..38f18eb 100644 --- a/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx +++ b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx @@ -329,6 +329,42 @@ describe("TanStack navigation blocking with a real router", () => { 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("should invalidate pending confirmation when options change", async () => { const pending = deferred(); const onAllow = vi.fn(); diff --git a/packages/router/src/tanstack-router/useNavigationBlocker.ts b/packages/router/src/tanstack-router/useNavigationBlocker.ts index cbfccc9..7f609fe 100644 --- a/packages/router/src/tanstack-router/useNavigationBlocker.ts +++ b/packages/router/src/tanstack-router/useNavigationBlocker.ts @@ -15,7 +15,7 @@ export function useNavigationBlocker( useEffect(() => { return invalidate; - }, [when, scope, shouldBlock, message, onBlock, onAllow, onConfirm, invalidate]); + }, [when, scope, shouldBlock, message, onConfirm, invalidate]); const shouldBlockFn = useCallback(async () => { const settle = begin(); From be24a4b873c608decfa2125fed2396e009e3eead Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sat, 3 Oct 2026 14:28:12 +0300 Subject: [PATCH 7/8] fix(router): compare confirmation scopes by value --- .changeset/native-tanstack-navigation.md | 2 + packages/router/README.md | 2 +- .../__tests__/useNavigationBlocker.test.tsx | 64 +++++++++++++++++++ .../tanstack-router/useNavigationBlocker.ts | 12 +++- 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/.changeset/native-tanstack-navigation.md b/.changeset/native-tanstack-navigation.md index 4a6251e..fd04166 100644 --- a/.changeset/native-tanstack-navigation.md +++ b/.changeset/native-tanstack-navigation.md @@ -7,3 +7,5 @@ Restore TanStack navigation blocking using native useBlocker. Confirmed transiti 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. diff --git a/packages/router/README.md b/packages/router/README.md index f2c934c..bd55e4b 100644 --- a/packages/router/README.md +++ b/packages/router/README.md @@ -293,7 +293,7 @@ function MyComponent() { } ``` -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 `scope` invalidates a pending confirmation even when the computed blocking condition remains true. Changes to observer callbacks `onBlock` and `onAllow` do not invalidate a pending confirmation; the attempt retains the callbacks it started with. +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. diff --git a/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx index 38f18eb..07c7c62 100644 --- a/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx +++ b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx @@ -365,6 +365,70 @@ describe("TanStack navigation blocking with a real router", () => { } ); + 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(); diff --git a/packages/router/src/tanstack-router/useNavigationBlocker.ts b/packages/router/src/tanstack-router/useNavigationBlocker.ts index 7f609fe..a9fb6ca 100644 --- a/packages/router/src/tanstack-router/useNavigationBlocker.ts +++ b/packages/router/src/tanstack-router/useNavigationBlocker.ts @@ -1,6 +1,11 @@ import { useBlocker } from "@tanstack/react-router"; import { useCallback, useEffect, useState } from "react"; -import { createConfirmationOwner, resolveConfirmResult, useShouldBlock } from "../core"; +import { + createConfirmationOwner, + normalizeScope, + resolveConfirmResult, + useShouldBlock, +} from "../core"; import type { NavigationBlockerReturn } from "../core"; import type { UseNavigationBlockerOptions } from "./types"; @@ -9,13 +14,16 @@ export function useNavigationBlocker( options: UseNavigationBlockerOptions ): NavigationBlockerReturn { const { when, scope, message, onBlock, onAllow, blockBrowserUnload = true, onConfirm } = options; + const shouldBlock = useShouldBlock(when, scope); + const scopeKey = JSON.stringify([...new Set(normalizeScope(scope))].sort()); const [confirmationOwner] = useState(createConfirmationOwner); + const { begin, invalidate } = confirmationOwner; useEffect(() => { return invalidate; - }, [when, scope, shouldBlock, message, onConfirm, invalidate]); + }, [when, scopeKey, shouldBlock, message, onConfirm, invalidate]); const shouldBlockFn = useCallback(async () => { const settle = begin(); From 05f4de81d9e3dd1b9dcb7856b60c80c967e5331b Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sat, 3 Oct 2026 14:42:40 +0300 Subject: [PATCH 8/8] test(router): document native not-found navigation bypass --- .changeset/native-tanstack-navigation.md | 2 + packages/router/README.md | 2 + .../__tests__/useNavigationBlocker.test.tsx | 46 ++++++++++++++++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/.changeset/native-tanstack-navigation.md b/.changeset/native-tanstack-navigation.md index fd04166..8f6456c 100644 --- a/.changeset/native-tanstack-navigation.md +++ b/.changeset/native-tanstack-navigation.md @@ -9,3 +9,5 @@ Invalidate pending confirmations when when or scope changes, including replaceme 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/router/README.md b/packages/router/README.md index bd55e4b..5229c95 100644 --- a/packages/router/README.md +++ b/packages/router/README.md @@ -297,6 +297,8 @@ Sync and async `onConfirm` are evaluated once per blocked navigation attempt. Ac `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 diff --git a/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx index 07c7c62..1092c56 100644 --- a/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx +++ b/packages/router/src/tanstack-router/__tests__/useNavigationBlocker.test.tsx @@ -28,7 +28,10 @@ async function mountBlocker( return ; } - const root = createRootRoute({ component: Root }); + const root = createRootRoute({ + component: Root, + notFoundComponent: () =>
Not found
, + }); const routes = ["/", "/next", "/other"].map((path) => createRoute({ getParentRoute: () => root, path, component: () =>
Destination
}) ); @@ -46,7 +49,7 @@ async function mountBlocker( ); - await waitFor(() => expect(view.getByText("Destination")).toBeTruthy()); + await waitFor(() => expect(view.getByText(/^(Destination|Not found)$/)).toBeTruthy()); function update(next: UseNavigationBlockerOptions) { view.rerender( @@ -92,6 +95,45 @@ afterEach(() => { }); 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();