Collab: broadcasting local edits and reconciling remote ones
Collaboration in excalidraw-app/collab/Collab.tsx is the app-shell layer that turns local Scene edits into socket broadcasts and turns incoming remote element arrays back into local Scene state, all without corrupting undo history. It does this entirely through the same public excalidrawAPI surface (getSceneElementsIncludingDeleted, updateScene) any embedder would use, resolving conflicts with per-element version/versionNonce comparisons and tagging remote writes with CaptureUpdateAction.NEVER.
syncElements / broadcastElements: how a local edit leaves the Scene
excalidraw-app/collab/Collab.tsx · lines 943–957Before sending anything, broadcastElements compares getSceneVersion(elements) - a single number summarizing the whole array - against the version this client last broadcast or received. If it hasn't grown, nothing is sent. This guard is what stops a remote update, once written back into the Scene, from being mistaken for a new local edit and echoed straight back out.
Once the version has actually moved forward, the array goes to portal.broadcastScene tagged WS_SUBTYPES.UPDATE, this client records the new version as its own last-seen value, and it queues a throttled full-scene resync (queueBroadcastAllElements) as a safety net in case an incremental message gets dropped.
syncElements is the entry point the app shell actually calls (via collabAPI.syncElements) on every local Scene change: it broadcasts over the socket and, on the same throttle cadence, queues a save of the same elements to Firebase so late joiners and reconnects have somewhere to catch up from.
broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => { if ( getSceneVersion(elements) > this.getLastBroadcastedOrReceivedSceneVersion() ) { this.portal.broadcastScene(WS_SUBTYPES.UPDATE, elements, false); this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements); this.queueBroadcastAllElements(); } }; syncElements = (elements: readonly OrderedExcalidrawElement[]) => { this.broadcastElements(elements); this.queueSaveToFirebase(); };
_reconcileElements: merging a remote array with the local Scene
excalidraw-app/collab/Collab.tsx · lines 754–786_reconcileElements takes only the remote array as its argument; existingElements is pulled fresh from the Scene via getSceneElementsIncludingDeleted - the same authoritative array from earlier modules, but here including tombstoned (deleted) elements, since a remote delete has to be compared against a local one too.
restoreElements normalizes the raw remote payload against the existing elements before merging. The comment explains why this can't happen after reconciliation instead: it would regenerate transient state such as appState.newElement and break it.
reconcileElements is the library merge function: for every remote element it decides, per element, whether to keep the local copy or accept the remote one, then folds in whatever local elements the remote array never mentioned.
bumpElementVersions then walks the merged array and, wherever the pre-merge local copy was newer or had a different versionNonce, bumps that element's version again relative to the local one. This keeps versions monotonically increasing across the merge, so this client's own next broadcastElements call still sees forward progress instead of getting stuck thinking nothing changed.
Before the reconciled elements ever reach the Scene, this line records their scene version as the last broadcast-or-received one. Order matters here: the updateScene call that follows (inside handleRemoteSceneUpdate) fires onChange synchronously, and without this line broadcastElements would see a 'new' version and re-broadcast a scene this client only just received.
The reconciled, version-bumped array is returned to whichever caller invoked it - the client-broadcast socket handler or saveCollabRoomToFirebase - which passes it straight to handleRemoteSceneUpdate.
private _reconcileElements = ( remoteElements: readonly RemoteExcalidrawElement[], ): ReconciledExcalidrawElement[] => { const appState = this.excalidrawAPI.getAppState(); const existingElements = this.getSceneElementsIncludingDeleted(); // NOTE ideally we restore _after_ reconciliation but we can't do that // as we'd regenerate even elements such as appState.newElement which would // break the state remoteElements = restoreElements(remoteElements, existingElements); let reconciledElements = reconcileElements( existingElements, remoteElements, appState, ); reconciledElements = bumpElementVersions( reconciledElements, existingElements, ); // Avoid broadcasting to the rest of the collaborators the scene // we just received! // Note: this needs to be set before updating the scene as it // synchronously calls render. this.setLastBroadcastedOrReceivedSceneVersion( getSceneVersion(reconciledElements), ); return reconciledElements; };
export const shouldDiscardRemoteElement = ( localAppState: AppState, local: OrderedExcalidrawElement | undefined, remote: RemoteExcalidrawElement,): boolean => { if ( local && // local element is being edited (local.id === localAppState.editingTextElement?.id || local.id === localAppState.resizingElement?.id || local.id === localAppState.newElement?.id || // local element is newer local.version > remote.version || // resolve conflicting edits deterministically by taking the one with // the lowest versionNonce (local.version === remote.version && local.versionNonce <= remote.versionNonce)) ) { return true; } return false;};
packages/excalidraw/data/reconcile.ts · lines 23–44private handleRemoteSceneUpdate = ( elements: ReconciledExcalidrawElement[], ) => { this.excalidrawAPI.updateScene({ elements, captureUpdate: CaptureUpdateAction.NEVER, }); this.loadImageFiles(); };
excalidraw-app/collab/Collab.tsx · lines 803–812Check your understanding
Answered in place — nothing is graded, everything is explained. 0 / 3 passed
Before broadcastElements sends a scene update over the socket, what does it check?
Why does handleRemoteSceneUpdate call updateScene with captureUpdate: CaptureUpdateAction.NEVER?
A local element and its remote counterpart both have version 7 but different versionNonce values. Which one does reconciliation keep?