Fix reader read-aloud races and add rows.Err checks

- reader.js: guard read-aloud against the loading placeholder (bodyReady)
  and invalidate stale speechSynthesis callbacks with a generation token
- storage: check rows.Err() after iterating story-view/content-length reads
- metrics: reuse placeholders() instead of duplicating the IN-clause builder
This commit is contained in:
prosolis
2026-07-07 22:35:09 -07:00
parent 616055a704
commit 8f9fcc45f3
3 changed files with 18 additions and 8 deletions

View File

@@ -69,22 +69,20 @@ func StoryViewTotals(ids []int64) map[int64]int {
} }
out[id] = n out[id] = n
} }
if err := rows.Err(); err != nil {
slog.Error("metrics: story view totals iteration failed", "err", err)
}
return out return out
} }
// intInClause builds a "?, ?, …" placeholder string and matching args slice for // intInClause builds a "?, ?, …" placeholder string and matching args slice for
// a SQL IN (…) over int64 ids. // a SQL IN (…) over int64 ids.
func intInClause(ids []int64) (string, []any) { func intInClause(ids []int64) (string, []any) {
ph := make([]byte, 0, len(ids)*2)
args := make([]any, len(ids)) args := make([]any, len(ids))
for i, id := range ids { for i, id := range ids {
if i > 0 {
ph = append(ph, ',')
}
ph = append(ph, '?')
args[i] = id args[i] = id
} }
return string(ph), args return placeholders(len(ids)), args
} }
// PathStat is one row of the per-page usage breakdown. // PathStat is one row of the per-page usage breakdown.

View File

@@ -438,6 +438,9 @@ func StoryContentLengths(ids []int64) map[int64]int {
} }
out[id] = int(n) out[id] = int(n)
} }
if err := rows.Err(); err != nil {
slog.Error("story content lengths iteration failed", "err", err)
}
return out return out
} }

View File

@@ -44,6 +44,7 @@
var open = false; var open = false;
var contentCache = {}; // id -> {content, lede} var contentCache = {}; // id -> {content, lede}
var inflight = null; var inflight = null;
var bodyReady = false; // true once the real article body is rendered (not the loading placeholder)
// ---- read-state store ----------------------------------------------------- // ---- read-state store -----------------------------------------------------
function loadRead() { function loadRead() {
@@ -264,6 +265,7 @@
} }
function loadingBody(it) { function loadingBody(it) {
bodyReady = false; // read-aloud waits for the real body, not this placeholder
renderBody(it, '<p style="opacity:.55">Loading the full story…</p>'); renderBody(it, '<p style="opacity:.55">Loading the full story…</p>');
} }
@@ -274,6 +276,7 @@
html = '<p style="opacity:.7">No article text was captured for this story. Open the original to read it.</p>'; html = '<p style="opacity:.7">No article text was captured for this story. Open the original to read it.</p>';
} }
renderBody(it, html); renderBody(it, html);
bodyReady = true;
} }
function fetchContent(it) { function fetchContent(it) {
@@ -483,6 +486,8 @@
// ---- read aloud (signed-in only) ------------------------------------------ // ---- read aloud (signed-in only) ------------------------------------------
var speaking = false; var speaking = false;
var speakGen = 0; // bumped on every stop/start; stale utterance callbacks that
// fire after speechSynthesis.cancel() check it and no-op
var speakParas = []; // <p> elements being read, for highlight var speakParas = []; // <p> elements being read, for highlight
var speakQueue = []; // remaining {el, text} to utter var speakQueue = []; // remaining {el, text} to utter
function speakingText() { function speakingText() {
@@ -502,6 +507,7 @@
} }
function stopSpeak() { function stopSpeak() {
if (!TTS_OK) return; if (!TTS_OK) return;
speakGen++;
try { window.speechSynthesis.cancel(); } catch (e) {} try { window.speechSynthesis.cancel(); } catch (e) {}
speaking = false; speaking = false;
speakQueue = []; speakQueue = [];
@@ -518,15 +524,18 @@
speakParas.push(item.el); speakParas.push(item.el);
try { item.el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {} try { item.el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {}
} }
var gen = speakGen;
var u = new SpeechSynthesisUtterance(item.text); var u = new SpeechSynthesisUtterance(item.text);
u.onend = function () { if (speaking) speakNext(); }; u.onend = function () { if (speaking && gen === speakGen) speakNext(); };
u.onerror = function () { if (speaking) speakNext(); }; u.onerror = function () { if (speaking && gen === speakGen) speakNext(); };
try { window.speechSynthesis.speak(u); } catch (e) { stopSpeak(); } try { window.speechSynthesis.speak(u); } catch (e) { stopSpeak(); }
} }
function startSpeak() { function startSpeak() {
if (!TTS_OK) return; if (!TTS_OK) return;
if (!bodyReady) { toast("Still loading…"); return; }
var parts = speakingText(); var parts = speakingText();
if (!parts.length) { toast("Nothing to read yet"); return; } if (!parts.length) { toast("Nothing to read yet"); return; }
speakGen++;
try { window.speechSynthesis.cancel(); } catch (e) {} try { window.speechSynthesis.cancel(); } catch (e) {}
speaking = true; speaking = true;
speakQueue = parts.slice(); speakQueue = parts.slice();