Skip to content

Commit 8bc91c7

Browse files
yprestoclaude
andcommitted
Add link click interceptor for Next.js 15.3+ compatibility
Co-Authored-By: Claude <[email protected]>
1 parent b5b10ac commit 8bc91c7

File tree

3 files changed

+165
-3
lines changed

3 files changed

+165
-3
lines changed

src/components/NavigationGuardProvider.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import React, { useRef } from "react";
44
import { useInterceptPageUnload } from "../hooks/useInterceptPageUnload";
55
import { useInterceptPopState } from "../hooks/useInterceptPopState";
6+
import { useInterceptLinkClicks } from "../hooks/useInterceptLinkClicks";
67
import { GuardDef } from "../types";
78
import { InterceptAppRouterProvider } from "./InterceptAppRouterProvider";
89
import { InterceptPagesRouterProvider } from "./InterceptPagesRouterProvider";
@@ -17,6 +18,7 @@ export function NavigationGuardProvider({
1718

1819
useInterceptPopState({ guardMapRef });
1920
useInterceptPageUnload({ guardMapRef });
21+
useInterceptLinkClicks({ guardMapRef });
2022

2123
return (
2224
<NavigationGuardProviderContext.Provider value={guardMapRef}>
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
2+
import { MutableRefObject, useRef } from "react";
3+
import { GuardDef } from "../types";
4+
import { debug } from "../utils/debug";
5+
6+
export function useInterceptLinkClicks({
7+
guardMapRef,
8+
}: {
9+
guardMapRef: MutableRefObject<Map<string, GuardDef>>;
10+
}) {
11+
const isSetup = useRef(false);
12+
13+
useIsomorphicLayoutEffect(() => {
14+
if (typeof window === 'undefined' || isSetup.current) return;
15+
isSetup.current = true;
16+
17+
debug('Setting up link click interceptor');
18+
19+
// Function to handle link clicks
20+
const handleLinkClick = async (e: MouseEvent) => {
21+
const target = e.target as HTMLElement;
22+
const link = target.closest('a[href]') as HTMLAnchorElement;
23+
24+
if (!link) return;
25+
26+
// Skip if already being processed
27+
if (link.dataset.guardProcessing === 'true') return;
28+
29+
const href = link.getAttribute('href');
30+
if (!href) return;
31+
32+
// Skip external links
33+
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
34+
return;
35+
}
36+
37+
// Skip hash links
38+
if (href.startsWith('#')) return;
39+
40+
// Skip if it has a target attribute (opens in new window/tab)
41+
if (link.target && link.target !== '_self') return;
42+
43+
// Skip if it's a download link
44+
if (link.hasAttribute('download')) return;
45+
46+
// Check if modifier keys are pressed (Ctrl, Cmd, Shift, Alt)
47+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
48+
49+
// Check if it's a middle click (open in new tab)
50+
if (e.button !== 0) return;
51+
52+
debug(`Intercepted link click to: ${href}`);
53+
54+
// Mark as processing to prevent double-handling
55+
link.dataset.guardProcessing = 'true';
56+
57+
// Get navigation type (default to push)
58+
const navigateType = link.dataset.replace === 'true' ? 'replace' : 'push';
59+
60+
// Check guards
61+
const defs = [...guardMapRef.current.values()];
62+
const enabledGuards = defs.filter(({ enabled }) =>
63+
enabled({ to: href, type: navigateType })
64+
);
65+
66+
if (enabledGuards.length === 0) {
67+
// No guards enabled, allow navigation
68+
delete link.dataset.guardProcessing;
69+
debug('No guards enabled, allowing navigation');
70+
return;
71+
}
72+
73+
// We have guards to check - prevent default immediately for async handling
74+
e.preventDefault();
75+
e.stopPropagation();
76+
e.stopImmediatePropagation();
77+
78+
let shouldNavigate = true;
79+
80+
for (const { callback } of enabledGuards) {
81+
debug(`Calling guard callback for ${navigateType} to ${href}`);
82+
83+
try {
84+
const result = await callback({ to: href, type: navigateType });
85+
debug(`Guard callback returned: ${result}`);
86+
87+
if (!result) {
88+
shouldNavigate = false;
89+
break;
90+
}
91+
} catch (error) {
92+
debug('Guard callback error:', error);
93+
shouldNavigate = false;
94+
break;
95+
}
96+
}
97+
98+
// Clean up processing flag
99+
delete link.dataset.guardProcessing;
100+
101+
if (shouldNavigate) {
102+
debug('All guards passed, navigating programmatically');
103+
// Navigate programmatically since we prevented the default
104+
const router = (window as any).next?.router;
105+
if (router) {
106+
if (navigateType === 'replace') {
107+
router.replace(href);
108+
} else {
109+
router.push(href);
110+
}
111+
} else {
112+
// Fallback to location navigation
113+
if (navigateType === 'replace') {
114+
location.replace(href);
115+
} else {
116+
location.href = href;
117+
}
118+
}
119+
} else {
120+
debug('Navigation blocked by guard');
121+
}
122+
};
123+
124+
// Add event listener in capture phase to intercept before React
125+
document.addEventListener('click', handleLinkClick, true);
126+
127+
// Also observe DOM changes to handle dynamically added links
128+
const observer = new MutationObserver((mutations) => {
129+
mutations.forEach((mutation) => {
130+
mutation.addedNodes.forEach((node) => {
131+
if (node.nodeType === Node.ELEMENT_NODE) {
132+
const element = node as HTMLElement;
133+
// Check if the added element is a link or contains links
134+
if (element.tagName === 'A' || element.querySelector('a')) {
135+
debug('New link(s) detected in DOM');
136+
}
137+
}
138+
});
139+
});
140+
});
141+
142+
observer.observe(document.body, {
143+
childList: true,
144+
subtree: true,
145+
});
146+
147+
return () => {
148+
debug('Cleaning up link click interceptor');
149+
document.removeEventListener('click', handleLinkClick, true);
150+
observer.disconnect();
151+
};
152+
}, [guardMapRef]);
153+
}

src/hooks/useNavigationGuard.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useCallback, useContext, useId, useState } from "react";
1+
import { useCallback, useContext, useId, useRef, useState } from "react";
22
import { NavigationGuardProviderContext } from "../components/NavigationGuardProviderContext";
33
import { NavigationGuardCallback, NavigationGuardOptions } from "../types";
44
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
5+
import { debug } from "../utils/debug";
56

67
// Should memoize callback func
78
export function useNavigationGuard(options: NavigationGuardOptions) {
@@ -18,12 +19,18 @@ export function useNavigationGuard(options: NavigationGuardOptions) {
1819

1920
useIsomorphicLayoutEffect(() => {
2021
const callback: NavigationGuardCallback = (params) => {
22+
debug(`Guard callback called with:`, params);
2123
if (options.confirm) {
24+
debug(`Using sync confirm function`);
2225
return options.confirm(params);
2326
}
2427

28+
debug(`Using async confirm, setting pending state`);
2529
return new Promise<boolean>((resolve) => {
26-
setPendingState({ resolve });
30+
// Small delay to ensure state update propagates
31+
setTimeout(() => {
32+
setPendingState({ resolve });
33+
}, 0);
2734
});
2835
};
2936

@@ -37,7 +44,7 @@ export function useNavigationGuard(options: NavigationGuardOptions) {
3744
return () => {
3845
guardMapRef.current.delete(callbackId);
3946
};
40-
}, [options.confirm, options.enabled]);
47+
}, [callbackId, guardMapRef, options.confirm, options.enabled]);
4148

4249
const active = pendingState !== null;
4350

0 commit comments

Comments
 (0)