const generatePaper = async () => {
if (!selectedPaper) return
setGenerating(true)
try {
await fetch(`${API_BASE}/api/v1/papers/${selectedPaper.id}/generate`, { method: 'POST' })
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 3000))
const resp = await fetch(`${API_BASE}/api/v1/papers/${selectedPaper.id}`)
if (resp.ok) {
const data = await resp.json()
setSelectedPaper(data)
if (data.status === 'completed' || data.status === 'failed') {
await selectPaper(data)
break
}
}
}
} catch (err) { console.error(err) }
(frontend/src/pages/Papers/PapersList.tsx:503-521)
60 iterations x 3s = up to 180 seconds of polling with no exit condition other than terminal status. There is no cancellation token, no unmount check, and no way for the user to stop it.
Trigger: start generation on paper A. While it runs (paper generation involves an LLM call plus a LaTeX compile, so the full 3 minutes is realistic), click paper B in the list.
Observed: the loop is still holding selectedPaper.id for paper A in its closure. Three seconds later it calls setSelectedPaper(data) with paper A's data, and the UI yanks the user back to paper A. Every three seconds, for up to three minutes. The user cannot escape it by clicking; the only remedy is a page reload.
Expected: switching papers abandons the previous poll.
Note the interaction with #41 — if the user also clicked "Render PDF", there is now a 1s interval and this 3s loop, both writing selectedPaper, both pinned to possibly-different paper IDs.
The minimum viable fix is a generation token that the loop re-checks each tick:
const genRef = useRef(0)
const generatePaper = async () => {
const paperId = selectedPaper.id
const token = ++genRef.current
...
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 3000))
if (genRef.current !== token) return // superseded
const resp = await fetch(`${API_BASE}/api/v1/papers/${paperId}`)
...
}
}
and bumping genRef.current in the paper-selection handler and in an unmount effect. An AbortController passed to both fetch calls would additionally stop the in-flight requests rather than just ignoring their results.
Also worth addressing while here: catch (err) { console.error(err) } at :521 means a backend that is simply down produces a spinner that spins for three minutes and then stops, with nothing shown to the user. The finally sets generating false, so the UI returns to idle as if the operation had completed normally.
(
frontend/src/pages/Papers/PapersList.tsx:503-521)60 iterations x 3s = up to 180 seconds of polling with no exit condition other than terminal status. There is no cancellation token, no unmount check, and no way for the user to stop it.
Trigger: start generation on paper A. While it runs (paper generation involves an LLM call plus a LaTeX compile, so the full 3 minutes is realistic), click paper B in the list.
Observed: the loop is still holding
selectedPaper.idfor paper A in its closure. Three seconds later it callssetSelectedPaper(data)with paper A's data, and the UI yanks the user back to paper A. Every three seconds, for up to three minutes. The user cannot escape it by clicking; the only remedy is a page reload.Expected: switching papers abandons the previous poll.
Note the interaction with #41 — if the user also clicked "Render PDF", there is now a 1s interval and this 3s loop, both writing
selectedPaper, both pinned to possibly-different paper IDs.The minimum viable fix is a generation token that the loop re-checks each tick:
and bumping
genRef.currentin the paper-selection handler and in an unmount effect. AnAbortControllerpassed to bothfetchcalls would additionally stop the in-flight requests rather than just ignoring their results.Also worth addressing while here:
catch (err) { console.error(err) }at:521means a backend that is simply down produces a spinner that spins for three minutes and then stops, with nothing shown to the user. Thefinallysetsgeneratingfalse, so the UI returns to idle as if the operation had completed normally.