# Synchronize editors

Use collaboration when several canvases edit the same document. Pass a shipped transport and a unique actor id through `collab`: dragging a node in one canvas moves it in the other. The engine merges document edits per property; the framework binding mounts the renderer and owns the session lifecycle.

## 1. Choose a transport and seed both peers

Use [`BroadcastChannelTransport`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-sync-classes) for tabs on the same browser origin, without a server. Use [`WebSocketTransport`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-sync-classes#websockettransport) for editors connected through your server. Each peer needs a different actor id, the same room, and matching initial document ids and content.

Install the binding you use, together with the packages the samples import.

JavaScript:

```bash
npm install @grafloria/element @grafloria/engine @grafloria/renderer
```
Angular:

```bash
npm install @grafloria/angular @grafloria/engine @grafloria/renderer @grafloria/element @angular/common @angular/core @angular/forms @angular/platform-browser rxjs
```
Qwik:

```bash
npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @grafloria/element @builder.io/qwik
```
React:

```bash
npm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom
```
Vue:

```bash
npm install @grafloria/vue @grafloria/engine @grafloria/renderer @grafloria/element vue
```
Put this shared file beside your component. The typed [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec) arrays draw two connected nodes. Each editor gets its own copy of the seed.

```ts title="shared.ts"
import { BroadcastChannelTransport } from '@grafloria/engine';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';

export function seedNodes(): NodeSpec[] {
  return [
    { id: 'a', label: 'Ingest', position: { x: 40, y: 80 },
      size: { width: 140, height: 66 } },
    { id: 'b', label: 'Publish', position: { x: 240, y: 80 },
      size: { width: 140, height: 66 } },
  ];
}

export function seedEdges(): EdgeSpec[] {
  return [{ id: 'e1', source: 'a', target: 'b' }];
}

export function makePeer(room: string, actor: string) {
  return {
    transport: new BroadcastChannelTransport({ name: room, actor }),
    actor,
    presence: true,
  };
}
```

Each sample renders two side-by-side canvases with Ingest connected to Publish and an Undo Ana button. For separate tabs, use a shared document-specific room name instead of generating a new room in each tab, and generate a distinct actor id for each editor.

## 2. Mount the editors and keep actor-local undo

Pass the options through your framework's canvas component. The binding joins at mount and leaves at unmount. In JavaScript, join explicitly after mounting the instance.

Keep the session delivered by the binding's collaboration-ready event. Its [`SyncAdapter`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-sync-classes#syncadapter) exposes the local [`Replica`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-collab). Call `replica.undo()` for collaboration-aware undo: it reverses this actor's edit, not a later edit made by another actor. The Undo Ana button below uses that path.

[`render`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core) returns the mounted [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance). [`createSyncSession`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-sync-functions) attaches collaboration to that instance's live model.

[`DiagramCanvasComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent) uses two-way node and edge bindings and emits `collabReady` with the session.

For Qwik, give the two [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik) components separate [`GrafloriaCollabOptions`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik#grafloriacollaboptions) for the same room and retain Ana's session through `onCollabReady$` for actor-local undo; see [Documents and kits](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/documents-and-kits) for browser-only setup and resumable state.

[`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react) accepts `collab` and calls `onCollabReady`. Keep the session in a ref and use uncontrolled defaults so the instance owns the graph.

[`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue) accepts `:collab` and emits `collab-ready`. Keep the session in a shallow ref.

:::code-group
```ts title="JavaScript"
import { render } from '@grafloria/element';
import { createSyncSession } from '@grafloria/engine';
import { makePeer, seedNodes, seedEdges } from './shared';

export function mountEditors(container: HTMLElement): () => void {
  const room = 'editors-' + Math.random().toString(36).slice(2);
  const toolbar = document.createElement('div');
  const undo = document.createElement('button');
  undo.textContent = 'Undo Ana';
  toolbar.append(undo);
  const panes = document.createElement('div');
  panes.style.cssText = 'display:flex;gap:12px;height:400px';
  container.append(toolbar, panes);

  function mount(actor: string) {
    const host = document.createElement('div');
    host.style.cssText = 'flex:1;min-width:0;height:400px';
    host.setAttribute('aria-label', actor);
    panes.append(host);
    const instance = render({ nodes: seedNodes(), edges: seedEdges() }, host);
    const options = makePeer(room, actor);
    const session = createSyncSession(instance.getModel(), options.transport,
      { actor });
    session.join();
    return { instance, session, transport: options.transport };
  }

  const ana = mount('ana');
  const ben = mount('ben');
  undo.onclick = () => { ana.session.replica.undo(); };

  return () => {
    for (const peer of [ana, ben]) {
      peer.session.dispose();
      peer.session.replica.dispose();
      peer.transport.close();
      peer.instance.dispose();
    }
    toolbar.remove();
    panes.remove();
  };
}

const container = document.createElement('div');
document.body.append(container);
const unmountEditors = mountEditors(container);
const close = document.createElement('button');
close.textContent = 'Close editors';
close.onclick = () => { unmountEditors(); container.remove(); close.remove(); };
document.body.append(close);
```
```ts title="Angular"
import { Component } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import type { SyncAdapter } from '@grafloria/engine';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
import { makePeer, seedNodes, seedEdges } from './shared';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [DiagramCanvasComponent],
  template: `
    <button (click)="undoAna()">Undo Ana</button>
    <div style="display:flex;gap:12px;height:400px">
      <grafloria-diagram-canvas
        [(nodes)]="nodesA" [(edges)]="edgesA" [collab]="collabA"
        (collabReady)="sessionA = $event"
        style="display:block;flex:1;min-width:0;height:400px" />
      <grafloria-diagram-canvas
        [(nodes)]="nodesB" [(edges)]="edgesB" [collab]="collabB"
        style="display:block;flex:1;min-width:0;height:400px" />
    </div>
  `,
})
export class AppComponent {
  private readonly room = 'editors-' + Math.random().toString(36).slice(2);
  nodesA: NodeSpec[] = seedNodes();
  edgesA: EdgeSpec[] = seedEdges();
  nodesB: NodeSpec[] = seedNodes();
  edgesB: EdgeSpec[] = seedEdges();
  readonly collabA = makePeer(this.room, 'ana');
  readonly collabB = makePeer(this.room, 'ben');
  sessionA?: SyncAdapter;
  undoAna(): void { this.sessionA?.replica.undo(); }
}
```
```tsx title="Qwik"
import { component$, noSerialize, useSignal, useVisibleTask$,
  type NoSerialize } from '@builder.io/qwik';
