-
-
Save run4w4y/329f14be5e0574bef0be81e79074b398 to your computer and use it in GitHub Desktop.
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 Hello, isServer
is not a component, it's just a small utility function, you can define it as
const isServer = () => typeof window === 'undefined'
@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
@naro-Kim You're welcome, glad to know it helped! :)
@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)
@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 :)
@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.
@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
@angelbanderasudg Glad to know it was of help to you!
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
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.
@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 :)
thank you for your response and effort
You missed isServer component