Skip to content

Instantly share code, notes, and snippets.

@run4w4y
Created July 11, 2023 19:00
Show Gist options
  • Save run4w4y/329f14be5e0574bef0be81e79074b398 to your computer and use it in GitHub Desktop.
Save run4w4y/329f14be5e0574bef0be81e79074b398 to your computer and use it in GitHub Desktop.
Attempt at getting an alternative to router events in Next.js 13 with app router
import { useCallback, useState } from "react"
import useRouteChangeEvents from "./useRouteChangeEvents"
const useLeaveConfirmation = (shouldPreventRouteChange: boolean) => {
const [showConfirmationDialog, setShowConfirmationDialog] = useState(false)
const onBeforeRouteChange = useCallback(() => {
if (shouldPreventRouteChange) {
setShowConfirmationDialog(true)
return false
}
return true
}, [shouldPreventRouteChange])
const { allowRouteChange } = useRouteChangeEvents({ onBeforeRouteChange })
return {
confirmationDialog: (
<AlertDialog open={showConfirmationDialog} onOpenChange={setShowConfirmationDialog}>
// Your alert dialog here
</AlertDialog>
)
}
}
export default useLeaveConfirmation
'use client'
import isServer from '@/common/util/isServer'
import React, { useContext, useEffect, useRef, useState } from 'react'
import { nanoid } from 'nanoid'
type HistoryURL = string | URL | null | undefined
type RouteChangeStartEvent = CustomEvent<{ targetUrl: string }>
type RouteChangeEndEvent = CustomEvent<{ targetUrl: HistoryURL }>
type ForceAnchorClickEvent = MouseEvent & { isForceAnchorClickEvent: true }
declare global {
interface WindowEventMap {
beforeRouteChangeEvent: RouteChangeStartEvent
routeChangeConfirmationEvent: RouteChangeStartEvent
routeChangeStartEvent: RouteChangeStartEvent
routeChangeEndEvent: RouteChangeEndEvent
}
}
interface FreezeRequestsContextValue {
freezeRequests: string[]
setFreezeRequests: React.Dispatch<React.SetStateAction<string[]>>
}
const FreezeRequestsContext = React.createContext<FreezeRequestsContextValue>({
freezeRequests: [],
setFreezeRequests: () => {}
})
export const useFreezeRequestsContext = () => {
const { freezeRequests, setFreezeRequests } = useContext(FreezeRequestsContext)
return {
freezeRequests,
request: (sourceId: string) => {
// console.log('route change freeze requested by: ', sourceId)
setFreezeRequests([...freezeRequests, sourceId])
},
revoke: (sourceId: string) => {
// console.log('route change freeze revoked by: ', sourceId)
setFreezeRequests(freezeRequests.filter((x) => x !== sourceId))
}
}
}
type PushStateInput = [data: unknown, unused: string, url: HistoryURL]
export const triggerRouteChangeStartEvent = (targetUrl: string): void => {
// console.log("registered route change start: ", targetUrl)
const ev = new CustomEvent('routeChangeStartEvent', { detail: { targetUrl } })
if (!isServer()) window.dispatchEvent(ev)
}
export const triggerRouteChangeEndEvent = (targetUrl: HistoryURL): void => {
// console.log("registered route change end: ", targetUrl)
const ev = new CustomEvent('routeChangeEndEvent', { detail: { targetUrl } })
if (!isServer()) window.dispatchEvent(ev)
}
export const triggerBeforeRouteChangeEvent = (targetUrl: string): void => {
// console.log("registered before route change event: ", targetUrl)
const ev = new CustomEvent('beforeRouteChangeEvent', { detail: { targetUrl } })
if (!isServer()) window.dispatchEvent(ev)
}
export const triggerRouteChangeConfirmationEvent = (targetUrl: string): void => {
// console.log("registered route change confirmation event: ", targetUrl)
const ev = new CustomEvent('routeChangeConfirmationEvent', { detail: { targetUrl } })
if (!isServer()) window.dispatchEvent(ev)
}
const createForceClickEvent = (event: MouseEvent): ForceAnchorClickEvent => {
const res = new MouseEvent('click', event) as ForceAnchorClickEvent
res.isForceAnchorClickEvent = true
return res
}
export const RouteChangesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [freezeRequests, setFreezeRequests] = useState<string[]>([])
useEffect(() => {
const abortController = new AbortController()
const handleAnchorClick = (event: MouseEvent | ForceAnchorClickEvent) => {
const target = event.currentTarget as HTMLAnchorElement
const isFrozen = freezeRequests.length !== 0
if (isFrozen && !(event as ForceAnchorClickEvent).isForceAnchorClickEvent) {
event.preventDefault()
event.stopPropagation()
window.addEventListener('routeChangeConfirmationEvent', (ev) => {
if (ev.detail.targetUrl === target.href) {
const forceClickEvent = createForceClickEvent(event)
target.dispatchEvent(forceClickEvent) // NOTE: may want to use a timeout here
}
}, { signal: abortController.signal })
triggerBeforeRouteChangeEvent(target.href)
return
}
triggerRouteChangeStartEvent(target.href)
}
const handleAnchors = (anchors: NodeListOf<HTMLAnchorElement>) => {
anchors.forEach((a) => {
a.addEventListener('click', handleAnchorClick, { signal: abortController.signal, capture: true })
})
}
const handleMutation: MutationCallback = (mutationList) => {
mutationList.forEach(record => {
if (record.type === 'childList' && record.target instanceof HTMLElement) {
const anchors: NodeListOf<HTMLAnchorElement> = record.target.querySelectorAll('a[href]')
handleAnchors(anchors)
}
})
}
const anchors: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a[href]')
handleAnchors(anchors)
const mutationObserver = new MutationObserver(handleMutation)
mutationObserver.observe(document, { childList: true, subtree: true })
const pushStateProxy = new Proxy(window.history.pushState, {
apply: (target, thisArg, argArray: PushStateInput) => {
triggerRouteChangeEndEvent(argArray[2])
return target.apply(thisArg, argArray)
},
getPrototypeOf: (target) => {
return target
},
})
window.history.pushState = pushStateProxy
return () => {
mutationObserver.disconnect()
abortController.abort()
window.history.pushState = Object.getPrototypeOf(pushStateProxy)
}
}, [freezeRequests])
return (
<FreezeRequestsContext.Provider value={{freezeRequests, setFreezeRequests}}>
{ children }
</FreezeRequestsContext.Provider>
)
}
interface RouteChangeCallbacks {
onBeforeRouteChange?: (target: string) => boolean // if `false` prevents a route change until `allowRouteChange` is called
onRouteChangeStart?: (target: string) => void
onRouteChangeComplete?: (target: HistoryURL) => void
}
const useRouteChangeEvents = (callbacks: RouteChangeCallbacks) => {
const id = useRef(nanoid())
const { request, revoke } = useFreezeRequestsContext()
const [confrimationTarget, setConfirmationTarget] = useState<string | null>(null)
useEffect(() => {
request(id.current)
return () => revoke(id.current)
}, [])
useEffect(() => {
const abortController = new AbortController()
window.addEventListener('beforeRouteChangeEvent', (ev) => {
const { targetUrl } = ev.detail
const shouldProceed = callbacks.onBeforeRouteChange && callbacks.onBeforeRouteChange(targetUrl)
if (shouldProceed ?? true) {
triggerRouteChangeConfirmationEvent(targetUrl)
} else {
setConfirmationTarget(targetUrl)
}
}, { signal: abortController.signal })
window.addEventListener('routeChangeEndEvent', (ev) => {
callbacks.onRouteChangeComplete && callbacks.onRouteChangeComplete(ev.detail.targetUrl)
}, { signal: abortController.signal })
window.addEventListener('routeChangeStartEvent', (ev) => {
callbacks.onRouteChangeStart && callbacks.onRouteChangeStart(ev.detail.targetUrl)
}, { signal: abortController.signal })
return () => abortController.abort()
}, [callbacks])
return {
allowRouteChange: () => {
if (!confrimationTarget) {
console.warn('allowRouteChange called for no specified confirmation target')
return
}
triggerRouteChangeConfirmationEvent(confrimationTarget)
}
}
}
export default useRouteChangeEvents
import { useEffect, useRef, useState } from 'react'
import { useRouter as usePrimitiveRouter } from 'next/navigation'
import { triggerBeforeRouteChangeEvent, triggerRouteChangeStartEvent, useFreezeRequestsContext } from './useRouteChangeEvents'
interface NavigateOptions {
scroll?: boolean
}
type AppRouterInstance = ReturnType<typeof usePrimitiveRouter>
const createRouterProxy = (router: AppRouterInstance, isFrozen: boolean, signal?: AbortSignal) =>
new Proxy(router, {
get: (target, prop, receiver) => {
if (prop === 'push') {
return (href: string, options?: NavigateOptions) => {
const resolvePush = () => {
triggerRouteChangeStartEvent(href)
Reflect.apply(target.push, this, [href, options])
}
if (isFrozen) {
window.addEventListener('routeChangeConfirmationEvent', (ev) => {
if (ev.detail.targetUrl === href) resolvePush()
}, { signal })
triggerBeforeRouteChangeEvent(href) // NOTE: may wanna use a timeout here
return
}
resolvePush()
}
}
return Reflect.get(target, prop, receiver)
},
})
const useRouter = (): AppRouterInstance => {
const router = usePrimitiveRouter()
const { freezeRequests } = useFreezeRequestsContext()
const abortControllerRef = useRef(new AbortController())
const [routerProxy, setRouterProxy] = useState<AppRouterInstance>(
createRouterProxy(router, freezeRequests.length !== 0, abortControllerRef.current.signal)
)
useEffect(() => {
return () => abortControllerRef.current.abort()
}, [])
useEffect(() => {
abortControllerRef.current.abort()
const abortController = new AbortController()
setRouterProxy(createRouterProxy(router, freezeRequests.length !== 0, abortController.signal))
return () => abortController.abort()
}, [router, freezeRequests])
return routerProxy
}
export default useRouter
@angelbanderasudg
Copy link

