## The Real Solution: Capture Phase + Role Detection Short answer: **Your approach #3 is actually the right one**, but it needs two critical refinements to work reliably. ### The Working Pattern ```tsx 'use client' import { useEffect } from 'react' import { usePathname, useSearchParams } from 'next/navigation' export function NavigationLoader() { const pathname = usePathname() const searchParams = useSearchParams() const { showLoader, hideLoader } = useLoadingContext() // Hide when route change *completes* useEffect(() => { hideLoader() }, [pathname, searchParams, hideLoader]) // Show on any navigation *start* useEffect(() => { function handleClick(e: MouseEvent) { const target = e.target as HTMLElement // Catch Next.js and plain tags const anchor = target.closest('a') if (anchor) { const href = anchor.getAttribute('href') if (!href || href.startsWith('#') || href.startsWith('mailto:') || anchor.target === '_blank') return if (href.startsWith('http') && !href.startsWith(window.location.origin)) return showLoader() return } // Catch role="link" divs (e.g. cards that use router.push()) if (target.closest('[role="link"]')) { showLoader() } } // ← **KEY #1: Use capture phase** so stopPropagation() doesn't block us document.addEventListener('click', handleClick, true) return () => document.removeEventListener('click', handleClick, true) }, [showLoader]) return null } ``` ### Why Your Attempt #3 Didn't Work You had the right idea but missed **the capture phase**: ```tsx // ❌ Your version (bubble phase) document.addEventListener('click', handleToggle) // ✅ Correct version (capture phase) document.addEventListener('click', handleToggle, true) ``` **Why it matters:** Child components (like your NavLink) call `e.stopPropagation()` during the bubble phase. In capture phase, your listener fires *before* child handlers, so their stopPropagation can't block you. ### What This Still Doesn't Catch Programmatic navigation **outside of click handlers** (your concern): ```tsx // These won't trigger the listener: router.push('/dashboard') // called from useEffect or button.onClick setTimeout(() => router.replace('/next'), 1000) ``` **Reality check:** In practice, ~95% of navigation is from user clicks. For the edge case of async programmatic navigation, you have two options: 1. **Wrap your router calls:** ```tsx function navigate(path: string) { showLoader() router.push(path) } ``` 2. **Use `useTransition()` for server actions:** ```tsx const [isPending, startTransition] = useTransition() useEffect(() => { isPending ? showLoader() : hideLoader() }, [isPending]) ``` The monkey-patching approach you tried (#2) doesn't work because Next.js captures router methods at module init before your patch runs—this is by design to prevent precisely this kind of interference. ### Summary - ✅ Capture phase event listeners catch user clicks reliably - ✅ Handle both `` tags and `role="link"` divs - ✅ Filter out hash links, external links, blank targets - ⚠️ Programmatic navigation needs separate handling (rare in practice) - ❌ Monkey-patching won't work; don't waste time there
cfc9e046-72cd-416b-a98c-cdce2b31cdf7
The Real Solution: Capture Phase + Role Detection
Short answer: Your approach #3 is actually the right one, but it needs two critical refinements to work reliably.
The Working Pattern
'use client'
import { useEffect } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
export function NavigationLoader() {
const pathname = usePathname()
const searchParams = useSearchParams()
const { showLoader, hideLoader } = useLoadingContext()
// Hide when route change *completes*
useEffect(() => {
hideLoader()
}, [pathname, searchParams, hideLoader])
// Show on any navigation *start*
useEffect(() => {
function handleClick(e: MouseEvent) {
const target = e.target as HTMLElement
// Catch Next.js
and plain tags
const anchor = target.closest('a')
if (anchor) {
const href = anchor.getAttribute('href')
if (!href || href.startsWith('#') || href.startsWith('mailto:') || anchor.target === '_blank') return
if (href.startsWith('http') && !href.startsWith(window.location.origin)) return
showLoader()
return
}
// Catch role="link" divs (e.g. cards that use router.push())
if (target.closest('[role="link"]')) {
showLoader()
}
}
// ← **KEY #1: Use capture phase** so stopPropagation() doesn't block us
document.addEventListener('click', handleClick, true)
return () => document.removeEventListener('click', handleClick, true)
}, [showLoader])
return null
}Why Your Attempt #3 Didn't Work
You had the right idea but missed the capture phase:
// ❌ Your version (bubble phase)
document.addEventListener('click', handleToggle)
// ✅ Correct version (capture phase)
document.addEventListener('click', handleToggle, true)Why it matters: Child components (like your NavLink) call e.stopPropagation() during the bubble phase. In capture phase, your listener fires before child handlers, so their stopPropagation can't block you.
What This Still Doesn't Catch
Programmatic navigation outside of click handlers (your concern):
// These won't trigger the listener:
router.push('/dashboard') // called from useEffect or button.onClick
setTimeout(() => router.replace('/next'), 1000)Reality check: In practice, ~95% of navigation is from user clicks. For the edge case of async programmatic navigation, you have two options:
Wrap your router calls:
function navigate(path: string) { showLoader() router.push(path) }Use
useTransition()for server actions:const [isPending, startTransition] = useTransition() useEffect(() => { isPending ? showLoader() : hideLoader() }, [isPending])
The monkey-patching approach you tried (#2) doesn't work because Next.js captures router methods at module init before your patch runs—this is by design to prevent precisely this kind of interference.
Summary
- ✅ Capture phase event listeners catch user clicks reliably
- ✅ Handle both `` tags and
role="link"divs - ✅ Filter out hash links, external links, blank targets
- ⚠️ Programmatic navigation needs separate handling (rare in practice)
- ❌ Monkey-patching won't work; don't waste time there