ファイル
btrc-hub/frontend/src/lib/useUnsavedChangesGuard.tsx
T
2026-10-20 20:44:14 +09:00

209 行
5.4 KiB
TypeScript

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<void> }
type UseUnsavedChangesGuardOptions = {
dirty: boolean
onDiscard?: () => void | Promise<void> }
type UnsavedChangesGuardContextValue = {
hasUnsavedChanges: boolean
registerUnsavedChangesSource: (
source: UnsavedChangesSource,
) => () => void
allowNextNavigation: () => void }
const UnsavedChangesGuardContext =
createContext<UnsavedChangesGuardContextValue | null> (null)
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
const dialogue = useDialogue ()
const { label } = useUITranslation ('settings')
const sourcesRef = useRef (new Map<symbol, UnsavedChangesSource> ())
const bypassNextNavigationRef = useRef<symbol | null> (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<boolean> => {
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<void | Promise<void>>
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<UnsavedChangesGuardContextValue> (() => ({
hasUnsavedChanges,
registerUnsavedChangesSource,
allowNextNavigation,
}), [
allowNextNavigation,
hasUnsavedChanges,
registerUnsavedChangesSource,
])
return (
<UnsavedChangesGuardContext.Provider value={value}>
{children}
</UnsavedChangesGuardContext.Provider>)
}
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
}