From 3ec650557ed4ca4425341f9f9673e56be5b9e2de Mon Sep 17 00:00:00 2001 From: Trevor Buckner Date: Mon, 22 Sep 2025 19:49:57 -0400 Subject: [PATCH 1/3] Fix Autosave and unsaved changes warning Use normal setTimeout for autosave instead of _.debounce. Fixes a lot of issues with functional component. Also fix existing bug where multiple "unsaved data" warnings could be queued up if the user keeps typing while the warning is being displayed. --- client/homebrew/pages/editPage/editPage.jsx | 157 ++++++++++---------- 1 file changed, 80 insertions(+), 77 deletions(-) diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index 25e2b152a..3fd0b8638 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -36,7 +36,7 @@ import { updateHistory, versionHistoryGarbageCollection } from '../../utils/vers import googleDriveIcon from '../../googleDrive.svg'; const SAVE_TIMEOUT = 10000; -const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes +const UNSAVED_WARNING_TIMEOUT = 9000; //Warn user afer 15 minutes of unsaved changes const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds const EditPage = (props)=>{ @@ -47,6 +47,7 @@ const EditPage = (props)=>{ const [currentBrew , setCurrentBrew ] = useState(props.brew); const [isSaving , setIsSaving ] = useState(false); + const [lastSavedTime , setLastSavedTime ] = useState(new Date()); const [saveGoogle , setSaveGoogle ] = useState(!!props.brew.googleId); const [error , setError ] = useState(null); const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text)); @@ -58,56 +59,56 @@ const EditPage = (props)=>{ const [alertTrashedGoogleBrew , setAlertTrashedGoogleBrew ] = useState(props.brew.trashed); const [alertLoginToTransfer , setAlertLoginToTransfer ] = useState(false); const [confirmGoogleTransfer , setConfirmGoogleTransfer ] = useState(false); - const [url , setUrl ] = useState(''); const [autoSaveEnabled , setAutoSaveEnabled ] = useState(true); - const [autoSaveWarning , setAutoSaveWarning ] = useState(true); - const [unsavedTime , setUnsavedTime ] = useState(new Date()); + const [warnUnsavedChanges , setWarnUnsavedChanges ] = useState(true); - const editorRef = useRef(null); - const savedBrew = useRef(_.cloneDeep(props.brew)); - const warningTimer = useRef(null); - const debounceSave = useCallback(_.debounce((brew, saveToGoogle)=>save(brew, saveToGoogle), SAVE_TIMEOUT), []); + const editorRef = useRef(null); + const lastSavedBrew = useRef(_.cloneDeep(props.brew)); + const saveTimeout = useRef(null); + const warnUnsavedTimeout = useRef(null); + const trySaveRef = useRef(trySave); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew + const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges useEffect(()=>{ - setUrl(window.location.href); - const autoSavePref = JSON.parse(localStorage.getItem('AUTOSAVE_ON') ?? true); setAutoSaveEnabled(autoSavePref); - setAutoSaveWarning(!autoSavePref); + setWarnUnsavedChanges(!autoSavePref); setHTMLErrors(Markdown.validate(currentBrew.text)); fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme); - document.addEventListener('keydown', handleControlKeys); - window.onbeforeunload = ()=>{ - if(isSaving || unsavedChanges) - return 'You have unsaved changes!'; + const handleControlKeys = (e) => { + if (!(e.ctrlKey || e.metaKey)) return; + if (e.keyCode === 83) trySaveRef.current(true); + if (e.keyCode === 80) printCurrentBrew(); + if ([83, 80].includes(e.keyCode)) { + e.stopPropagation(); + e.preventDefault(); + } }; - return ()=>{ + document.addEventListener('keydown', handleControlKeys); + window.onbeforeunload = ()=>{ + if(unsavedChangesRef.current) + return 'You have unsaved changes!'; + }; + return () => { document.removeEventListener('keydown', handleControlKeys); - window.onbeforeunload = null; + window.onBeforeUnload = null; }; }, []); useEffect(()=>{ - const hasChange = !_.isEqual(currentBrew, savedBrew.current); + trySaveRef.current = trySave; + unsavedChangesRef.current = unsavedChanges; + }); + + useEffect(()=>{ + const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current); setUnsavedChanges(hasChange); - if(hasChange && autoSaveEnabled) trySave(); + if(autoSaveEnabled) trySave(false, hasChange); }, [currentBrew]); - const handleControlKeys = (e)=>{ - if(!(e.ctrlKey || e.metaKey)) return; - const S_KEY = 83; - const P_KEY = 80; - if(e.keyCode === S_KEY) trySave(true); - if(e.keyCode === P_KEY) printCurrentBrew(); - if(e.keyCode === S_KEY || e.keyCode === P_KEY) { - e.stopPropagation(); - e.preventDefault(); - } - }; - const handleSplitMove = ()=>{ editorRef.current?.update(); }; @@ -156,22 +157,10 @@ const EditPage = (props)=>{ snippets : newData.snippets })); - const trySave = (immediate = false)=>{ - //debounceSave = _.debounce(save, SAVE_TIMEOUT); - if(isSaving) return; - - const hasChange = !_.isEqual(currentBrew, savedBrew.current); - - if(immediate) { - debounceSave(currentBrew, saveGoogle); - debounceSave.flush?.(); - return; - } - - if(hasChange) - debounceSave(currentBrew, saveGoogle); - else - debounceSave.cancel?.(); + const resetWarnUnsavedTimer = ()=>{ + setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds + clearTimeout(warnUnsavedTimeout.current); + warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings }; const handleGoogleClick = ()=>{ @@ -197,11 +186,26 @@ const EditPage = (props)=>{ trySave(true); }; - const save = async (brew, saveToGoogle)=>{ - debounceSave?.cancel?.(); + const trySave = (immediate = false, hasChanges = true)=>{ + clearTimeout(saveTimeout.current); + if(isSaving) return; + if(!hasChanges && !immediate) return; + const newTimeout = immediate ? 0 : SAVE_TIMEOUT; - setIsSaving(true); - setError(null); + saveTimeout.current = setTimeout(async () => { + setIsSaving(true); + setError(null); + await save(currentBrew, saveGoogle) + .catch((err)=>{ + setError(err); + }); + setIsSaving(false); + setLastSavedTime(new Date()); + if(!autoSaveEnabled) resetWarnUnsavedTimer(); + }, newTimeout); + }; + + const save = async (brew, saveToGoogle)=>{ setHTMLErrors(Markdown.validate(brew.text)); await updateHistory(brew).catch(console.error); @@ -212,9 +216,10 @@ const EditPage = (props)=>{ ...brew, text : brew.text.normalize('NFC'), pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1, - patches : stringifyPatches(makePatches(encodeURI(savedBrew.current.text.normalize('NFC')), encodeURI(brew.text.normalize('NFC')))), - hash : await md5(savedBrew.current.text), - textBin : undefined + patches : stringifyPatches(makePatches(encodeURI(lastSavedBrew.current.text.normalize('NFC')), encodeURI(brew.text.normalize('NFC')))), + hash : await md5(lastSavedBrew.current.text), + textBin : undefined, + version : lastSavedBrew.current.version }; const compressedBrew = gzipSync(strToU8(JSON.stringify(brewToSave))); @@ -232,22 +237,24 @@ const EditPage = (props)=>{ }); if(!res) return; - const { googleId, editId, shareId, version } = res.body; - - savedBrew.current = { - ...brew, - googleId : googleId ?? null, - editId, - shareId, - version + const updatedFields = { + googleId: res.body.googleId ?? null, + editId : res.body.editId, + shareId : res.body.shareId, + version : res.body.version }; - setCurrentBrew(savedBrew.current); + lastSavedBrew.current = { + ...brew, + ...updatedFields + }; - setIsSaving(false); - setUnsavedTime(new Date()); + setCurrentBrew((prevBrew)=>({ + ...prevBrew, + ...updatedFields + })); - history.replaceState(null, null, `/edit/${editId}`); + history.replaceState(null, null, `/edit/${res.body.editId}`); }; const renderGoogleDriveIcon = ()=>( @@ -268,7 +275,7 @@ const EditPage = (props)=>{ {alertLoginToTransfer && (
You must be signed in to a Google account to transfer between the homebrewery and Google Drive! - +
Sign In
Not Now
@@ -291,9 +298,9 @@ const EditPage = (props)=>{ return saving...; // #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING - if(unsavedChanges && autoSaveWarning) { - resetAutoSaveWarning(); - const elapsedTime = Math.round((new Date() - unsavedTime) / 1000 / 60); + if(unsavedChanges && warnUnsavedChanges) { + resetWarnUnsavedTimer(); + const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60); const text = elapsedTime === 0 ? 'Autosave is OFF.' : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`; @@ -317,15 +324,11 @@ const EditPage = (props)=>{ }; const toggleAutoSave = ()=>{ - if(warningTimer.current) clearTimeout(warningTimer.current); + clearTimeout(warnUnsavedTimeout.current); + clearTimeout(saveTimeout.current); localStorage.setItem('AUTOSAVE_ON', JSON.stringify(!autoSaveEnabled)); - setAutoSaveWarning(autoSaveWarning); setAutoSaveEnabled(!autoSaveEnabled); - }; - - const resetAutoSaveWarning = ()=>{ - setTimeout(()=>setAutoSaveWarning(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds - warningTimer.current = setTimeout(()=>setAutoSaveWarning(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved changes warnings + setWarnUnsavedChanges(autoSaveEnabled); }; const renderAutoSaveButton = ()=>( From f0baa763ecab11090a53b51a3749417a78f7b2cd Mon Sep 17 00:00:00 2001 From: Trevor Buckner Date: Mon, 22 Sep 2025 19:52:42 -0400 Subject: [PATCH 2/3] lint --- client/homebrew/pages/editPage/editPage.jsx | 36 ++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index 3fd0b8638..6a87c8d30 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -66,7 +66,7 @@ const EditPage = (props)=>{ const lastSavedBrew = useRef(_.cloneDeep(props.brew)); const saveTimeout = useRef(null); const warnUnsavedTimeout = useRef(null); - const trySaveRef = useRef(trySave); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew + const trySaveRef = useRef(trySave); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges useEffect(()=>{ @@ -76,11 +76,11 @@ const EditPage = (props)=>{ setHTMLErrors(Markdown.validate(currentBrew.text)); fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme); - const handleControlKeys = (e) => { - if (!(e.ctrlKey || e.metaKey)) return; - if (e.keyCode === 83) trySaveRef.current(true); - if (e.keyCode === 80) printCurrentBrew(); - if ([83, 80].includes(e.keyCode)) { + const handleControlKeys = (e)=>{ + if(!(e.ctrlKey || e.metaKey)) return; + if(e.keyCode === 83) trySaveRef.current(true); + if(e.keyCode === 80) printCurrentBrew(); + if([83, 80].includes(e.keyCode)) { e.stopPropagation(); e.preventDefault(); } @@ -91,7 +91,7 @@ const EditPage = (props)=>{ if(unsavedChangesRef.current) return 'You have unsaved changes!'; }; - return () => { + return ()=>{ document.removeEventListener('keydown', handleControlKeys); window.onBeforeUnload = null; }; @@ -160,7 +160,7 @@ const EditPage = (props)=>{ const resetWarnUnsavedTimer = ()=>{ setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds clearTimeout(warnUnsavedTimeout.current); - warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings + warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings }; const handleGoogleClick = ()=>{ @@ -192,7 +192,7 @@ const EditPage = (props)=>{ if(!hasChanges && !immediate) return; const newTimeout = immediate ? 0 : SAVE_TIMEOUT; - saveTimeout.current = setTimeout(async () => { + saveTimeout.current = setTimeout(async ()=>{ setIsSaving(true); setError(null); await save(currentBrew, saveGoogle) @@ -238,10 +238,10 @@ const EditPage = (props)=>{ if(!res) return; const updatedFields = { - googleId: res.body.googleId ?? null, - editId : res.body.editId, - shareId : res.body.shareId, - version : res.body.version + googleId : res.body.googleId ?? null, + editId : res.body.editId, + shareId : res.body.shareId, + version : res.body.version }; lastSavedBrew.current = { @@ -307,8 +307,8 @@ const EditPage = (props)=>{ return Reminder... -
{text}
-
+
{text}
+ ; } // #3 - Unsaved changes exist, click to save, show SAVE NOW @@ -353,9 +353,9 @@ const EditPage = (props)=>{ {error ? : - {renderSaveButton()} - {renderAutoSaveButton()} - } + {renderSaveButton()} + {renderAutoSaveButton()} + } From c5071aa27eeb383ec34374b27a077b0179aa91e8 Mon Sep 17 00:00:00 2001 From: Trevor Buckner Date: Mon, 22 Sep 2025 19:55:39 -0400 Subject: [PATCH 3/3] Restore unsaved warning timeout duration to 15 mins --- client/homebrew/pages/editPage/editPage.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index 6a87c8d30..6c2220ec1 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -36,7 +36,7 @@ import { updateHistory, versionHistoryGarbageCollection } from '../../utils/vers import googleDriveIcon from '../../googleDrive.svg'; const SAVE_TIMEOUT = 10000; -const UNSAVED_WARNING_TIMEOUT = 9000; //Warn user afer 15 minutes of unsaved changes +const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds const EditPage = (props)=>{