You missed isServer component

@run4w4y
Copy link
Author

run4w4y commented Aug 9, 2023

@angelbanderasudg Hello, isServer is not a component, it's just a small utility function, you can define it as
const isServer = () => typeof window === 'undefined'

@naro-Kim
Copy link

naro-Kim commented Aug 10, 2023

@run4w4y Hello! Before reading this comment, I wrote const isServer = typeof window === 'undefined'; and set all if (!isServer()) window.dispatchEvent(ev) to if (!isServer) window.dispatchEvent(ev). ; It seems fine.
Thanks for sharing nice code! You are the savior of Nextjs13 app dir

@run4w4y
Copy link
Author

run4w4y commented Aug 10, 2023

@naro-Kim You're welcome, glad to know it helped! :)

@angelbanderasudg
Copy link

@run4w4y Thanks for your response, but i don't get how I should be using it after changing all useRouter from next/navigation to yours, I see that onBeforeRouteChange don't get triggered, a little help please?, I am not sure where to call useLeaveConfirmation(true)

@run4w4y
Copy link
Author

run4w4y commented Aug 10, 2023

@angelbanderasudg I would be glad to help you! What are you trying to achieve? If your use-case is trying to prevent a user from leaving a page with, say, unsaved changes, you probably don't even need to be handling the events yourself. All you would have to do is add RouteChangesProvider somewhere in your layout and use the useLeaveConfirmation hook inside the component that would be requesting route change prevention, like so const { confirmationDialog } = useLeaveConfirmation(workingDraft.isDirty()). You can replace workingDraft.isDirty() with whatever logic to determine whether your application state was changed by the user. If your isDirty is true at a given moment, that would activate route change prevention, otherwise the user would be free to leave the page. Hope that helps :)

