2026-10-07 10:07:03 -07:00
import React , { createContext , useCallback , useContext , useEffect , useMemo , useRef , useState , ReactNode } from 'react' ;
2026-10-07 09:57:11 -07:00
import { useEditorConfig } from './EditorConfigContext' ;
export type LoadState = 'loading' | 'loaded' | 'failed' ;
interface SaveGuardContextValue {
/** Nothing may be saved until this is 'loaded' -- a failed load leaves the
* canvas blank, and saving that would overwrite the customer's site. */
loadState : LoadState ;
setLoadState : ( s : LoadState ) => void ;
/** Server refused a save that would replace a non-empty site with an empty
* one (409 empty_overwrite_refused). Holds the message while the banner is up. */
emptyRefusal : string | null ;
setEmptyRefusal : ( message : string | null ) => void ;
/** One-shot: the next save sends force_empty (consumed by the next successful save). */
forceEmptyNextSave : React.MutableRefObject < boolean >;
armForceEmptyNextSave : () => void ;
2026-10-07 10:07:03 -07:00
/** True when the editor holds changes (canvas, pages, design, header/footer)
* made after the last successful save or load. Cleared only by a save. */
dirty : boolean ;
/** Synchronous read of `dirty`, for handlers that must not see a stale render. */
isDirty : () => boolean ;
/** Record a change. Ignored until the site has loaded (load itself rewrites state). */
markDirty : () => void ;
2026-10-07 10:09:26 -07:00
/** Monotonic change counter: compare two reads to learn whether anything changed in between. */
getVersion : () => number ;
2026-10-07 10:07:03 -07:00
/** Call right before serializing for a save; pass the ticket to `endSave`. */
beginSave : () => SaveTicket ;
/** Finish a save. Clears dirty only if it succeeded AND nothing changed since `beginSave`. */
endSave : ( ticket : SaveTicket , succeeded : boolean ) => void ;
/** True while at least one save request is in flight. */
saving : boolean ;
/** Skip the unsaved-changes prompt for the next unload (e.g. a deliberate "Reload latest"). */
suppressBeforeUnload : () => void ;
2026-10-07 10:19:07 -07:00
/** Server revision the editor last loaded or saved: a string, null (no project on the
* server yet), or undefined (unknown -- an older backend sent none, so saves skip the check). */
getRev : () => string | null | undefined ;
setRev : ( rev : string | null ) => void ;
/** Server message while the "changed in another tab" modal is up; autosave stays paused. */
conflict : string | null ;
setConflict : ( message : string | null ) => void ;
/** One-shot: the next save sends force_overwrite (armed by "Keep mine", spent by a save that goes through). */
forceOverwriteNextSave : React.MutableRefObject < boolean >;
armForceOverwriteNextSave : () => void ;
2026-10-07 10:20:45 -07:00
/** Cancel an armed overwrite (the customer backed out of the follow-up empty-save prompt). */
disarmForceOverwriteNextSave : () => void ;
2026-10-07 10:19:07 -07:00
/** "Reload latest": skip the unsaved-changes prompt, then reload the whole editor. */
reloadLatest : () => void ;
2026-10-07 10:07:03 -07:00
}
/** Identifies the editor state a save was built from. */
export interface SaveTicket {
version : number ;
2026-10-07 09:57:11 -07:00
}
// Default (no provider: standalone mode and isolated unit tests) is
// permissive -- the gate only applies inside <SaveGuardProvider> in WHP mode.
const defaultValue : SaveGuardContextValue = {
loadState : 'loaded' ,
setLoadState : () => {},
emptyRefusal : null ,
setEmptyRefusal : () => {},
forceEmptyNextSave : { current : false },
armForceEmptyNextSave : () => {},
2026-10-07 10:07:03 -07:00
dirty : false ,
isDirty : () => false ,
2026-10-07 10:09:26 -07:00
getVersion : () => 0 ,
2026-10-07 10:07:03 -07:00
markDirty : () => {},
beginSave : () => ({ version : 0 }),
endSave : () => {},
saving : false ,
suppressBeforeUnload : () => {},
2026-10-07 10:19:07 -07:00
getRev : () => undefined ,
setRev : () => {},
conflict : null ,
setConflict : () => {},
forceOverwriteNextSave : { current : false },
armForceOverwriteNextSave : () => {},
2026-10-07 10:20:45 -07:00
disarmForceOverwriteNextSave : () => {},
2026-10-07 10:19:07 -07:00
reloadLatest : () => {},
2026-10-07 09:57:11 -07:00
};
const SaveGuardContext = createContext < SaveGuardContextValue >( defaultValue );
export const useSaveGuard = () => useContext ( SaveGuardContext );
2026-10-07 10:19:07 -07:00
export const SaveGuardProvider : React.FC < { children : ReactNode ; /** Injectable for tests; defaults to a full page reload. */ reloadPage ?: () => void } > = ({ children , reloadPage }) => {
2026-10-07 09:57:11 -07:00
const { isWHP } = useEditorConfig ();
const [ loadState , setLoadState ] = useState < LoadState >( isWHP ? 'loading' : 'loaded' );
const [ emptyRefusal , setEmptyRefusal ] = useState < string | null >( null );
const forceEmptyNextSave = useRef ( false );
2026-10-07 10:19:07 -07:00
const [ conflict , setConflict ] = useState < string | null >( null );
const forceOverwriteNextSave = useRef ( false );
const revRef = useRef < string | null | undefined >( undefined );
const getRev = useCallback (() => revRef . current , []);
const setRev = useCallback (( rev : string | null ) => { revRef . current = rev ; }, []);
2026-10-07 09:57:11 -07:00
2026-10-07 10:07:03 -07:00
// Dirty tracking. `versionRef` counts changes so a save that raced with an
// edit cannot mark that edit clean.
const [ dirty , setDirty ] = useState ( false );
const [ savesInFlight , setSavesInFlight ] = useState ( 0 );
const dirtyRef = useRef ( false );
const versionRef = useRef ( 0 );
const loadedRef = useRef ( loadState === 'loaded' );
const suppressRef = useRef ( false );
// A (re)load replaces the whole editor state: it is the new clean baseline.
useEffect (() => {
loadedRef . current = loadState === 'loaded' ;
if ( loadState === 'loaded' ) {
dirtyRef . current = false ;
setDirty ( false );
}
}, [ loadState ]);
const markDirty = useCallback (() => {
if ( ! loadedRef . current ) return ;
versionRef . current += 1 ;
dirtyRef . current = true ;
setDirty ( true );
}, []);
const isDirty = useCallback (() => dirtyRef . current , []);
2026-10-07 10:09:26 -07:00
const getVersion = useCallback (() => versionRef . current , []);
2026-10-07 10:07:03 -07:00
const beginSave = useCallback (() : SaveTicket => {
setSavesInFlight (( n ) => n + 1 );
return { version : versionRef.current };
}, []);
const endSave = useCallback (( ticket : SaveTicket , succeeded : boolean ) => {
setSavesInFlight (( n ) => Math . max ( 0 , n - 1 ));
if ( succeeded && ticket . version === versionRef . current ) {
dirtyRef . current = false ;
setDirty ( false );
}
}, []);
const suppressBeforeUnload = useCallback (() => { suppressRef . current = true ; }, []);
2026-10-07 10:19:07 -07:00
const reloadLatest = useCallback (() => {
suppressRef . current = true ;
( reloadPage ?? (() => window . location . reload ()))();
}, [ reloadPage ]);
2026-10-07 10:07:03 -07:00
const saving = savesInFlight > 0 ;
const needsPrompt = isWHP && ( dirty || saving );
useEffect (() => {
if ( ! needsPrompt ) return ;
const onBeforeUnload = ( e : BeforeUnloadEvent ) => {
if ( suppressRef . current ) return ;
e . preventDefault ();
e . returnValue = '' ;
};
window . addEventListener ( 'beforeunload' , onBeforeUnload );
return () => window . removeEventListener ( 'beforeunload' , onBeforeUnload );
}, [ needsPrompt ]);
2026-10-07 09:57:11 -07:00
const value = useMemo < SaveGuardContextValue >(() => ({
loadState ,
setLoadState ,
emptyRefusal ,
setEmptyRefusal ,
forceEmptyNextSave ,
armForceEmptyNextSave : () => { forceEmptyNextSave . current = true ; },
2026-10-07 10:07:03 -07:00
dirty ,
isDirty ,
2026-10-07 10:09:26 -07:00
getVersion ,
2026-10-07 10:07:03 -07:00
markDirty ,
beginSave ,
endSave ,
saving ,
suppressBeforeUnload ,
2026-10-07 10:19:07 -07:00
getRev ,
setRev ,
conflict ,
setConflict ,
forceOverwriteNextSave ,
armForceOverwriteNextSave : () => { forceOverwriteNextSave . current = true ; },
2026-10-07 10:20:45 -07:00
disarmForceOverwriteNextSave : () => { forceOverwriteNextSave . current = false ; },
2026-10-07 10:19:07 -07:00
reloadLatest ,
}), [ loadState , emptyRefusal , dirty , saving , isDirty , getVersion , markDirty , beginSave , endSave , suppressBeforeUnload , getRev , setRev , conflict , reloadLatest ]);
2026-10-07 09:57:11 -07:00
return < SaveGuardContext.Provider value = { value }>{ children }</ SaveGuardContext.Provider >;
};