import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction, } from 'react'; export type SettingsOperationKind = 'save' | 'reset' | 'import' | 'reload'; export type SettingsOperation = { controller: AbortController; generation: number; kind: SettingsOperationKind; savedBaseline: Settings; submittedDraft: Settings; submittedDraftRevision: number; submittedServerRevision: string; submittedSignature: string; }; export type SettingsLoadAdmission = { authorityGeneration: number; draftRevision: number; draftSignature: string; }; export type SettingsSignature = (settings: Settings) => string; export function stableSettingsSignature(value: unknown): string { if (value === undefined) return 'undefined'; if (value === null) return 'null'; if (typeof value === 'string') return `string:${JSON.stringify(value)}`; if (typeof value === 'boolean') return value ? 'boolean:true' : 'boolean:false'; if (typeof value === 'number') { return Number.isFinite(value) ? `number:${value}` : `number:${String(value)}`; } if (Array.isArray(value)) { return `array:[${value.map(stableSettingsSignature).join(',')}]`; } if (typeof value === 'object') { const record = value as Record; return `object:{${Object.keys(record) .sort() .map((key) => `${JSON.stringify(key)}:${stableSettingsSignature(record[key])}`) .join(',')}}`; } return `${typeof value}:${String(value)}`; } export function useSettingsDraftLifecycle( initialSettings: Settings, signature: SettingsSignature, initialServerRevision: string, ) { const [settings, setSettingsState] = useState(initialSettings); const initialSignatureRef = useRef(signature(initialSettings)); const [savedBaseline, setSavedBaseline] = useState(initialSettings); const [savedSignature, setSavedSignature] = useState(initialSignatureRef.current); const [serverRevision, setServerRevision] = useState(initialServerRevision); const [pendingKind, setPendingKind] = useState(null); const currentDraftRef = useRef(initialSettings); const draftRevisionRef = useRef(0); const savedBaselineRef = useRef(initialSettings); const serverRevisionRef = useRef(initialServerRevision); const operationGenerationRef = useRef(0); const baselineAuthorityGenerationRef = useRef(0); const currentOperationRef = useRef | null>(null); const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; operationGenerationRef.current += 1; baselineAuthorityGenerationRef.current += 1; currentOperationRef.current?.controller.abort(); currentOperationRef.current = null; }; }, []); const setSettings = useCallback>>( (action) => { const current = currentDraftRef.current; const next = typeof action === 'function' ? (action as (previous: Settings) => Settings)(current) : action; if (signature(next) !== signature(current)) { draftRevisionRef.current += 1; } currentDraftRef.current = next; setSettingsState(next); }, [signature], ); const hasUnsavedChanges = useMemo( () => signature(settings) !== savedSignature, [savedSignature, settings, signature], ); useEffect(() => { if (!hasUnsavedChanges) return undefined; const warnBeforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ''; }; window.addEventListener('beforeunload', warnBeforeUnload); return () => window.removeEventListener('beforeunload', warnBeforeUnload); }, [hasUnsavedChanges]); const captureLoadAdmission = useCallback((): SettingsLoadAdmission => { const authorityGeneration = (baselineAuthorityGenerationRef.current += 1); return { authorityGeneration, draftRevision: draftRevisionRef.current, draftSignature: signature(currentDraftRef.current), }; }, [signature]); const applyLoadedBaseline = useCallback( (admission: SettingsLoadAdmission, baseline: Settings, revision: string): boolean => { if ( !mountedRef.current || admission.authorityGeneration !== baselineAuthorityGenerationRef.current || currentOperationRef.current ) { return false; } savedBaselineRef.current = baseline; serverRevisionRef.current = revision; setSavedBaseline(baseline); setSavedSignature(signature(baseline)); setServerRevision(revision); if ( draftRevisionRef.current === admission.draftRevision && signature(currentDraftRef.current) === admission.draftSignature ) { currentDraftRef.current = baseline; setSettingsState(baseline); } return true; }, [signature], ); const beginOperation = useCallback( (kind: SettingsOperationKind): SettingsOperation | null => { if (!mountedRef.current || currentOperationRef.current) { return null; } const submittedDraft = currentDraftRef.current; const operation: SettingsOperation = { controller: new AbortController(), generation: operationGenerationRef.current + 1, kind, savedBaseline: savedBaselineRef.current, submittedDraft, submittedDraftRevision: draftRevisionRef.current, submittedServerRevision: serverRevisionRef.current, submittedSignature: signature(submittedDraft), }; operationGenerationRef.current = operation.generation; baselineAuthorityGenerationRef.current += 1; currentOperationRef.current = operation; setPendingKind(kind); return operation; }, [signature], ); const hasCurrentOperation = useCallback(() => currentOperationRef.current !== null, []); const isCurrentOperation = useCallback((operation: SettingsOperation): boolean => { return ( mountedRef.current && !operation.controller.signal.aborted && currentOperationRef.current === operation && operationGenerationRef.current === operation.generation ); }, []); const applyOperationBaseline = useCallback( (operation: SettingsOperation, baseline: Settings, revision: string): boolean => { if (!isCurrentOperation(operation)) { return false; } savedBaselineRef.current = baseline; serverRevisionRef.current = revision; setSavedBaseline(baseline); setSavedSignature(signature(baseline)); setServerRevision(revision); if ( draftRevisionRef.current === operation.submittedDraftRevision && signature(currentDraftRef.current) === operation.submittedSignature ) { currentDraftRef.current = baseline; setSettingsState(baseline); } return true; }, [isCurrentOperation, signature], ); const applyReloadBaseline = useCallback( (operation: SettingsOperation, baseline: Settings, revision: string): boolean => { if (!isCurrentOperation(operation)) { return false; } savedBaselineRef.current = baseline; serverRevisionRef.current = revision; setSavedBaseline(baseline); setSavedSignature(signature(baseline)); setServerRevision(revision); currentDraftRef.current = baseline; draftRevisionRef.current += 1; setSettingsState(baseline); return true; }, [isCurrentOperation, signature], ); const finishOperation = useCallback( (operation: SettingsOperation): boolean => { if (!isCurrentOperation(operation)) { return false; } currentOperationRef.current = null; setPendingKind(null); return true; }, [isCurrentOperation], ); return { settings, setSettings, savedBaseline, serverRevision, hasUnsavedChanges, pendingKind, captureLoadAdmission, applyLoadedBaseline, beginOperation, hasCurrentOperation, isCurrentOperation, applyOperationBaseline, applyReloadBaseline, finishOperation, }; }