// fixtures/save-queue.mjs — the editor's save queue, factored out of // fixtures/editor-entry.js so its retry/drop logic can be exercised // under plain Node with no DOM, no ProseMirror, and no real Y.Doc — // see fixtures/save-queue.test.mjs. // // This is fix round 1's F1 fix. The original inline version in // editor-entry.js requeued a failed batch unconditionally: right for // a network blip or a 500 (worth retrying), catastrophic for a 4xx // (the core has already looked at these exact bytes and refused // them — retrying is futile, and worse, the NEXT flush would run // Y.mergeUpdates(queue) over the requeued batch plus whatever the // person typed since, merging every later edit into a batch that can // never be accepted. A ~1.2MB paste that gets 413 would then also // carry away everything typed afterwards, forever, with nothing // shown on screen — reproduced for real against a running server // before this fix went in. // // The fix: classify the response. A network failure (post() rejects) // or a 5xx is retryable — our own fault or a transient one, so the // bytes go back on the queue and a retry is scheduled. A 4xx is // permanent — the bytes themselves are the problem, so the batch is // dropped (never requeued, never merged into) and the failure is // reported instead of hidden. Ordinary edits keep flowing through the // same queue immediately afterwards, unaffected. // // post/mergeUpdates/setTimeoutFn are injected rather than imported so // the test can drive this with tiny byte arrays, a stub network call, // and a fake clock instead of real Yjs updates, a real fetch, and // real timers — the control flow being tested does not depend on any // of those being real. export function createSaveQueue({ post, mergeUpdates, onStatus, debounceMs = 2000, setTimeoutFn = setTimeout }) { let queue = [] // Bytes currently in flight (already merged and sent, response not // back yet) live here, separately from queue — not just so a // concurrent enqueue() during the await doesn't get folded into a // request already on the wire, but so pendingBytes() below can // include them: bytes an in-flight fetch is carrying are exactly // as at-risk from a same-tick tab close as bytes still sitting in // queue (fix round 1's LOW finding), so beforeunload must be able // to beacon both. let inFlight = null let timer = null let status = 'ok' function report(next) { // 'failed' is sticky: once bytes are known dropped, a later // successful save must not quietly erase that history from // view — the person still needs to know something did not make // it, even after the next paragraph saves fine. if (status === 'failed' && next !== 'failed') return status = next if (onStatus) onStatus(status) } function scheduleRetry() { if (!timer) timer = setTimeoutFn(flush, debounceMs) } async function flush() { timer = null if (!queue.length) return const merged = mergeUpdates(queue) queue = [] inFlight = merged let r try { r = await post(merged) } catch (err) { inFlight = null queue.unshift(merged) report('retrying') scheduleRetry() return } inFlight = null if (r.ok) { report('ok') return } if (r.status >= 400 && r.status < 500) { // Permanent: drop it. Do not requeue, do not merge later edits // into it — see this module's own header comment. report('failed') return } // 5xx: not the client's fault, worth retrying. queue.unshift(merged) report('retrying') scheduleRetry() } function enqueue(update) { queue.push(update) if (timer) return timer = setTimeoutFn(flush, debounceMs) } // pendingBytes merges whatever is still owed to the server — // queued-but-unsent and in-flight-but-unconfirmed alike — into one // update, for beforeunload to beacon. null when there is nothing // to send. function pendingBytes() { const parts = [] if (inFlight) parts.push(inFlight) if (queue.length) parts.push(mergeUpdates(queue)) if (!parts.length) return null return parts.length === 1 ? parts[0] : mergeUpdates(parts) } return { enqueue, flush, pendingBytes, get status() { return status }, } }