From 4ab4f70349a4292f970d010644f720b50b2896cc Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 19 Jul 2026 17:42:27 +0200 Subject: [PATCH] fix: no duplicate logs on repeat Enter + ESC stops the callsign lookup Duplicate logs: `saving` is React state (async), so a burst of Enter presses / clicks fired before the re-render each ran a full AddQSO and logged the same QSO several times when a slow lookup made logging take a moment. A synchronous savingRef guard now ignores the repeat immediately. ESC: clearing the entry (ESC) left the "looking up" spinner turning and let a still in-flight lookup re-populate the field afterwards. resetEntry now cancels the pending lookup debounce, hides the spinner at once, and bumps a generation counter so a late lookup result is discarded instead of applied. --- frontend/src/App.tsx | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f1b42f2..4cf062f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -605,6 +605,11 @@ export default function App() { QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {}); }; const [saving, setSaving] = useState(false); + // Synchronous re-entrancy guard: `saving` is React state (updates async), so it + // can't stop a burst of Enter presses / clicks fired before the re-render — each + // would run a full AddQSO and log the SAME contact several times when a slow QRZ + // lookup made the log take seconds. This ref blocks the repeat immediately. + const savingRef = useRef(false); const [filterCallsign, setFilterCallsign] = useState(''); // Advanced filter builder (replaces the old band/mode dropdowns). const [filterOpen, setFilterOpen] = useState(false); @@ -1153,6 +1158,10 @@ export default function App() { const [lookupError, setLookupError] = useState(''); const lookupTimerRef = useRef(null); const wbTimerRef = useRef(null); + // Bumped whenever the entry is cleared (ESC) or a new call starts, so a still + // in-flight lookup discards its result when it finally returns instead of + // re-populating a field the operator just cleared. + const lookupGenRef = useRef(0); const [wb, setWb] = useState(null); const [wbBusy, setWbBusy] = useState(false); @@ -2155,7 +2164,9 @@ export default function App() { }, [spots]); async function save() { + if (savingRef.current) return; // a log is already in flight — ignore the repeat if (!callsign.trim()) { setError('Callsign required'); return; } + savingRef.current = true; setSaving(true); setError(''); try { const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined; @@ -2225,13 +2236,21 @@ export default function App() { await refresh(); } catch (e: any) { setError(String(e?.message ?? e)); - } finally { setSaving(false); } + } finally { setSaving(false); savingRef.current = false; } } // resetEntry clears the form for the next QSO. Triggered after a // successful log AND by ESC. Locked values (band/mode/freq/start/end) // are preserved so backdated batches stay productive. function resetEntry() { + // Stop any callsign lookup DEAD: cancel the pending debounce, hide the "looking + // up" spinner immediately, and invalidate any request already in flight so its + // late result can't re-fill the field we're about to clear (the "ESC clears the + // QSO but the QRZ lookup keeps going" bug). + if (lookupTimerRef.current) { window.clearTimeout(lookupTimerRef.current); lookupTimerRef.current = null; } + if (wbTimerRef.current) { window.clearTimeout(wbTimerRef.current); wbTimerRef.current = null; } + lookupGenRef.current++; + setLookupBusy(false); // Discard any in-progress QSO recording (no-op if it was already saved on // log, or if the recorder is off). QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; @@ -2436,14 +2455,15 @@ export default function App() { } async function runLookup(call: string) { if (call !== lastLookedUpRef.current) resetAutoFill(); + const gen = lookupGenRef.current; // invalidated by ESC / resetEntry setLookupBusy(true); try { const r = await LookupCallsign(call); // Discard a STALE result: the operator already moved to another call - // (clicked a new spot / typed) while this lookup was in flight. Applying it - // would clobber the current call's fields and zoom the map to the wrong - // station — the bug where replacing a call didn't re-zoom the map. - if (call !== callsignValRef.current.trim().toUpperCase()) return; + // (clicked a new spot / typed) OR cleared the entry (ESC) while this lookup + // was in flight. Applying it would clobber the current fields and zoom the + // map to the wrong station. + if (gen !== lookupGenRef.current || call !== callsignValRef.current.trim().toUpperCase()) return; lastLookedUpRef.current = call; // cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent). // A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the @@ -2496,9 +2516,15 @@ export default function App() { QSOAudioBegin().then(setRecording).catch(() => {}); } } catch (e: any) { - setLookupResult(null); - setLookupError(String(e?.message ?? e)); - } finally { setLookupBusy(false); } + if (gen === lookupGenRef.current && call === callsignValRef.current.trim().toUpperCase()) { + setLookupResult(null); + setLookupError(String(e?.message ?? e)); + } + } finally { + // Only clear the spinner if we're still the current lookup — a newer one + // (or an ESC that already reset it) owns the busy state otherwise. + if (gen === lookupGenRef.current) setLookupBusy(false); + } } function scheduleLookup(value: string, force?: boolean) { setLookupError('');