Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

fix(frontend): confirm own PM steps via receiveTransaction after successful POST to advance collab plugin version

+45 -29
+25 -21
static/collab-client.js
··· 6 6 export class CollabClient { 7 7 constructor(rkey, initialVersion, applyRemoteSteps) { 8 8 this.rkey = rkey; 9 - this.version = initialVersion; // authoritative for source mode; advisory for rich mode 9 + this.version = initialVersion; // authoritative for source mode 10 10 this.applyRemoteSteps = applyRemoteSteps; 11 11 this._inflight = false; 12 12 this._queue = []; // source mode only 13 - this._pmGetSendable = null; // rich mode: () => sendableSteps(pmView.state) | null 13 + this._pmGetSendable = null; // rich mode: () => sendableSteps(state) | null 14 + this._pmConfirm = null; // rich mode: (steps, clientIDs) => void — confirms sent steps 14 15 } 15 16 16 17 // Source mode: queue explicit step objects and flush. ··· 19 20 this._flush(); 20 21 } 21 22 22 - // Rich mode: register the sendable-steps provider. 23 - setPMSendable(fn) { 24 - this._pmGetSendable = fn; 25 - this._queue = []; // clear any stale source-mode queue 23 + // Rich mode: register the sendable-steps provider and confirmation callback. 24 + // getSendable: () => { steps, version } | null (calls sendableSteps(pmState)) 25 + // confirm: (steps, clientIDs) => void (calls receiveTransaction on own steps) 26 + setPMHandlers(getSendable, confirm) { 27 + this._pmGetSendable = getSendable; 28 + this._pmConfirm = confirm; 29 + this._queue = []; 26 30 } 27 31 28 - // Rich mode: call after every PM transaction to trigger a flush attempt. 32 + // Rich mode: call after every PM transaction. 29 33 notifyPMChange() { 30 34 this._flush(); 31 35 } ··· 33 37 async _flush() { 34 38 if (this._inflight) return; 35 39 36 - let toSend; 37 - let clientVersion; 38 40 const isRichMode = !!this._pmGetSendable; 41 + let toSend, clientVersion, sentSteps; 39 42 40 43 if (isRichMode) { 41 44 const sendable = this._pmGetSendable(); 42 45 if (!sendable || sendable.steps.length === 0) return; 43 - // Use version from the collab plugin state — always authoritative. 44 46 clientVersion = sendable.version; 45 - toSend = sendable.steps.map(s => ({type: 'pm-step', json: JSON.stringify(s.toJSON())})); 47 + sentSteps = sendable.steps; // keep reference to confirm on success 48 + toSend = sentSteps.map(s => ({type: 'pm-step', json: JSON.stringify(s.toJSON())})); 46 49 } else { 47 50 if (this._queue.length === 0) return; 48 51 clientVersion = this.version; ··· 64 67 if (resp.ok) { 65 68 const {version} = await resp.json(); 66 69 this.version = version; 67 - if (!isRichMode) { 70 + if (isRichMode) { 71 + // Confirm our own steps in the collab plugin so it advances its 72 + // version counter and clears the unconfirmed buffer. 73 + if (this._pmConfirm && sentSteps) { 74 + this._pmConfirm(sentSteps, sentSteps.map(() => this._clientID || '')); 75 + } 76 + } else { 68 77 this._queue = this._queue.slice(toSend.length); 69 78 } 70 - // Rich mode: the collab plugin tracks confirmation internally; 71 - // this.version is updated here as a fallback for WebSocket-less paths. 72 79 } else if (resp.status === 409) { 73 80 const {steps: missedJSON} = await resp.json(); 74 81 const missed = missedJSON.map(s => JSON.parse(s)); 75 - // Apply missed steps — for rich mode this calls receiveTransaction 76 - // which rebases our unconfirmed steps and updates the collab plugin state. 77 - // Do NOT update this.version here; the collab plugin owns that for rich mode. 82 + // Apply missed steps — for rich mode this calls receiveTransaction which 83 + // rebases unconfirmed steps and advances the collab plugin version. 78 84 this.applyRemoteSteps(missed); 79 - // Retry after a short delay so the PM state settles. 80 85 setTimeout(() => this._flush(), 30); 81 - return; // skip the finally retry below 86 + return; // skip finally retry 82 87 } else { 83 88 console.error('CollabClient: unexpected status', resp.status); 84 89 } ··· 98 103 handleWSMessage(msg, myClientID) { 99 104 if (msg.type !== 'steps') return; 100 105 if (msg.clientID === myClientID) { 101 - // Our steps confirmed via WebSocket broadcast — update advisory version. 102 - this.version = msg.version; 106 + // Our own steps echoed back — version already advanced by POST success path. 103 107 return; 104 108 } 105 109 const steps = msg.steps.map(s => JSON.parse(s));
+20 -8
templates/document_edit.html
··· 358 358 }) 359 359 .create(); 360 360 361 - // Register the PM sendable-steps provider so CollabClient reads directly 362 - // from prosemirror-collab's unconfirmed buffer rather than a separate queue. 363 - collabClient.setPMSendable(() => { 364 - const pmView = milkdownEditor.action(c => c.get(editorViewCtx)); 365 - return sendableSteps(pmView.state); 366 - }); 361 + // Register PM handlers so CollabClient reads sendableSteps and confirms 362 + // its own steps via receiveTransaction (advancing the collab plugin version). 363 + collabClient.setPMHandlers( 364 + () => { 365 + const pmView = milkdownEditor.action(c => c.get(editorViewCtx)); 366 + return sendableSteps(pmView.state); 367 + }, 368 + (steps, clientIDs) => { 369 + const pmView = milkdownEditor.action(c => c.get(editorViewCtx)); 370 + applyingRemote = true; 371 + try { 372 + const tr = receiveTransaction(pmView.state, steps, clientIDs); 373 + pmView.dispatch(tr); 374 + } finally { 375 + applyingRemote = false; 376 + } 377 + } 378 + ); 367 379 368 380 return milkdownEditor; 369 381 } ··· 410 422 const doc = cmView.state.doc; 411 423 cmView.dispatch({ changes: { from: 0, to: doc.length, insert: md } }); 412 424 updatePreview(md); 413 - collabClient.setPMSendable(null); // detach from stale Milkdown instance 425 + collabClient.setPMHandlers(null, null); // detach from stale Milkdown instance 414 426 } else { 415 427 // source → rich: extract markdown from CodeMirror, recreate Milkdown 416 428 const md = cmView.state.doc.toString(); 417 - await createMilkdownEditor(md); // setPMSendable called inside 429 + await createMilkdownEditor(md); // setPMHandlers called inside 418 430 } 419 431 420 432 currentMode = nextMode;