Skip to content

Commit 4671702

Browse files
committed
feat: Open prevent page leave modal on browser back button navigation
Implemented a workaround to show the "prevent leave" modal when users click the browser back button with unsaved changes. This solution addresses a limitation in Next.js App Router which doesn't provide built-in hooks for intercepting browser history navigation events. Technical details: - Creates a duplicate history entry as a buffer to prevent immediate back navigation, allowing the prevent leave modal to be displayed. - Uses history.go(-2) to properly navigate when user confirms leaving - Re-establishes buffer state when user cancels navigation Note: This implementation specifically targets the browser back button use case, which covers the vast majority of user navigation patterns. Forward button navigation is not supported by this workaround, but I think this is an acceptable trade-off considering the complexity of fully emulating the browser's history behavior within a Next.js application.
1 parent e6aad5a commit 4671702

File tree

2 files changed

+96
-4
lines changed

2 files changed

+96
-4
lines changed

Diff for: packages/ui/src/elements/LeaveWithoutSaving/index.tsx

+85-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
'use client'
2-
import React, { useCallback } from 'react'
2+
import React, { useCallback, useEffect } from 'react'
33

44
import type { OnCancel } from '../ConfirmationModal/index.js'
55

@@ -12,34 +12,116 @@ import { usePreventLeave } from './usePreventLeave.js'
1212

1313
const modalSlug = 'leave-without-saving'
1414

15+
// Workaround to show the prevent leave page modal
16+
// when clicking the browser navigation button.
17+
// Note that this hook assumes only back button being used
18+
// and not forward button. Which is the case for most of the time.
19+
const useOnBrowserHistoryChange = ({
20+
hasAcceptedLeave,
21+
hasCancelledLeave,
22+
onLeave,
23+
onPrevent,
24+
onStay,
25+
shouldPreventLeave,
26+
skip,
27+
}: {
28+
hasAcceptedLeave: boolean
29+
hasCancelledLeave: boolean
30+
onLeave: () => void
31+
onPrevent: () => void
32+
onStay: () => void
33+
shouldPreventLeave: boolean
34+
skip: boolean
35+
}) => {
36+
// Push a duplicated history entry to the stack
37+
// so that when the popstate event is triggered
38+
// it doesn't immediately navigate away.
39+
// This creates a fake visual and we can show the
40+
// prevent leave page modal in the meantime
41+
useEffect(() => {
42+
window.history.pushState(null, '', window.location.href)
43+
}, [])
44+
45+
const handlePopState = useCallback(() => {
46+
if (!skip) {
47+
if (shouldPreventLeave) {
48+
onPrevent()
49+
} else {
50+
window.history.go(-2) // -2 because we pushed a duplicate entry
51+
}
52+
}
53+
}, [onPrevent, shouldPreventLeave, skip])
54+
55+
useEffect(() => {
56+
window.addEventListener('popstate', handlePopState)
57+
58+
return () => {
59+
window.removeEventListener('popstate', handlePopState)
60+
}
61+
}, [handlePopState])
62+
63+
useEffect(() => {
64+
if (!skip) {
65+
if (hasAcceptedLeave) {
66+
onLeave()
67+
window.history.go(-2)
68+
} else if (hasCancelledLeave) {
69+
onStay()
70+
window.history.pushState(null, '', window.location.href) // Push a duplicate entry to the stack
71+
}
72+
}
73+
}, [hasAcceptedLeave, hasCancelledLeave, onLeave, onStay, skip])
74+
}
75+
1576
export const LeaveWithoutSaving: React.FC = () => {
1677
const { closeModal, openModal } = useModal()
1778
const modified = useFormModified()
1879
const { isValid } = useForm()
1980
const { user } = useAuth()
2081
const [hasAccepted, setHasAccepted] = React.useState(false)
82+
const [hasCancelled, setHasCancelled] = React.useState(false)
2183
const { t } = useTranslation()
2284

2385
const prevent = Boolean((modified || !isValid) && user)
2486

2587
const onPrevent = useCallback(() => {
2688
openModal(modalSlug)
89+
// Reset the modal confirm and cancel state
90+
setHasAccepted(false)
91+
setHasCancelled(false)
2792
}, [openModal])
2893

2994
const handleAccept = useCallback(() => {
3095
closeModal(modalSlug)
96+
setHasAccepted(true)
3197
}, [closeModal])
3298

33-
usePreventLeave({ hasAccepted, onAccept: handleAccept, onPrevent, prevent })
34-
3599
const onCancel: OnCancel = useCallback(() => {
36100
closeModal(modalSlug)
101+
setHasCancelled(true)
37102
}, [closeModal])
38103

39104
const onConfirm = useCallback(() => {
40105
setHasAccepted(true)
41106
}, [])
42107

108+
const { preventTriggered } = usePreventLeave({
109+
hasAccepted,
110+
onAccept: handleAccept,
111+
onPrevent,
112+
prevent,
113+
})
114+
115+
useOnBrowserHistoryChange({
116+
hasAcceptedLeave: hasAccepted,
117+
hasCancelledLeave: hasCancelled,
118+
onLeave: handleAccept,
119+
onPrevent,
120+
onStay: onCancel,
121+
shouldPreventLeave: prevent,
122+
skip: preventTriggered,
123+
})
124+
43125
return (
44126
<ConfirmationModal
45127
body={t('general:changesNotSaved')}

Diff for: packages/ui/src/elements/LeaveWithoutSaving/usePreventLeave.tsx

+11-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// Credit: `react-use` maintainers
55
// - Source: https://github.com/streamich/react-use/blob/ade8d3905f544305515d010737b4ae604cc51024/src/useBeforeUnload.ts#L2
66
import { useRouter } from 'next/navigation.js'
7-
import { useCallback, useEffect, useRef } from 'react'
7+
import { useCallback, useEffect, useRef, useState } from 'react'
88

99
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
1010

@@ -79,6 +79,11 @@ export const usePreventLeave = ({
7979
const router = useRouter()
8080
const cancelledURL = useRef<string>('')
8181

82+
// This state is added to accomodate the workaround for useOnBrowserHistoryChange.
83+
// This is to prevent useOnBrowserHistoryChange fires the prevent modal too
84+
// when there's an in app navigation.
85+
const [preventTriggered, setPreventTriggered] = useState(false)
86+
8287
// check when page is about to be changed
8388
useEffect(() => {
8489
function isAnchorOfCurrentUrl(currentUrl: string, newUrl: string) {
@@ -130,6 +135,7 @@ export const usePreventLeave = ({
130135
event.stopPropagation()
131136

132137
if (typeof onPrevent === 'function') {
138+
setPreventTriggered(true)
133139
onPrevent()
134140
}
135141
}
@@ -157,4 +163,8 @@ export const usePreventLeave = ({
157163
startRouteTransition(() => router.push(cancelledURL.current))
158164
}
159165
}, [hasAccepted, onAccept, router, startRouteTransition])
166+
167+
return {
168+
preventTriggered,
169+
}
160170
}

0 commit comments

Comments
 (0)