import { GrafloriaFlow, type GrafloriaCollabOptions } from '@grafloria/qwik';
import type { SyncAdapter } from '@grafloria/engine';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
import { makePeer, seedNodes, seedEdges } from './shared';

export default component$(() => {
  const collabA = useSignal<NoSerialize<GrafloriaCollabOptions>>();
  const collabB = useSignal<NoSerialize<GrafloriaCollabOptions>>();
  const sessionA = useSignal<NoSerialize<SyncAdapter>>();
  const nodesA: NodeSpec[] = seedNodes();
  const nodesB: NodeSpec[] = seedNodes();
  const edgesA: EdgeSpec[] = seedEdges();
  const edgesB: EdgeSpec[] = seedEdges();

  useVisibleTask$(() => {
    const room = 'editors-' + Math.random().toString(36).slice(2);
    collabA.value = noSerialize(makePeer(room, 'ana'));
    collabB.value = noSerialize(makePeer(room, 'ben'));
  });

  return <div>
    <button onClick$={() => { sessionA.value?.replica.undo(); }}>Undo Ana</button>
    <div style={{ display: 'flex', gap: '12px', height: '400px' }}>
      {collabA.value && collabB.value && <>
        <GrafloriaFlow defaultNodes={nodesA} defaultEdges={edgesA}
          collab={collabA.value}
          onCollabReady$={(session) => { sessionA.value = noSerialize(session); }}
          style={{ flex: '1', minWidth: '0', height: '400px' }} />
        <GrafloriaFlow defaultNodes={nodesB} defaultEdges={edgesB}
          collab={collabB.value}
          style={{ flex: '1', minWidth: '0', height: '400px' }} />
      </>}
    </div>
  </div>;
});
```
```tsx title="React"
import { useMemo, useRef } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import type { SyncAdapter } from '@grafloria/engine';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
import { makePeer, seedNodes, seedEdges } from './shared';

