Skip to content

Commit 1674de5

Browse files
committed
feat: add onNavigate prop
1 parent ffd1149 commit 1674de5

File tree

6 files changed

+219
-6
lines changed

6 files changed

+219
-6
lines changed

packages/router/src/Router.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { RouteContext } from "./context/RouteContext.js";
1010
import {
1111
type NavigateOptions,
1212
type MatchedRouteWithData,
13+
type OnNavigateCallback,
1314
internalRoutes,
1415
} from "./types.js";
1516
import { matchRoutes } from "./core/matchRoutes.js";
@@ -26,9 +27,20 @@ import type { RouteDefinition } from "./route.js";
2627

2728
export type RouterProps = {
2829
routes: RouteDefinition[];
30+
/**
31+
* Callback invoked before navigation is intercepted.
32+
* Call `event.preventDefault()` to prevent the router from handling this navigation.
33+
*
34+
* @param event - The NavigateEvent from the Navigation API
35+
* @param matched - Array of matched routes, or null if no routes matched
36+
*/
37+
onNavigate?: OnNavigateCallback;
2938
};
3039

31-
export function Router({ routes: inputRoutes }: RouterProps): ReactNode {
40+
export function Router({
41+
routes: inputRoutes,
42+
onNavigate,
43+
}: RouterProps): ReactNode {
3244
const routes = internalRoutes(inputRoutes);
3345

3446
const currentEntry = useSyncExternalStore(
@@ -39,8 +51,8 @@ export function Router({ routes: inputRoutes }: RouterProps): ReactNode {
3951

4052
// Set up navigation interception
4153
useEffect(() => {
42-
return setupNavigationInterception(routes);
43-
}, [routes]);
54+
return setupNavigationInterception(routes, onNavigate);
55+
}, [routes, onNavigate]);
4456

4557
// Navigate function for programmatic navigation
4658
const navigate = useCallback(

packages/router/src/__tests__/Router.test.tsx

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
22
import { render, screen, act } from "@testing-library/react";
33
import { Router } from "../Router.js";
44
import { Outlet } from "../Outlet.js";
@@ -136,4 +136,110 @@ describe("Router", () => {
136136

137137
expect(screen.getByText("About")).toBeInTheDocument();
138138
});
139+
140+
describe("onNavigate callback", () => {
141+
it("calls onNavigate with NavigateEvent and matched routes before navigation", () => {
142+
const onNavigate = vi.fn();
143+
144+
const routes: RouteDefinition[] = [
145+
{ path: "/", component: () => <div>Home</div> },
146+
{ path: "/about", component: () => <div>About</div> },
147+
];
148+
149+
render(<Router routes={routes} onNavigate={onNavigate} />);
150+
151+
act(() => {
152+
const { proceed } = mockNavigation.__simulateNavigationWithEvent(
153+
"http://localhost/about",
154+
);
155+
proceed();
156+
});
157+
158+
expect(onNavigate).toHaveBeenCalledTimes(1);
159+
expect(onNavigate).toHaveBeenCalledWith(
160+
expect.objectContaining({
161+
destination: expect.objectContaining({
162+
url: "http://localhost/about",
163+
}),
164+
}),
165+
expect.arrayContaining([
166+
expect.objectContaining({
167+
pathname: "/about",
168+
}),
169+
]),
170+
);
171+
});
172+
173+
it("prevents navigation when onNavigate calls event.preventDefault()", () => {
174+
const onNavigate = vi.fn((event: NavigateEvent) => {
175+
event.preventDefault();
176+
});
177+
178+
const routes: RouteDefinition[] = [
179+
{ path: "/", component: () => <div>Home</div> },
180+
{ path: "/about", component: () => <div>About</div> },
181+
];
182+
183+
render(<Router routes={routes} onNavigate={onNavigate} />);
184+
185+
act(() => {
186+
const { proceed } = mockNavigation.__simulateNavigationWithEvent(
187+
"http://localhost/about",
188+
);
189+
proceed();
190+
});
191+
192+
// Should still show Home because navigation was prevented
193+
expect(screen.getByText("Home")).toBeInTheDocument();
194+
expect(screen.queryByText("About")).not.toBeInTheDocument();
195+
});
196+
197+
it("allows navigation when onNavigate does not call preventDefault()", () => {
198+
const onNavigate = vi.fn(); // Does not call preventDefault
199+
200+
const routes: RouteDefinition[] = [
201+
{ path: "/", component: () => <div>Home</div> },
202+
{ path: "/about", component: () => <div>About</div> },
203+
];
204+
205+
render(<Router routes={routes} onNavigate={onNavigate} />);
206+
207+
act(() => {
208+
const { proceed } = mockNavigation.__simulateNavigationWithEvent(
209+
"http://localhost/about",
210+
);
211+
proceed();
212+
});
213+
214+
expect(screen.getByText("About")).toBeInTheDocument();
215+
});
216+
217+
it("calls onNavigate with null for unmatched routes", () => {
218+
const onNavigate = vi.fn();
219+
220+
const routes: RouteDefinition[] = [
221+
{ path: "/", component: () => <div>Home</div> },
222+
];
223+
224+
render(<Router routes={routes} onNavigate={onNavigate} />);
225+
226+
act(() => {
227+
const { proceed } = mockNavigation.__simulateNavigationWithEvent(
228+
"http://localhost/unknown",
229+
);
230+
proceed();
231+
});
232+
233+
// onNavigate should be called with null for unmatched routes
234+
expect(onNavigate).toHaveBeenCalledTimes(1);
235+
expect(onNavigate).toHaveBeenCalledWith(
236+
expect.objectContaining({
237+
destination: expect.objectContaining({
238+
url: "http://localhost/unknown",
239+
}),
240+
}),
241+
null,
242+
);
243+
});
244+
});
139245
});

packages/router/src/__tests__/setup.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,41 @@ export function createMockNavigation(initialUrl = "http://localhost/") {
3838
}
3939
};
4040

