| 1 | // fixtures/save-queue.mjs — the editor's save queue, factored out of |
| 2 | // fixtures/editor-entry.js so its retry/drop logic can be exercised |
| 3 | // under plain Node with no DOM, no ProseMirror, and no real Y.Doc — |
| 4 | // see fixtures/save-queue.test.mjs. |
| 5 | // |
| 6 | // This is fix round 1's F1 fix. The original inline version in |
| 7 | // editor-entry.js requeued a failed batch unconditionally: right for |
| 8 | // a network blip or a 500 (worth retrying), catastrophic for a 4xx |
| 9 | // (the core has already looked at these exact bytes and refused |
| 10 | // them — retrying is futile, and worse, the NEXT flush would run |
| 11 | // Y.mergeUpdates(queue) over the requeued batch plus whatever the |
| 12 | // person typed since, merging every later edit into a batch that can |
| 13 | // never be accepted. A ~1.2MB paste that gets 413 would then also |
| 14 | // carry away everything typed afterwards, forever, with nothing |
| 15 | // shown on screen — reproduced for real against a running server |
| 16 | // before this fix went in. |
| 17 | // |
| 18 | // The fix: classify the response. A network failure (post() rejects) |
| 19 | // or a 5xx is retryable — our own fault or a transient one, so the |
| 20 | // bytes go back on the queue and a retry is scheduled. A 4xx is |
| 21 | // permanent — the bytes themselves are the problem, so the batch is |
| 22 | // dropped (never requeued, never merged into) and the failure is |
| 23 | // reported instead of hidden. Ordinary edits keep flowing through the |
| 24 | // same queue immediately afterwards, unaffected. |
| 25 | // |
| 26 | // post/mergeUpdates/setTimeoutFn are injected rather than imported so |
| 27 | // the test can drive this with tiny byte arrays, a stub network call, |
| 28 | // and a fake clock instead of real Yjs updates, a real fetch, and |
| 29 | // real timers — the control flow being tested does not depend on any |
| 30 | // of those being real. |
| 31 | export function createSaveQueue({ post, mergeUpdates, onStatus, debounceMs = 2000, setTimeoutFn = setTimeout }) { |
| 32 | let queue = [] |
| 33 | // Bytes currently in flight (already merged and sent, response not |
| 34 | // back yet) live here, separately from queue — not just so a |
| 35 | // concurrent enqueue() during the await doesn't get folded into a |
| 36 | // request already on the wire, but so pendingBytes() below can |
| 37 | // include them: bytes an in-flight fetch is carrying are exactly |
| 38 | // as at-risk from a same-tick tab close as bytes still sitting in |
| 39 | // queue (fix round 1's LOW finding), so beforeunload must be able |
| 40 | // to beacon both. |
| 41 | let inFlight = null |
| 42 | let timer = null |
| 43 | let status = 'ok' |
| 44 | |
| 45 | function report(next) { |
| 46 | // 'failed' is sticky: once bytes are known dropped, a later |
| 47 | // successful save must not quietly erase that history from |
| 48 | // view — the person still needs to know something did not make |
| 49 | // it, even after the next paragraph saves fine. |
| 50 | if (status === 'failed' && next !== 'failed') return |
| 51 | status = next |
| 52 | if (onStatus) onStatus(status) |
| 53 | } |
| 54 | |
| 55 | function scheduleRetry() { |
| 56 | if (!timer) timer = setTimeoutFn(flush, debounceMs) |
| 57 | } |
| 58 | |
| 59 | async function flush() { |
| 60 | timer = null |
| 61 | if (!queue.length) return |
| 62 | const merged = mergeUpdates(queue) |
| 63 | queue = [] |
| 64 | inFlight = merged |
| 65 | |
| 66 | let r |
| 67 | try { |
| 68 | r = await post(merged) |
| 69 | } catch (err) { |
| 70 | inFlight = null |
| 71 | queue.unshift(merged) |
| 72 | report('retrying') |
| 73 | scheduleRetry() |
| 74 | return |
| 75 | } |
| 76 | inFlight = null |
| 77 | |
| 78 | if (r.ok) { |
| 79 | report('ok') |
| 80 | return |
| 81 | } |
| 82 | if (r.status >= 400 && r.status < 500) { |
| 83 | // Permanent: drop it. Do not requeue, do not merge later edits |
| 84 | // into it — see this module's own header comment. |
| 85 | report('failed') |
| 86 | return |
| 87 | } |
| 88 | // 5xx: not the client's fault, worth retrying. |
| 89 | queue.unshift(merged) |
| 90 | report('retrying') |
| 91 | scheduleRetry() |
| 92 | } |
| 93 | |
| 94 | function enqueue(update) { |
| 95 | queue.push(update) |
| 96 | if (timer) return |
| 97 | timer = setTimeoutFn(flush, debounceMs) |
| 98 | } |
| 99 | |
| 100 | // pendingBytes merges whatever is still owed to the server — |
| 101 | // queued-but-unsent and in-flight-but-unconfirmed alike — into one |
| 102 | // update, for beforeunload to beacon. null when there is nothing |
| 103 | // to send. |
| 104 | function pendingBytes() { |
| 105 | const parts = [] |
| 106 | if (inFlight) parts.push(inFlight) |
| 107 | if (queue.length) parts.push(mergeUpdates(queue)) |
| 108 | if (!parts.length) return null |
| 109 | return parts.length === 1 ? parts[0] : mergeUpdates(parts) |
| 110 | } |
| 111 | |
| 112 | return { |
| 113 | enqueue, |
| 114 | flush, |
| 115 | pendingBytes, |
| 116 | get status() { return status }, |
| 117 | } |
| 118 | } |
| 119 | |