Flush pending saves before reloading projects
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
+66
-6
@@ -169,6 +169,8 @@ function App() {
|
||||
const latestProjectExpandedMap = useRefMain(projectExpandedMap);
|
||||
const isDirtyRef = useRefMain(false);
|
||||
const saveTimerRef = useRefMain(null);
|
||||
const saveInFlightRef = useRefMain(null); // promise of the performSave currently running
|
||||
const pendingSaveArgsRef = useRefMain(null); // { label, projectId } of the debounced save
|
||||
|
||||
useEffectMain(() => { latestData.current = data; }, [data]);
|
||||
useEffectMain(() => { latestExpandedMap.current = expandedMap; }, [expandedMap]);
|
||||
@@ -200,6 +202,15 @@ function App() {
|
||||
// Full reload of every registered project + merged data rebuild. Used by
|
||||
// init, 'set-changed' polls, restores, and the admin flows (todos 6-8).
|
||||
const reloadProjects = async ({ seedExpanded = false } = {}) => {
|
||||
// Drain pending/in-flight saves first: replacing state from disk while
|
||||
// edits sit in the 600ms debounce (or a save POST is mid-flight) would
|
||||
// silently discard them. On flush failure abort — never clobber state.
|
||||
try {
|
||||
await flushPendingSave();
|
||||
} catch (e) {
|
||||
showToast('Save failed: ' + e.message, 'err');
|
||||
return null;
|
||||
}
|
||||
const [projs, sizeInfo, backups] = await Promise.all([
|
||||
ApiBackend.loadAllProjects(),
|
||||
Storage.getHealthInfo(),
|
||||
@@ -411,12 +422,8 @@ function App() {
|
||||
// ---- Real async save ----
|
||||
// projectId (string or array) restricts the save to that project(s);
|
||||
// omitted = derive the dirty set from _projectId tags.
|
||||
const triggerSave = (label, projectId = null) => {
|
||||
isDirtyRef.current = true;
|
||||
setSaveState(prev => ({ ...prev, status: 'saving' }));
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
// Returns null on success or the caught error (dirty stays set on failure).
|
||||
const performSave = async (label, projectId = null) => {
|
||||
const d = latestData.current;
|
||||
const em = latestExpandedMap.current;
|
||||
try {
|
||||
@@ -450,13 +457,62 @@ function App() {
|
||||
meta: { ...prev.meta, saved: now },
|
||||
}));
|
||||
if (label) showToast(label);
|
||||
return null;
|
||||
} catch (e) {
|
||||
setSaveState(prev => ({ ...prev, status: 'error' }));
|
||||
showToast('Save failed: ' + e.message, 'err');
|
||||
return e;
|
||||
}
|
||||
};
|
||||
|
||||
// Run performSave under in-flight tracking: the ref holds the promise
|
||||
// while it runs and clears on settle (only if still the same promise).
|
||||
const startTrackedSave = (label, projectId = null) => {
|
||||
const p = performSave(label, projectId).finally(() => {
|
||||
if (saveInFlightRef.current === p) saveInFlightRef.current = null;
|
||||
});
|
||||
saveInFlightRef.current = p;
|
||||
return p;
|
||||
};
|
||||
|
||||
const triggerSave = (label, projectId = null) => {
|
||||
isDirtyRef.current = true;
|
||||
setSaveState(prev => ({ ...prev, status: 'saving' }));
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
pendingSaveArgsRef.current = { label, projectId };
|
||||
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveTimerRef.current = null;
|
||||
const { label, projectId } = pendingSaveArgsRef.current ?? {};
|
||||
pendingSaveArgsRef.current = null;
|
||||
startTrackedSave(label, projectId);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
// Drain pending edits into disk before a full state reload: fire the
|
||||
// debounced save now, wait out any save already in flight, and retry once
|
||||
// if edits landed meanwhile. No-op when clean. Mirrors the guard
|
||||
// SyncPoller._tickMulti implements for its own path. Rethrows the error
|
||||
// on failure — dirty is left set so the pending edits survive.
|
||||
const flushPendingSave = async () => {
|
||||
let err = null;
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
const args = pendingSaveArgsRef.current;
|
||||
pendingSaveArgsRef.current = null;
|
||||
err = await startTrackedSave(args?.label ?? null, args?.projectId ?? null);
|
||||
}
|
||||
if (saveInFlightRef.current) {
|
||||
const e = await saveInFlightRef.current;
|
||||
err = err ?? e;
|
||||
}
|
||||
if (isDirtyRef.current) {
|
||||
err = await startTrackedSave(null, null);
|
||||
}
|
||||
if (err) throw err;
|
||||
};
|
||||
|
||||
const mutate = (fn) => {
|
||||
isDirtyRef.current = true;
|
||||
setData(d => { const c = structuredClone(d); fn(c); return c; });
|
||||
@@ -743,6 +799,10 @@ function App() {
|
||||
onConfirm: async (newName) => {
|
||||
if (!newName || newName === proj.name) return;
|
||||
try {
|
||||
// Flush pending edits under the CURRENT id first — after the
|
||||
// rename POST a save tagged with the old id would 404 forever.
|
||||
// (reloadProjects' own flush cannot help: it runs too late here.)
|
||||
await flushPendingSave();
|
||||
const result = await ApiBackend.renameProject(projectId, newName);
|
||||
if (!result.ok) throw new Error(result.error || 'Rename failed');
|
||||
setProjectExpandedMap(prev => {
|
||||
|
||||
@@ -7706,6 +7706,9 @@ function App() {
|
||||
const latestProjectExpandedMap = useRefMain(projectExpandedMap);
|
||||
const isDirtyRef = useRefMain(false);
|
||||
const saveTimerRef = useRefMain(null);
|
||||
const saveInFlightRef = useRefMain(null); // promise of the performSave currently running
|
||||
const pendingSaveArgsRef = useRefMain(null); // { label, projectId } of the debounced save
|
||||
|
||||
useEffectMain(() => {
|
||||
latestData.current = data;
|
||||
}, [data]);
|
||||
@@ -7742,6 +7745,15 @@ function App() {
|
||||
const reloadProjects = async ({
|
||||
seedExpanded = false
|
||||
} = {}) => {
|
||||
// Drain pending/in-flight saves first: replacing state from disk while
|
||||
// edits sit in the 600ms debounce (or a save POST is mid-flight) would
|
||||
// silently discard them. On flush failure abort — never clobber state.
|
||||
try {
|
||||
await flushPendingSave();
|
||||
} catch (e) {
|
||||
showToast('Save failed: ' + e.message, 'err');
|
||||
return null;
|
||||
}
|
||||
const [projs, sizeInfo, backups] = await Promise.all([ApiBackend.loadAllProjects(), Storage.getHealthInfo(), Storage.listBackups()]);
|
||||
const newData = await buildMergedDataFromProjects(projs, {
|
||||
sizeInfo,
|
||||
@@ -7964,14 +7976,8 @@ function App() {
|
||||
// ---- Real async save ----
|
||||
// projectId (string or array) restricts the save to that project(s);
|
||||
// omitted = derive the dirty set from _projectId tags.
|
||||
const triggerSave = (label, projectId = null) => {
|
||||
isDirtyRef.current = true;
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
status: 'saving'
|
||||
}));
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
// Returns null on success or the caught error (dirty stays set on failure).
|
||||
const performSave = async (label, projectId = null) => {
|
||||
const d = latestData.current;
|
||||
const em = latestExpandedMap.current;
|
||||
try {
|
||||
@@ -8017,15 +8023,71 @@ function App() {
|
||||
}
|
||||
}));
|
||||
if (label) showToast(label);
|
||||
return null;
|
||||
} catch (e) {
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
status: 'error'
|
||||
}));
|
||||
showToast('Save failed: ' + e.message, 'err');
|
||||
return e;
|
||||
}
|
||||
};
|
||||
|
||||
// Run performSave under in-flight tracking: the ref holds the promise
|
||||
// while it runs and clears on settle (only if still the same promise).
|
||||
const startTrackedSave = (label, projectId = null) => {
|
||||
const p = performSave(label, projectId).finally(() => {
|
||||
if (saveInFlightRef.current === p) saveInFlightRef.current = null;
|
||||
});
|
||||
saveInFlightRef.current = p;
|
||||
return p;
|
||||
};
|
||||
const triggerSave = (label, projectId = null) => {
|
||||
isDirtyRef.current = true;
|
||||
setSaveState(prev => ({
|
||||
...prev,
|
||||
status: 'saving'
|
||||
}));
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
pendingSaveArgsRef.current = {
|
||||
label,
|
||||
projectId
|
||||
};
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveTimerRef.current = null;
|
||||
const {
|
||||
label,
|
||||
projectId
|
||||
} = pendingSaveArgsRef.current ?? {};
|
||||
pendingSaveArgsRef.current = null;
|
||||
startTrackedSave(label, projectId);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
// Drain pending edits into disk before a full state reload: fire the
|
||||
// debounced save now, wait out any save already in flight, and retry once
|
||||
// if edits landed meanwhile. No-op when clean. Mirrors the guard
|
||||
// SyncPoller._tickMulti implements for its own path. Rethrows the error
|
||||
// on failure — dirty is left set so the pending edits survive.
|
||||
const flushPendingSave = async () => {
|
||||
let err = null;
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
const args = pendingSaveArgsRef.current;
|
||||
pendingSaveArgsRef.current = null;
|
||||
err = await startTrackedSave(args?.label ?? null, args?.projectId ?? null);
|
||||
}
|
||||
if (saveInFlightRef.current) {
|
||||
const e = await saveInFlightRef.current;
|
||||
err = err ?? e;
|
||||
}
|
||||
if (isDirtyRef.current) {
|
||||
err = await startTrackedSave(null, null);
|
||||
}
|
||||
if (err) throw err;
|
||||
};
|
||||
const mutate = fn => {
|
||||
isDirtyRef.current = true;
|
||||
setData(d => {
|
||||
@@ -8396,6 +8458,10 @@ function App() {
|
||||
onConfirm: async newName => {
|
||||
if (!newName || newName === proj.name) return;
|
||||
try {
|
||||
// Flush pending edits under the CURRENT id first — after the
|
||||
// rename POST a save tagged with the old id would 404 forever.
|
||||
// (reloadProjects' own flush cannot help: it runs too late here.)
|
||||
await flushPendingSave();
|
||||
const result = await ApiBackend.renameProject(projectId, newName);
|
||||
if (!result.ok) throw new Error(result.error || 'Rename failed');
|
||||
setProjectExpandedMap(prev => {
|
||||
@@ -8644,6 +8710,7 @@ function App() {
|
||||
history = [];
|
||||
projs.forEach((p, i) => {
|
||||
for (const e of parsedAll[i].entries) e._projectId = p.id;
|
||||
for (const h of parsedAll[i].history) h._projectId = p.id;
|
||||
entries.push(...parsedAll[i].entries);
|
||||
history.push(...parsedAll[i].history);
|
||||
});
|
||||
@@ -8835,7 +8902,7 @@ function App() {
|
||||
const pidOf = x => x._projectId ?? fallbackFirst;
|
||||
for (const pid of [...new Set(toRestore.map(pidOf))]) {
|
||||
const backlogContent = await Parser.serialize(toDiskIds(newEntries.filter(e => pidOf(e) === pid), newHistory.filter(h => pidOf(h) === pid), pid));
|
||||
const archiveContent = await Parser.serialize(toDiskIds(remainingArchive.filter(e => pidOf(e) === pid), [], pid));
|
||||
const archiveContent = await Parser.serialize(toDiskIds(remainingArchive.filter(e => pidOf(e) === pid), archiveData.history.filter(h => pidOf(h) === pid), pid));
|
||||
const result = await ApiBackend.restoreFromArchive(backlogContent, archiveContent, pid);
|
||||
if (!result.ok) throw new Error('Restore failed');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user