From 46717024b7354a99f1e537b92f444bcde173f837 Mon Sep 17 00:00:00 2001 From: Amelia Lim Date: Wed, 9 Apr 2025 16:37:28 +0200 Subject: [PATCH] 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. --- .../src/elements/LeaveWithoutSaving/index.tsx | 88 ++++++++++++++++++- .../LeaveWithoutSaving/usePreventLeave.tsx | 12 ++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/elements/LeaveWithoutSaving/index.tsx b/packages/ui/src/elements/LeaveWithoutSaving/index.tsx index a5186f06113..fc447f42885 100644 --- a/packages/ui/src/elements/LeaveWithoutSaving/index.tsx +++ b/packages/ui/src/elements/LeaveWithoutSaving/index.tsx @@ -1,5 +1,5 @@ 'use client' -import React, { useCallback } from 'react' +import React, { useCallback, useEffect } from 'react' import type { OnCancel } from '../ConfirmationModal/index.js' @@ -12,34 +12,116 @@ import { usePreventLeave } from './usePreventLeave.js' const modalSlug = 'leave-without-saving' +// Workaround to show the prevent leave page modal +// when clicking the browser navigation button. +// Note that this hook assumes only back button being used +// and not forward button. Which is the case for most of the time. +const useOnBrowserHistoryChange = ({ + hasAcceptedLeave, + hasCancelledLeave, + onLeave, + onPrevent, + onStay, + shouldPreventLeave, + skip, +}: { + hasAcceptedLeave: boolean + hasCancelledLeave: boolean + onLeave: () => void + onPrevent: () => void + onStay: () => void + shouldPreventLeave: boolean + skip: boolean +}) => { + // Push a duplicated history entry to the stack + // so that when the popstate event is triggered + // it doesn't immediately navigate away. + // This creates a fake visual and we can show the + // prevent leave page modal in the meantime + useEffect(() => { + window.history.pushState(null, '', window.location.href) + }, []) + + const handlePopState = useCallback(() => { + if (!skip) { + if (shouldPreventLeave) { + onPrevent() + } else { + window.history.go(-2) // -2 because we pushed a duplicate entry + } + } + }, [onPrevent, shouldPreventLeave, skip]) + + useEffect(() => { + window.addEventListener('popstate', handlePopState) + + return () => { + window.removeEventListener('popstate', handlePopState) + } + }, [handlePopState]) + + useEffect(() => { + if (!skip) { + if (hasAcceptedLeave) { + onLeave() + window.history.go(-2) + } else if (hasCancelledLeave) { + onStay() + window.history.pushState(null, '', window.location.href) // Push a duplicate entry to the stack + } + } + }, [hasAcceptedLeave, hasCancelledLeave, onLeave, onStay, skip]) +} + export const LeaveWithoutSaving: React.FC = () => { const { closeModal, openModal } = useModal() const modified = useFormModified() const { isValid } = useForm() const { user } = useAuth() const [hasAccepted, setHasAccepted] = React.useState(false) + const [hasCancelled, setHasCancelled] = React.useState(false) const { t } = useTranslation() const prevent = Boolean((modified || !isValid) && user) const onPrevent = useCallback(() => { openModal(modalSlug) + // Reset the modal confirm and cancel state + setHasAccepted(false) + setHasCancelled(false) }, [openModal]) const handleAccept = useCallback(() => { closeModal(modalSlug) + setHasAccepted(true) }, [closeModal]) - usePreventLeave({ hasAccepted, onAccept: handleAccept, onPrevent, prevent }) - const onCancel: OnCancel = useCallback(() => { closeModal(modalSlug) + setHasCancelled(true) }, [closeModal]) const onConfirm = useCallback(() => { setHasAccepted(true) }, []) + const { preventTriggered } = usePreventLeave({ + hasAccepted, + onAccept: handleAccept, + onPrevent, + prevent, + }) + + useOnBrowserHistoryChange({ + hasAcceptedLeave: hasAccepted, + hasCancelledLeave: hasCancelled, + onLeave: handleAccept, + onPrevent, + onStay: onCancel, + shouldPreventLeave: prevent, + skip: preventTriggered, + }) + return ( ('') + // This state is added to accomodate the workaround for useOnBrowserHistoryChange. + // This is to prevent useOnBrowserHistoryChange fires the prevent modal too + // when there's an in app navigation. + const [preventTriggered, setPreventTriggered] = useState(false) + // check when page is about to be changed useEffect(() => { function isAnchorOfCurrentUrl(currentUrl: string, newUrl: string) { @@ -130,6 +135,7 @@ export const usePreventLeave = ({ event.stopPropagation() if (typeof onPrevent === 'function') { + setPreventTriggered(true) onPrevent() } } @@ -157,4 +163,8 @@ export const usePreventLeave = ({ startRouteTransition(() => router.push(cancelledURL.current)) } }, [hasAccepted, onAccept, router, startRouteTransition]) + + return { + preventTriggered, + } }