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 4df1269177..edb65cbbbb 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx @@ -287,8 +287,11 @@ function useDocumentSync( const applyDeltaEvent = (event: import("matrix-js-sdk/src/matrix").MatrixEvent): void => { if (event.getRoomId() !== room.roomId) return; if (event.getType() !== DOC_DELTA_EVENT_TYPE) return; - // Skip our own events — local model already has our changes. - if (event.getSender() === client.getUserId()) return; + // Skip events sent by this exact device — we already have those + // changes in our local model. We must NOT skip events from the same + // user on a different device (e.g. two tabs open). + const senderDeviceId = event.getContent<{ device_id?: string }>().device_id; + if (event.getSender() === client.getUserId() && senderDeviceId === client.getDeviceId()) return; const data = event.getContent<{ data?: string }>().data; if (!data) return; logger.info(`[DocumentView] Matrix delta event from ${event.getSender()}, applying ${data.length}b (base64)`); @@ -384,6 +387,7 @@ function useDocumentSync( await client.sendEvent(room.roomId, DOC_DELTA_EVENT_TYPE as any, { data: base64Encode(delta), heads, + device_id: client.getDeviceId(), }); deltaSendCount.current++; @@ -432,6 +436,7 @@ function useDocumentSync( client.sendEvent(room.roomId, DOC_DELTA_EVENT_TYPE as any, { data: base64Encode(delta), heads, + device_id: client.getDeviceId(), }).catch((e) => logger.warn("[DocumentView] Failed to send final delta on unmount", e)); } // Always save snapshot on close so next load starts fresh. diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/useDocumentRTC.ts b/apps/web/src/components/views/rooms/wysiwyg_composer/useDocumentRTC.ts index 1c76a0f539..8fb3195228 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/useDocumentRTC.ts +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/useDocumentRTC.ts @@ -19,7 +19,7 @@ Please see LICENSE files in the repository root for full details. * `isConnected` stays false and the caller should fall back to Matrix events. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type Room as MatrixRoom, type MatrixClient } from "matrix-js-sdk/src/matrix"; import { isLivekitTransportConfig } from "matrix-js-sdk/src/matrixrtc"; import { logger } from "matrix-js-sdk/src/logger"; @@ -245,5 +245,16 @@ export function useDocumentRTC( .catch((e: unknown) => logger.warn("[DocumentRTC] Failed to publish cursor", e)); }, []); - return { publishDelta, publishCursor, onDeltaRef, onCursorRef, onPeerLeaveRef, isConnected }; + // Wrap in useMemo so the returned object reference is stable across renders + // (only changes when isConnected changes). Without this, DocumentView's + // wiring useEffect ([rtc, applyDeltaBytes]) fires on every render because + // the plain object literal `{}` produces a new reference each time. + const result = useMemo( + () => ({ publishDelta, publishCursor, onDeltaRef, onCursorRef, onPeerLeaveRef, isConnected }), + // publishDelta/publishCursor are useCallback stable; refs are always stable; + // only isConnected can change. + // eslint-disable-next-line react-hooks/exhaustive-deps + [isConnected], + ); + return result; }