import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react' import { useBlocker } from 'react-router-dom' import { useDialogue } from '@/lib/dialogues/useDialogue' import { useUITranslation } from '@/lib/useUITranslation' import type { FC, PropsWithChildren } from 'react' type UnsavedChangesSource = { dirty: boolean discard: () => void | Promise } type UseUnsavedChangesGuardOptions = { dirty: boolean onDiscard?: () => void | Promise } type UnsavedChangesGuardContextValue = { hasUnsavedChanges: boolean registerUnsavedChangesSource: ( source: UnsavedChangesSource, ) => () => void allowNextNavigation: () => void } const UnsavedChangesGuardContext = createContext (null) export const UnsavedChangesGuardProvider: FC = ({ children }) => { const dialogue = useDialogue () const { label } = useUITranslation ('settings') const sourcesRef = useRef (new Map ()) const bypassNextNavigationRef = useRef (null) const [revision, setRevision] = useState (0) const dialogueOpenRef = useRef (false) const handlingBlockedTransitionRef = useRef (false) const registerUnsavedChangesSource = useCallback (( source: UnsavedChangesSource, ): (() => void) => { const sourceId = Symbol ('unsaved-changes-source') sourcesRef.current.set (sourceId, source) setRevision (current => current + 1) return () => { if (!(sourcesRef.current.delete (sourceId))) return setRevision (current => current + 1) } }, []) const allowNextNavigation = useCallback (() => { const token = Symbol ('allowed-navigation') bypassNextNavigationRef.current = token queueMicrotask (() => { if (bypassNextNavigationRef.current === token) bypassNextNavigationRef.current = null }) }, []) const sources = useMemo ( () => [...sourcesRef.current.values ()], [revision], ) const dirtySources = useMemo ( () => sources.filter (source => source.dirty), [sources], ) const hasUnsavedChanges = dirtySources.length > 0 const hasUnsavedChangesRef = useRef (hasUnsavedChanges) hasUnsavedChangesRef.current = hasUnsavedChanges const shouldBlock = useCallback (() => { if (bypassNextNavigationRef.current != null) { bypassNextNavigationRef.current = null return false } return hasUnsavedChangesRef.current }, []) const blocker = useBlocker (shouldBlock) const confirmDiscardChanges = useCallback (async (): Promise => { if (!(hasUnsavedChanges)) return true if (dialogueOpenRef.current) return false dialogueOpenRef.current = true try { const confirmed = await dialogue.confirm ({ title: label ('unsaved.leavePage'), confirmText: label ('unsaved.discardAndMove'), cancelText: label ('actions.cancel'), closeLabel: label ('actions.close'), variant: 'danger' }) if (!(confirmed)) return false return true } finally { dialogueOpenRef.current = false } }, [dialogue, hasUnsavedChanges, label]) useEffect (() => { if (blocker.state !== 'blocked' || handlingBlockedTransitionRef.current) return handlingBlockedTransitionRef.current = true void (async () => { try { const confirmed = await confirmDiscardChanges () if (!(confirmed)) { blocker.reset () return } let discardResults: Array> try { discardResults = dirtySources.map (source => source.discard ()) } catch { blocker.reset () return } blocker.proceed () await Promise.allSettled (discardResults) } finally { handlingBlockedTransitionRef.current = false } }) () }, [blocker, confirmDiscardChanges, dirtySources]) useEffect (() => { if (!(hasUnsavedChanges)) return const handleBeforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault () event.returnValue = '' } window.addEventListener ('beforeunload', handleBeforeUnload) return () => { window.removeEventListener ('beforeunload', handleBeforeUnload) } }, [hasUnsavedChanges]) const value = useMemo (() => ({ hasUnsavedChanges, registerUnsavedChangesSource, allowNextNavigation, }), [ allowNextNavigation, hasUnsavedChanges, registerUnsavedChangesSource, ]) return ( {children} ) } export const useUnsavedChangesGuard = ( options?: UseUnsavedChangesGuardOptions, ): UnsavedChangesGuardContextValue => { const context = useContext (UnsavedChangesGuardContext) if (context == null) throw new Error ('UnsavedChangesGuardProvider が必要です.') const { registerUnsavedChangesSource } = context useEffect (() => { if (options == null) return return registerUnsavedChangesSource ({ dirty: options.dirty, discard: options.onDiscard ?? (() => undefined) }) }, [options?.dirty, options?.onDiscard, registerUnsavedChangesSource]) return context }