From 3f42a0bcd3c2624a0c86e048924b98ddd52294b8 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 4 Mar 2026 21:16:23 +0000 Subject: [PATCH] fix(docs): fix MutationObserver feedback loop and own-event echo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes of instability: 1. Receiver re-sends deltas it just received: MutationObserver callbacks are async microtasks, so setting suppressMutations=false synchronously after innerHTML= was too early — the observer callback hadn't fired yet. Fix: defer the reset via requestAnimationFrame() so the flag is still true when the observer's microtask runs. 2. Sender's own events echo back and rewrite DOM: in encrypted rooms device_id is not reliably in event.getUnsigned(), so the per-device skip check failed and the sender's own deltas were treated as remote. This caused innerHTML to be set from get_content_as_html() which may differ from the WASM useListeners DOM output (e.g.

vs
for newlines), collapsing structure. Fix: skip by userId alone — the local model already has our changes from save_incremental(), so applying our own events is both unnecessary and harmful. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../views/rooms/wysiwyg_composer/DocumentView.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx b/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx index 457f0ca1db..97b84faa50 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx @@ -189,7 +189,7 @@ function useDocumentSync( if (editorRef.current) { suppressMutations.current = true; editorRef.current.innerHTML = composerModel.get_content_as_html(); - suppressMutations.current = false; + requestAnimationFrame(() => { suppressMutations.current = false; }); onContentChanged(); } logger.info("[DocumentView] Loaded document from room state"); @@ -216,8 +216,10 @@ function useDocumentSync( if (event.getRoomId() !== room.roomId) return; if (event.getType() !== DOC_DELTA_EVENT_TYPE) return; - const eventDeviceId = event.getUnsigned()?.["device_id"] as string | undefined; - if (event.getSender() === client.getUserId() && eventDeviceId === client.getDeviceId()) return; + // Skip our own events. In encrypted rooms device_id may not be in + // unsigned, so also skip by sender alone — the local model already + // has our own changes via save_incremental(). + if (event.getSender() === client.getUserId()) return; const model = composerModelRef.current; if (!isCollaborative(model)) { logger.warn("[DocumentView] Model not collaborative yet, dropping delta"); return; } @@ -227,11 +229,14 @@ function useDocumentSync( try { model.receive_changes(base64Decode(data)); if (editorRef.current) { + // Suppress MutationObserver during DOM update. Use + // requestAnimationFrame to reset the flag AFTER the observer's + // microtask has fired so it doesn't schedule a spurious delta send. suppressMutations.current = true; const caretOffset = saveCaretOffset(editorRef.current); editorRef.current.innerHTML = model.get_content_as_html(); restoreCaretOffset(editorRef.current, caretOffset); - suppressMutations.current = false; + requestAnimationFrame(() => { suppressMutations.current = false; }); onContentChanged(); } logger.info("[DocumentView] Applied remote delta successfully");