Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/native-tanstack-navigation.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -91,6 +92,7 @@ describe("useConfirmableBlocker", () => {
finish();
await Promise.all(outcomes);
});

expect(result.current.blocked).toBe(false);
});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -212,6 +215,7 @@ describe("useConfirmableBlocker", () => {
await act(async () => {
await expect(result.current.onConfirm()).rejects.toBe(failure);
});

expect(onConfirm).toHaveBeenCalledTimes(2);
});

Expand Down Expand Up @@ -367,6 +371,7 @@ describe("useConfirmableBlocker", () => {
act(() => {
result.current.execute();
});

expect(result.current.isDialogOpen).toBe(true);

await actAsync(async () => {
Expand All @@ -390,6 +395,7 @@ describe("useConfirmableBlocker", () => {
act(() => {
result.current.execute();
});

expect(result.current.isDialogOpen).toBe(true);

act(() => {
Expand Down
14 changes: 10 additions & 4 deletions packages/router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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";
Expand All @@ -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

Expand Down Expand Up @@ -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 |

Expand Down
4 changes: 2 additions & 2 deletions packages/router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/router/src/core/confirmationOwner.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface ConfirmationOwner {
begin: () => () => boolean;
invalidate: VoidFunction;
}
26 changes: 26 additions & 0 deletions packages/router/src/core/confirmationOwner.utils.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
1 change: 1 addition & 0 deletions packages/router/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ export type {
NavigationBlockerReturn,
} from "./types";
export type { DialogState, UseDialogStateReturn } from "./useDialogState";
export { createConfirmationOwner } from "./confirmationOwner.utils";
Loading
Loading