@run4w4y
Copy link
Author

run4w4y commented Aug 10, 2023

@angelbanderasudg If that above doesn't make a lot of sense, I would be willing to put together a small example in codesandbox or something, although I can only do that next week.
Now that I think of it, it would probably make it easier for people to use if I made this into an npm package with a proper README, wouldn't it haha? Maybe I'll do that when I have some more free time on my hands.

@angelbanderasudg
Copy link

@run4w4y Thank you very much my dumbass wasn't using the RouteChangesProvider in the layout. And yes for my use case it was enought with only using the provider and the useLeaveConfirmation hook

@run4w4y
Copy link
Author

run4w4y commented Aug 10, 2023

@angelbanderasudg Glad to know it was of help to you!

@run4w4y
Copy link
Author

run4w4y commented Aug 14, 2023

Hello, for everyone looking to use this: I got around to publishing this code as an npm package with a decent enough README! Please check it out here

@Yasmine98223
Copy link

Hello @run4w4y , thanks for your effort. you did agreat job.

i used beforeunload event listener to catch back, reload, and close using the browsers. it works with reload and close actions but don't work with back button.

Do you have any insights into why this might be happening? I'd greatly appreciate it.

@run4w4y
Copy link
Author

run4w4y commented Aug 30, 2023

@Yasmine98223 Hello, thank you for your feedback! Indeed, I have confirmed the issue on my side as well; as for why it happens: it might have something to do with how Next handles back navigation internally. I will try to look into that as soon as I have spare time to do so and see if there's something I could do about it.
I would also like to ask you to use the repository for the npm package with this code (https://github.com/run4w4y/nextjs-router-events) and open an issue there, since I'm not planning on updating this gist later on, as well as I imagine it being easier for you too :)

@Yasmine98223
Copy link

thank you for your response and effort

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment