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
6 changes: 6 additions & 0 deletions packages/docs/src/pages/ApiHooksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ function EditForm() {
This hook only handles SPA navigations (links, programmatic navigation). For hard
navigations (tab close, refresh), handle <code>beforeunload</code> separately.
</li>
<li>
Some navigate events are not cancelable — notably certain browser back/forward
traversals, depending on the browser and user activation. For those,{" "}
<code>shouldBlock</code> is not called and the navigation proceeds, since the Navigation
API provides no way to cancel it.
</li>
</ul>
</article>

Expand Down
14 changes: 11 additions & 3 deletions packages/router/docs/useBlocker-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand Down
17 changes: 14 additions & 3 deletions packages/router/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 };
};
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 31 additions & 0 deletions packages/router/src/__tests__/useBlocker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div>Home</div>;
}

const routes: RouteDefinition[] = [
{ path: "/", component: TestComponent },
{ path: "/about", component: () => <div>About</div> },
];

render(<Router routes={routes} />);
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);
Expand Down
8 changes: 6 additions & 2 deletions packages/router/src/core/NavigationAPIAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/router/src/hooks/useBlocker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down