fix(docs): fix MutationObserver feedback loop and own-event echo

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. <p> vs <br> 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>
This commit is contained in:
David Langley 2026-03-04 21:16:23 +00:00
parent 5c5b3d7215
commit 3f42a0bcd3

View File

@ -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");