41+
// Create a mock NavigateEvent
42+
const createMockNavigateEvent = (
43+
destinationUrl: string,
44+
): NavigateEvent & { defaultPrevented: boolean } => {
45+
let defaultPrevented = false;
46+
return {
47+
type: "navigate",
48+
canIntercept: true,
49+
hashChange: false,
50+
destination: {
51+
url: destinationUrl,
52+
key: `key-${entries.length}`,
53+
id: `id-${entries.length}`,
54+
index: entries.length,
55+
sameDocument: true,
56+
getState: () => undefined,
57+
},
58+
navigationType: "push",
59+
userInitiated: false,
60+
signal: new AbortController().signal,
61+
formData: null,
62+
downloadRequest: null,
63+
info: undefined,
64+
hasUAVisualTransition: false,
65+
intercept: vi.fn(),
66+
scroll: vi.fn(),
67+
get defaultPrevented() {
68+
return defaultPrevented;
69+
},
70+
preventDefault: vi.fn(() => {
71+
defaultPrevented = true;
72+
}),
73+
} as unknown as NavigateEvent & { defaultPrevented: boolean };
74+
};
75+
4176
const mockNavigation = {
4277
currentEntry,
4378
entries: () => [...entries],
@@ -88,11 +123,45 @@ export function createMockNavigation(initialUrl = "http://localhost/") {
88123
},
89124
),
90125

91-
// Test helper to simulate navigation
126+
// Test helper to simulate navigation (bypasses navigate event)
92127
__simulateNavigation(url: string, state?: unknown) {
93128
mockNavigation.navigate(url, { state });
94129
},
95130

131+
// Test helper to simulate navigation with navigate event dispatch
132+
// This allows testing of onNavigate callback behavior
133+
__simulateNavigationWithEvent(url: string): {
134+
event: NavigateEvent & { defaultPrevented: boolean };
135+
proceed: () => void;
136+
} {
137+
const newUrl = new URL(url, currentEntry.url).href;
138+
const event = createMockNavigateEvent(newUrl);
139+
140+
// Dispatch navigate event first (allows onNavigate to be called)
141+
dispatchEvent("navigate", event);
142+
143+
// Return event and a proceed function
144+
// If event.defaultPrevented is true, proceeding should be skipped
145+
return {
146+
event,
147+
proceed: () => {
148+
if (!event.defaultPrevented) {
149+
const newEntry = new MockNavigationHistoryEntry(
150+
newUrl,
151+
entries.length,
152+
);
153+
entries.push(newEntry);
154+
currentEntry = newEntry;
155+
mockNavigation.currentEntry = currentEntry;
156+
dispatchEvent(
157+
"currententrychange",
158+
new Event("currententrychange"),
159+
);
160+
}
161+
},
162+
};
163+
},
164+
96165
// Test helper to simulate traverse navigation (back/forward)
97166
// This reuses an existing entry instead of creating a new one
98167
__simulateTraversal(entryIndex: number) {

packages/router/src/core/navigation.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import type { InternalRouteDefinition, NavigateOptions } from "../types.js";
1+
import type {
2+
InternalRouteDefinition,
3+
NavigateOptions,
4+
OnNavigateCallback,
5+
} from "../types.js";
26
import { matchRoutes } from "./matchRoutes.js";
37
import { executeLoaders, createLoaderRequest } from "./loaderCache.js";
48

@@ -72,6 +76,7 @@ export function getServerSnapshot(): null {
7276
*/
7377
export function setupNavigationInterception(
7478
routes: InternalRouteDefinition[],
79+
onNavigate?: OnNavigateCallback,
7580
): () => void {
7681
if (!hasNavigation()) {
7782
return () => {};
@@ -87,6 +92,14 @@ export function setupNavigationInterception(
8792
const url = new URL(event.destination.url);
8893
const matched = matchRoutes(routes, url.pathname);
8994

95+
// Call onNavigate callback if provided (regardless of route match)
96+
if (onNavigate) {
97+
onNavigate(event, matched);
98+
if (event.defaultPrevented) {
99+
return; // Do not intercept, allow browser default
100+
}
101+
}
102+
90103
if (matched) {
91104
// Abort initial load's loaders if this is the first navigation
92105
if (idleController) {

packages/router/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type {
1919
MatchedRouteWithData,
2020
NavigateOptions,
2121
Location,
22+
OnNavigateCallback,
2223
} from "./types.js";
2324

2425
export type { LoaderArgs, RouteDefinition } from "./route.js";

packages/router/src/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,15 @@ export type Location = {
7474
search: string;
7575
hash: string;
7676
};
77+
78+
/**
79+
* Callback invoked before navigation is intercepted.
80+
* Call `event.preventDefault()` to prevent the router from handling this navigation.
81+
*
82+
* @param event - The NavigateEvent from the Navigation API
83+
* @param matched - Array of matched routes, or null if no routes matched
84+
*/
85+
export type OnNavigateCallback = (
86+
event: NavigateEvent,
87+
matched: readonly MatchedRoute[] | null,
88+
) => void;

0 commit comments

Comments
 (0)