fix(settings): hold autosave until the Settings window closes

Every knob in Settings writes through the store, so the debounce could fire
mid-session: it sent a half-edited value and came back with a server copy that
replaced state.project, putting the knob under the cursor back where it was.

Opening the window now holds scheduled saves; closing it writes once, with the
values on screen. An explicit flush still goes through, so Generate and the
update/switch/restart actions cannot build or reload from an unsaved project.
Leaving the tab flushes too.

Also fixes the same stale-echo race in the models save path (an edit made while
a save was in flight was overwritten by the response), and makes commit() wait
for an in-flight models reload before copying it into project.models.
This commit is contained in:
digital-garbage
2026-08-12 19:06:18 +03:00
parent 2c0b08bdee
commit 5babbdda82
3 changed files with 74 additions and 4 deletions
+10
View File
@@ -33,6 +33,13 @@
window.FunPackRestart?.waitForReload?.(msgEl, startMs);
}
// All three actions below end in a server restart and a page reload, so anything the
// store is still holding — a debounced field, or a whole settings session waiting on its
// close — has to reach disk first. flushSave() ignores the suspension for exactly this.
async function _flushPendingEdits() {
try { await window.Store?.flushSave?.(); } catch (_) {}
}
async function update() {
const gs = await _ensureStatus();
if (!gs) return;
@@ -43,6 +50,7 @@
const branch = gs.branch || "dev";
const behind = gs.behind > 0 ? `\n\n${gs.behind} commit(s) available on origin/${branch}.` : "";
if (!confirm(`Pull latest "${branch}" from origin and restart ComfyUI?\n\nAny running generation will be lost.${behind}`)) return;
await _flushPendingEdits();
const msg = _restartOverlay(`Pulling origin/${branch}…\nComfyUI will restart when the pull finishes.`);
try {
const res = await API().gitUpdate(branch);
@@ -74,6 +82,7 @@
onPick: async (branch) => {
if (branch === cur) return;
if (!confirm(`Switch to "${branch}", pull from origin, and restart ComfyUI?\n\nAny running generation will be lost.`)) return;
await _flushPendingEdits();
const msg = _restartOverlay(`Switching to ${branch}…\nComfyUI will restart when ready.`);
try {
const res = await API().gitCheckout(branch);
@@ -95,6 +104,7 @@
"Restart ComfyUI now?\n\nThe server will be down for ~10-40s and any running generation "
+ "will be lost. This page reloads automatically when it's back."
)) return;
await _flushPendingEdits();
const msg = _restartOverlay("Restarting ComfyUI…\nThis page will reload when it's back.");
if (!msg) return;
try { await API().restart(); } catch (_) { /* the connection drops as it execv's */ }
+8
View File
@@ -84,9 +84,14 @@
}
function close() {
const wasOpen = !!overlay;
teardownSection();
if (overlay) overlay.remove();
overlay = null; activeId = null;
// One save for the whole session, on the way out, with the values that are on screen.
// Nothing in here is worth a network round-trip per keystroke, and a save landing
// mid-edit used to come back and overwrite the knob still under the cursor.
if (wasOpen) window.Store?.resumeSave?.();
}
function open(id) {
@@ -95,6 +100,9 @@
if (overlay && !overlay.isConnected) { close(); }
if (overlay) { show(id || activeId); return; }
query = "";
// Held until close() — see the note there. Paired strictly with the overlay's
// lifetime, and only on the branch that actually creates one.
window.Store?.suspendSave?.();
overlay = el("div", "modal-overlay sw-overlay");
const win = el("div", "settings-win");
+56 -4
View File
@@ -160,6 +160,16 @@
let _selectionAnchorId = null; // shift-click range anchor
let _modelsSaveTimer = null; // debounce exposed-control (node input) saves
let _modelsSaveDirty = false;
// Autosave suspension, held while a settings surface is open (see suspendSave). Every
// knob in there writes through the store, so a debounce that fires mid-session sends a
// half-edited value AND comes back with a server copy that replaces state.project — the
// race behind "I set 0.5 and it went back to 1.0". Counted, not a flag: sections nest.
let _saveSuspended = 0;
// In-flight reload of state.models (fired by funpack-models-changed). commit() copies
// state.models into project.models, so committing while this is pending would write the
// PRE-edit node list — the same stale-copy problem the sync exists to prevent, just from
// the other side. Closing Settings commits immediately, so the window is real.
let _modelsLoad = null;
function notify() { listeners.forEach((fn) => { try { fn(state); } catch (e) { console.error(e); } }); }
function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
@@ -185,6 +195,7 @@
function scheduleSaveFromHistory() {
_localDirty = true;
if (_saveSuspended) { _notifySaveChip(); return; }
clearTimeout(saveTimer);
saveTimer = setTimeout(() => { saveTimer = null; commit(); }, SAVE_DEBOUNCE_MS);
_notifySaveChip();
@@ -903,6 +914,7 @@
// edits something visible, or when prompt preview actually needs refreshing.
function scheduleSave() {
_localDirty = true;
if (_saveSuspended) { _notifySaveChip(); return; }
clearTimeout(saveTimer);
saveTimer = setTimeout(() => { saveTimer = null; commit(); }, SAVE_DEBOUNCE_MS);
_notifySaveChip();
@@ -911,11 +923,32 @@
// For free-text / number fields: queue a save without re-rendering; blur/generate flush.
function scheduleSaveSilent() {
_localDirty = true;
if (_saveSuspended) { _notifySaveChip(); return; }
clearTimeout(saveTimer);
saveTimer = setTimeout(() => { saveTimer = null; commit(); }, SAVE_SILENT_DEBOUNCE_MS);
_notifySaveChip();
}
// Hold every scheduled save until the matching resumeSave(). Edits still land in local
// state and still mark the project unsaved — only the network write waits, so a pane full
// of knobs saves once, on close, with the values that are actually on screen.
//
// An EXPLICIT flush still goes through: Generate, and anything else calling flushSave(),
// must not be able to build a graph from a project the server hasn't seen.
function suspendSave() {
_saveSuspended++;
clearTimeout(saveTimer);
saveTimer = null;
}
// Balanced with suspendSave(). Returns the commit promise if this released the last hold
// and there was something to write, else null.
function resumeSave() {
if (_saveSuspended > 0) _saveSuspended--;
if (_saveSuspended > 0) return null;
return flushSave();
}
// Commit any pending save immediately (field blur / before generate). Returns the
// commit promise when it flushed, else null.
function flushSave() {
@@ -930,6 +963,7 @@
if (_commitPromise) { _commitQueued = true; return _commitPromise; }
const skipPreviewRefresh = !!opts.skipPreviewRefresh;
_commitPromise = (async () => {
if (_modelsLoad) { try { await _modelsLoad; } catch (_) {} }
// Sync BEFORE snapshotting: the snapshot is what gets uploaded, and a snapshot taken
// first carries the models config (and renders/ghosts) from the PREVIOUS sync — every
// autosave then silently reverted exposed-control edits (bypass/input/link changes
@@ -3988,7 +4022,12 @@
// ── pluggable models / exposed controls ──────────────────────────────────────
async function loadModels() {
try { state.models = await API.getModels(state.project?.id); } catch (_) { state.models = { slots: [] }; }
const p = (async () => {
try { state.models = await API.getModels(state.project?.id); } catch (_) { state.models = { slots: [] }; }
})();
_modelsLoad = p;
await p;
if (_modelsLoad === p) _modelsLoad = null;
if (state.project) {
state.project.models = JSON.parse(JSON.stringify(state.models || { slots: [] }));
}
@@ -4017,8 +4056,14 @@
async function flushModelsSave() {
if (!_modelsSaveTimer && !_modelsSaveDirty) return;
clearTimeout(_modelsSaveTimer); _modelsSaveTimer = null; _modelsSaveDirty = false;
try { state.models = await API.saveModels(state.project?.id, state.models); notify(); }
catch (e) { console.error("saveModels failed", e); }
try {
const saved = await API.saveModels(state.project?.id, state.models);
// Same guard commit() has: an edit made while this write was in flight is NEWER than
// the copy the server just echoed back, and adopting the echo would roll it back —
// then send the rolled-back value on the next save. Keep local; the queued save wins.
if (!_modelsSaveDirty) state.models = saved;
notify();
} catch (e) { console.error("saveModels failed", e); }
}
// Edit a configured node input from the main editor (an "exposed" control) and persist.
@@ -4310,11 +4355,18 @@
if (window.WelcomePage) window.WelcomePage.open();
}
// Backstop for a suspended save the user walks away from: leaving the tab is the last
// moment we reliably get. Safe to do unconditionally — nobody is mid-edit in a tab that
// just went to the background, which is the only thing suspension exists to protect.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") { try { flushSave(); } catch (_) {} }
});
window.Store = {
get, set, subscribe, notify, init,
scheduleSaveFromHistory, notifyHistoryState,
refreshProjectList, loadProject, newProject, deleteProject, downloadProject, importProject,
patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, flushSave, selectScene, addScene, removeScene, removeSelectedScenes, removeFromPlan, restoreToPlan, dismissGhost, moveScene, moveSceneTo, moveTimelineClip, scene,
patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, flushSave, suspendSave, resumeSave, selectScene, addScene, removeScene, removeSelectedScenes, removeFromPlan, restoreToPlan, dismissGhost, moveScene, moveSceneTo, moveTimelineClip, scene,
addVideoClip, addImageClip, convertToVideo, convertToScene, isVideoClip, isGenerativeScene,
genUnitId, isGenSubclip, genUnitRoot, genUnitSceneIds,
renderPromptForScene, renderPromptMismatch, renderAnchorMismatch, renderMediaLabel, renderIsStale,