export default function Editors() {
  const sessionA = useRef<SyncAdapter | null>(null);
  const peers = useMemo(() => {
    const room = 'editors-' + Math.random().toString(36).slice(2);
    const nodesA: NodeSpec[] = seedNodes();
    const nodesB: NodeSpec[] = seedNodes();
    const edgesA: EdgeSpec[] = seedEdges();
    const edgesB: EdgeSpec[] = seedEdges();
    return { a: makePeer(room, 'ana'), b: makePeer(room, 'ben'),
      nodesA, nodesB, edgesA, edgesB };
  }, []);

  return <div>
    <button onClick={() => { sessionA.current?.replica.undo(); }}>Undo Ana</button>
    <div style={{ display: 'flex', gap: 12, height: 400 }}>
      <GrafloriaFlow defaultNodes={peers.nodesA} defaultEdges={peers.edgesA}
        collab={peers.a} onCollabReady={(session) => { sessionA.current = session; }}
        style={{ flex: 1, minWidth: 0, height: 400 }} />
      <GrafloriaFlow defaultNodes={peers.nodesB} defaultEdges={peers.edgesB}
        collab={peers.b} style={{ flex: 1, minWidth: 0, height: 400 }} />
    </div>
  </div>;
}
```
```vue title="Vue"
<script setup lang="ts">
import { shallowRef } from 'vue';
import { GrafloriaFlow } from '@grafloria/vue';
import type { SyncAdapter } from '@grafloria/engine';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
import { makePeer, seedNodes, seedEdges } from './shared';

const room = 'editors-' + Math.random().toString(36).slice(2);
const nodesA: NodeSpec[] = seedNodes();
const nodesB: NodeSpec[] = seedNodes();
const edgesA: EdgeSpec[] = seedEdges();
const edgesB: EdgeSpec[] = seedEdges();
const collabA = makePeer(room, 'ana');
const collabB = makePeer(room, 'ben');
const sessionA = shallowRef<SyncAdapter>();
function ready(session: SyncAdapter): void { sessionA.value = session; }
function undoAna(): void { sessionA.value?.replica.undo(); }
</script>

<template>
  <button @click="undoAna">Undo Ana</button>
  <div style="display:flex;gap:12px;height:400px">
    <GrafloriaFlow :default-nodes="nodesA" :default-edges="edgesA"
      :collab="collabA" @collab-ready="ready"
      style="flex:1;min-width:0;height:400px" />
    <GrafloriaFlow :default-nodes="nodesB" :default-edges="edgesB"
      :collab="collabB" style="flex:1;min-width:0;height:400px" />
  </div>
