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:
@@ -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 */ }
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user