diff --git a/packages/docs/src/pages/ApiHooksPage.tsx b/packages/docs/src/pages/ApiHooksPage.tsx
index 0fa14f9..544c188 100644
--- a/packages/docs/src/pages/ApiHooksPage.tsx
+++ b/packages/docs/src/pages/ApiHooksPage.tsx
@@ -113,6 +113,12 @@ function EditForm() {
This hook only handles SPA navigations (links, programmatic navigation). For hard
navigations (tab close, refresh), handle beforeunload separately.
+
+ Some navigate events are not cancelable — notably certain browser back/forward
+ traversals, depending on the browser and user activation. For those,{" "}
+ shouldBlock is not called and the navigation proceeds, since the Navigation
+ API provides no way to cancel it.
+
diff --git a/packages/router/docs/useBlocker-design.md b/packages/router/docs/useBlocker-design.md
index 768e7b8..cea3454 100644
--- a/packages/router/docs/useBlocker-design.md
+++ b/packages/router/docs/useBlocker-design.md
@@ -67,11 +67,15 @@ When navigation occurs:
For navigations within the app (links, programmatic navigation):
1. Navigation event fires
-2. All registered blocker functions are called synchronously
-3. If any returns `true`:
+2. If the event is not cancelable (`event.cancelable === false`), blockers are
+ skipped entirely and navigation proceeds — `preventDefault()` would be
+ ignored, so running blockers (and possibly showing a `confirm()` dialog)
+ would be misleading
+3. All registered blocker functions are called synchronously
+4. If any returns `true`:
- Navigation is prevented (`event.preventDefault()`)
- User remains on current page
-4. If all return `false`:
+5. If all return `false`:
- Navigation proceeds normally
### Hard Navigations
@@ -226,6 +230,10 @@ Some navigations cannot or should not be blocked:
- Same-page hash changes (anchor links)
- `replace` navigations that don't change the path (state-only updates)
- Hard navigations (tab close, external URL) - use `beforeunload` separately
+- Non-cancelable navigate events — per the Navigation API spec, some events
+ (notably certain back/forward traversals, depending on browser and user
+ activation) have `cancelable: false`. Blockers are skipped for these since
+ the navigation cannot be prevented
## Comparison with `onNavigate`
diff --git a/packages/router/src/__tests__/setup.ts b/packages/router/src/__tests__/setup.ts
index 21e89f8..71d9570 100644
--- a/packages/router/src/__tests__/setup.ts
+++ b/packages/router/src/__tests__/setup.ts
@@ -76,11 +76,13 @@ export function createMockNavigation(initialUrl = "http://localhost/") {
eventInfo?: unknown,
eventFormData?: FormData | null,
eventNavigationType?: "push" | "replace" | "reload" | "traverse",
+ eventCancelable = true,
): NavigateEvent & { defaultPrevented: boolean } => {
let defaultPrevented = false;
return {
type: "navigate",
canIntercept: true,
+ cancelable: eventCancelable,
hashChange: false,
destination: {
url: destinationUrl,
@@ -103,7 +105,10 @@ export function createMockNavigation(initialUrl = "http://localhost/") {
return defaultPrevented;
},
preventDefault: vi.fn(() => {
- defaultPrevented = true;
+ // Mimic DOM behavior: preventDefault is ignored on non-cancelable events
+ if (eventCancelable) {
+ defaultPrevented = true;
+ }
}),
} as unknown as NavigateEvent & { defaultPrevented: boolean };
};
@@ -194,13 +199,19 @@ export function createMockNavigation(initialUrl = "http://localhost/") {
// This allows testing of onNavigate callback behavior
__simulateNavigationWithEvent(
url: string,
- options?: { info?: unknown; formData?: FormData },
+ options?: { info?: unknown; formData?: FormData; cancelable?: boolean },
): {
event: NavigateEvent & { defaultPrevented: boolean };
proceed: () => void;
} {
const newUrl = new URL(url, currentEntry.url).href;
- const event = createMockNavigateEvent(newUrl, options?.info, options?.formData);
+ const event = createMockNavigateEvent(
+ newUrl,
+ options?.info,
+ options?.formData,
+ undefined,
+ options?.cancelable,
+ );
// Dispatch navigate event first (allows onNavigate to be called)
dispatchEvent("navigate", event);
diff --git a/packages/router/src/__tests__/useBlocker.test.tsx b/packages/router/src/__tests__/useBlocker.test.tsx
index 0a982bb..8516700 100644
--- a/packages/router/src/__tests__/useBlocker.test.tsx
+++ b/packages/router/src/__tests__/useBlocker.test.tsx
@@ -72,6 +72,37 @@ describe("useBlocker", () => {
expect(event.preventDefault).toHaveBeenCalled();
});
+ it("skips shouldBlock when the navigate event is not cancelable", () => {
+ const shouldBlock = vi.fn(() => true);
+
+ function TestComponent() {
+ useBlocker({ shouldBlock });
+ return Home
;
+ }
+
+ const routes: RouteDefinition[] = [
+ { path: "/", component: TestComponent },
+ { path: "/about", component: () => About
},
+ ];
+
+ render();
+ expect(screen.getByText("Home")).toBeInTheDocument();
+
+ // Simulate a non-cancelable navigation (e.g. a browser traversal that
+ // cannot be canceled). preventDefault() would be ignored, so the blocker
+ // must not run — otherwise a confirm() dialog would be shown whose
+ // answer cannot be honored.
+ const { event } = mockNavigation.__simulateNavigationWithEvent("/about", {
+ cancelable: false,
+ });
+
+ expect(shouldBlock).not.toHaveBeenCalled();
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(false);
+ // Navigation is still handled normally (route matched, so intercepted)
+ expect(event.intercept).toHaveBeenCalled();
+ });
+
it("blocks navigation based on dynamic condition", async () => {
function TestComponent() {
const [isDirty, setIsDirty] = useState(false);
diff --git a/packages/router/src/core/NavigationAPIAdapter.ts b/packages/router/src/core/NavigationAPIAdapter.ts
index 5bffb7f..0ae2da1 100644
--- a/packages/router/src/core/NavigationAPIAdapter.ts
+++ b/packages/router/src/core/NavigationAPIAdapter.ts
@@ -288,8 +288,12 @@ export class NavigationAPIAdapter implements RouterAdapter {
// Invalidate cached snapshot to pick up new info
this.#cachedSnapshot = null;
- // Check blockers first - if any blocker returns true, prevent navigation
- if (checkBlockers?.()) {
+ // Check blockers first - if any blocker returns true, prevent navigation.
+ // Some navigate events are not cancelable (e.g. certain traversals,
+ // depending on browser and user activation); preventDefault() would be
+ // ignored on them, so skip shouldBlock() entirely rather than show a
+ // confirmation dialog whose answer cannot be honored.
+ if (event.cancelable && checkBlockers?.()) {
event.preventDefault();
return;
}
diff --git a/packages/router/src/hooks/useBlocker.ts b/packages/router/src/hooks/useBlocker.ts
index 4f5b4e9..54b8a1b 100644
--- a/packages/router/src/hooks/useBlocker.ts
+++ b/packages/router/src/hooks/useBlocker.ts
@@ -35,6 +35,11 @@ export type UseBlockerOptions = {
*
* Note: This hook only handles SPA navigations (links, programmatic navigation).
* For hard navigations (tab close, refresh), handle `beforeunload` separately.
+ *
+ * Note: Some navigate events are not cancelable — notably certain browser
+ * back/forward traversals, depending on the browser and user activation.
+ * For those, `shouldBlock` is not called and the navigation proceeds,
+ * since the Navigation API provides no way to cancel it.
*/
export function useBlocker(options: UseBlockerOptions): void {
const context = useContext(BlockerContext);