</template>
```
:::

![The JavaScript sample shows two connected-node canvases, an Undo Ana button, and a Close editors button.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/38d64f0f37aa759ee22c479eee3662eb.png)

Angular renders the same pair through two canvas components.

![The Angular sample shows Ingest connected to Publish in each canvas and an Undo Ana button.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/a99ef17985d78484ea58cf4f2db98365.png)

Qwik mounts the pair after creating the transports in the browser.

React seeds each flow with its own node and edge arrays.

Vue renders the pair with uncontrolled defaults.

Drag Ingest in Ana's left canvas, then Publish in Ben's right canvas. Both canvases show both moves. Click Undo Ana: it reverses Ana's last captured position write on both peers, while Ben's Publish move stays. These samples do not group drag updates into a replica transaction, so one click does not undo the whole drag. Remote changes do not enter Ana's replica undo stack. If another actor has already superseded Ana's write to the same property, undo skips that write instead of restoring stale state.

For your own multi-mutation action, use `session.replica.transact()` to group its mutations into one local undo step. `undo()` and `redo()` return the operations they emit; those inverse edits travel through the same synchronization path. See [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history) for the separate engine command API.

## 3. Handle conflicts and reconnects

The merge unit is a property path, not a whole node. A move and a rename of the same node survive together because position and label are separate registers. Two writes to the same register resolve by Lamport clock, then actor id as the tie-breaker—not by network arrival order. Try the [Conflict resolution demo](https://grafloria.com/demos/collab/conflict-resolution.html) to stage a move and rename before exchanging edits.

Read `transport.status` for the current [`TransportStatus`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-sync-types#transportstatus), and subscribe with `onStatus()` for changes. It reports `connected` or `disconnected`; it is not a document-convergence indicator. A WebSocket session's collaboration-ready callback follows `join()`, not necessarily the socket opening.

`disconnect()` deliberately drops a transport without destroying it. Edits still enter the live replica's log while disconnected. `connect()` reopens the channel; the session listens for the connected transition and exchanges missing operations automatically. Use `close()` only when you no longer need the transport. Reconnect catch-up needs another peer that still holds the history: it is not durable storage.

For a server-backed editor, replace each `makePeer()` call with options containing a WebSocket transport. Use the same room URL for both peers, with different actor ids:

```ts title="socket-peer.ts"
import { WebSocketTransport } from '@grafloria/engine';

export function makeSocketPeer(url: string, actor: string) {
  return {
    transport: new WebSocketTransport({ url }),
    actor,
    presence: true,
  };
}
```

Pass your relay's URL, such as `wss://api.example.com/diagrams/ingest`, as `url`. The relay broadcasts each received frame verbatim to every other socket in that room. Your product owns room membership, authentication and persistence; Grafloria does not ship that server.

An unexpected socket close retries automatically with exponential backoff. A deliberate `disconnect()` does not retry; call `connect()` to return. See the [Offline and reconnect demo](https://grafloria.com/demos/collab/offline-and-reconnect.html) for edits made on both sides of a dropped connection.

## Options that matter

| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `collab.transport` | [`SyncTransport`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-sync-interfaces#synctransport) | Required when collaboration is enabled | Carries messages and connection status. |
| `collab.actor` | `string` | Required when collaboration is enabled | Identifies this peer; keep it unique across peers. |
| `collab.presence` | `boolean` or [`BindPresenceOptions`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-presence#bindpresenceoptions) | No binding when omitted | `true` mounts live cursors and remote selection outlines with default settings. |
| Broadcast channel `name` | `string` | Required | Names the shared room; namespace it by document id. |
| WebSocket `url` | `string` | Required | Connects to your relay. |
| WebSocket `reconnect` | `boolean` | Enabled unless `false` | Retries unexpected closes. |
| WebSocket `reconnectBaseMs` | `number` | `250` | Initial retry delay, reset after a successful open. |
| WebSocket `reconnectMaxMs` | `number` | `10000` | Caps the doubling retry delay. |

## Pitfalls

- Keep `collab` stable for the mounted canvas. To change rooms or actors, remount the editor; the binding attaches the session for the instance's lifetime.
- Do not use the same actor id for two peers. Ordering depends on actor identity, and the broadcast transport filters messages from its own actor.
- Start both peers from the same document. Creating the session captures subsequent edits; it does not turn independently seeded content into a shared initial operation history.
- If you switch to controlled React data, wire the change-event return path described in the [React quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/react-quick-start).
- Do not confuse presence with saved document edits. Cursor and selection awareness travels separately and does not enter the operation log.

## Live demos and related pages

- [Two tabs, live](https://grafloria.com/demos/collab/two-tabs-live.html) — drag between two independent editors over a real broadcast channel; [source](https://github.com/grafloria/grafloria/blob/cccb505e76c7af26c8bafecaba8146db7d8df928/demos/collab/two-tabs-live.html).
- [Collaboration-aware undo](https://grafloria.com/demos/interaction/undo-redo.html) — reverse one actor's edit while preserving the other actor's move.
- [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents) — persist the live document rather than a framework projection.
- [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history) — execute user-facing edits through the engine's command stack.
