Fix race condition causing duplicate entries during autosave

Use refs to track entry ID and prevent concurrent saves.
When a save is in progress, queue the next save instead of
starting a new one.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gemini Agent
2026-01-24 12:08:33 +00:00
parent 504f07a106
commit e99401858f

View File

@@ -23,6 +23,8 @@ export function useEntry({ date, entryId, isNewEntry = false }: UseEntryOptions
const [isLoading, setIsLoading] = useState(!isNewEntry); const [isLoading, setIsLoading] = useState(!isNewEntry);
const [lastSaved, setLastSaved] = useState<Date | null>(null); const [lastSaved, setLastSaved] = useState<Date | null>(null);
const pendingSaveRef = useRef(false); const pendingSaveRef = useRef(false);
const savingRef = useRef(false); // Prevent concurrent saves
const entryIdRef = useRef<string | null>(null); // Track entry ID across saves
// Load entry (only if editing existing or viewing by date) // Load entry (only if editing existing or viewing by date)
useEffect(() => { useEffect(() => {
@@ -45,6 +47,7 @@ export function useEntry({ date, entryId, isNewEntry = false }: UseEntryOptions
if (data) { if (data) {
setEntry(data); setEntry(data);
entryIdRef.current = data.id;
setText(data.text); setText(data.text);
setMood(data.mood); setMood(data.mood);
setRoughDay(Boolean(data.roughDay)); setRoughDay(Boolean(data.roughDay));
@@ -64,12 +67,19 @@ export function useEntry({ date, entryId, isNewEntry = false }: UseEntryOptions
const save = useCallback(async () => { const save = useCallback(async () => {
if (!text.trim()) return null; if (!text.trim()) return null;
// Prevent concurrent saves
if (savingRef.current) {
pendingSaveRef.current = true; // Mark that we need to save again
return null;
}
savingRef.current = true;
setIsSaving(true); setIsSaving(true);
pendingSaveRef.current = false; pendingSaveRef.current = false;
try { try {
const body: CreateEntryRequest = { const body: CreateEntryRequest = {
id: entry?.id, // Include id if updating existing id: entryIdRef.current || undefined, // Use ref to always have latest ID
date: entry?.date || targetDate, date: entry?.date || targetDate,
text: text.trim(), text: text.trim(),
mood, mood,
@@ -86,16 +96,28 @@ export function useEntry({ date, entryId, isNewEntry = false }: UseEntryOptions
if (res.ok) { if (res.ok) {
const saved: EntryWithTags = await res.json(); const saved: EntryWithTags = await res.json();
setEntry(saved); setEntry(saved);
entryIdRef.current = saved.id; // Update ref immediately
setLastSaved(new Date()); setLastSaved(new Date());
return saved; return saved;
} }
} catch (error) { } catch (error) {
console.error("Failed to save entry:", error); console.error("Failed to save entry:", error);
} finally { } finally {
savingRef.current = false;
setIsSaving(false); setIsSaving(false);
// If there were changes while saving, trigger another save
if (pendingSaveRef.current) {
pendingSaveRef.current = false;
// Use setTimeout to avoid immediate recursion
setTimeout(() => {
pendingSaveRef.current = true;
debouncedSave();
}, 100);
}
} }
return null; return null;
}, [text, mood, roughDay, tagNames, entry?.id, entry?.date, targetDate]); }, [text, mood, roughDay, tagNames, entry?.date, targetDate]);
// Debounced save // Debounced save
const debouncedSave = useDebounce(() => { const debouncedSave = useDebounce(() => {
@@ -157,12 +179,14 @@ export function useEntry({ date, entryId, isNewEntry = false }: UseEntryOptions
// Reset form for new entry // Reset form for new entry
const reset = useCallback(() => { const reset = useCallback(() => {
setEntry(null); setEntry(null);
entryIdRef.current = null; // Clear the ID ref
setText(""); setText("");
setMood(null); setMood(null);
setRoughDay(false); setRoughDay(false);
setTagNames([]); setTagNames([]);
setLastSaved(null); setLastSaved(null);
pendingSaveRef.current = false; pendingSaveRef.current = false;
savingRef.current = false;
}, []); }, []);
// Save on unmount if pending // Save on unmount if pending
@@ -174,7 +198,7 @@ export function useEntry({ date, entryId, isNewEntry = false }: UseEntryOptions
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
id: entry?.id, id: entryIdRef.current || undefined,
date: entry?.date || targetDate, date: entry?.date || targetDate,
text: text.trim(), text: text.trim(),
mood, mood,
@@ -184,7 +208,7 @@ export function useEntry({ date, entryId, isNewEntry = false }: UseEntryOptions
}).catch(() => {}); }).catch(() => {});
} }
}; };
}, [text, mood, roughDay, tagNames, entry?.id, entry?.date, targetDate]); }, [text, mood, roughDay, tagNames, entry?.date, targetDate]);
return { return {
entry, entry,