Use a transport with a mounted diagram so peers converge on document edits, retain edits made while disconnected, and optionally show presence or anchored comments.
When to use this
Use the component's collab prop when a framework binding mounts the diagram. Use render and createSyncSession when you mount the JavaScript surface yourself or need a headless peer. A session synchronizes the document; it does not provide rooms, authentication, or storage.
The transport is broadcast-to-others: send() never echoes a message to its sender. Reconnection depends on status notifications, so implement onStatus() in a custom transport. The shipped transports are BroadcastChannelTransport, MemoryTransport, and WebSocketTransport; use BroadcastChannelTransport for two tabs and WebSocketTransport when your server carries the channel.
Mount two synchronized diagrams
Give both peers the same room name and different actor IDs. Each mounted instance joins at mount and leaves at unmount. The remote instance repaints the merged document after a local edit.
JavaScript
This sample mounts two real diagrams in one page. Drag a node in either pane: the other pane moves the same node. The containers have height so the diagrams paint.
tsconst peerA = document.createElement('div');
peerA.id = 'peer-a';
peerA.style.height = '400px';
const peerB = document.createElement('div');
peerB.id = 'peer-b';
peerB.style.height = '400px';
document.body.append(peerA, peerB);
import { render } from '@grafloria/element';
import { BroadcastChannelTransport, createSyncSession } from '@grafloria/engine';
const nodes = [
{ id: 'a', label: 'Ingest', position: { x: 60, y: 80 }, size: { width: 150, height: 66 } },
{ id: 'b', label: 'Transform', position: { x: 300, y: 80 }, size: { width: 150, height: 66 } },
];
const edges = [{ id: 'e1', source: 'a', target: 'b' }];
const room = `grafloria-${Math.random().toString(36).slice(2)}`;
const first = render({ nodes, edges }, '#peer-a');
const second = render({ nodes: structuredClone(nodes), edges: structuredClone(edges) }, '#peer-b');
const firstTransport = new BroadcastChannelTransport({ name: room, actor: 'ana' });
const secondTransport = new BroadcastChannelTransport({ name: room, actor: 'ben' });
const firstSession = createSyncSession(first.getModel(), firstTransport, { actor: 'ana', batch: false });
const secondSession = createSyncSession(second.getModel(), secondTransport, { actor: 'ben', batch: false });
firstSession.join();
secondSession.join();
window.addEventListener('beforeunload', () => {
firstSession.leave();
secondSession.leave();
first.dispose();
second.dispose();
});
render() returns a live DiagramInstance. Its getModel() supplies the document to the session; the instance remains the object that paints and receives interaction. batch: false makes each change cross the demo channel as it occurs rather than waiting for a batch.
Angular, React, and Vue
The bindings use the same engine seam. Keep each transport and its actor stable for the mounted component's lifetime; changing the room means remounting. These examples show two peers, so each pane receives an independent copy of the data.
tsimport { Component } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import { BroadcastChannelTransport } from '@grafloria/engine';
@Component({
standalone: true,
imports: [DiagramCanvasComponent],
template: `
<grafloria-diagram-canvas [nodes]="nodesA" [edges]="edges" [collab]="collabA" style="display:block;height:400px" />
<grafloria-diagram-canvas [nodes]="nodesB" [edges]="edges" [collab]="collabB" style="display:block;height:400px" />
`,
})
export class CollaborationComponent {
private readonly room = `angular-${Math.random().toString(36).slice(2)}`;
readonly nodesA = [
{ id: 'a', position: { x: 60, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Ingest' } },
{ id: 'b', position: { x: 320, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Publish' } },
];
readonly nodesB = [
{ id: 'a', position: { x: 60, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Ingest' } },
{ id: 'b', position: { x: 320, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Publish' } },
];
readonly edges = [{ id: 'e1', source: 'a', target: 'b' }];
readonly collabA = { transport: new BroadcastChannelTransport({ name: this.room, actor: 'ana' }), actor: 'ana' };
readonly collabB = { transport: new BroadcastChannelTransport({ name: this.room, actor: 'ben' }), actor: 'ben' };
}
tsximport { component$, noSerialize, useSignal } from '@builder.io/qwik';
import { GrafloriaFlow, type GrafloriaCollabOptions } from '@grafloria/qwik';
import { BroadcastChannelTransport } from '@grafloria/engine';
const nodes = [
{ id: 'a', position: { x: 60, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Ingest' } },
{ id: 'b', position: { x: 320, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Publish' } },
];
const edges = [{ id: 'e1', source: 'a', target: 'b' }];
export default component$(() => {
const collab = useSignal(() => {
const room = `qwik-${Math.random().toString(36).slice(2)}`;
const a: GrafloriaCollabOptions = { transport: new BroadcastChannelTransport({ name: room, actor: 'ana' }), actor: 'ana' };
const b: GrafloriaCollabOptions = { transport: new BroadcastChannelTransport({ name: room, actor: 'ben' }), actor: 'ben' };
return { a: noSerialize(a), b: noSerialize(b) };
});
return <div style={{ display: 'flex', height: '400px' }}><GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} collab={collab.value.a} style={{ flex: '1' }} /><GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} collab={collab.value.b} style={{ flex: '1' }} /></div>;
});
tsximport { useMemo } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import { BroadcastChannelTransport } from '@grafloria/engine';
const nodes = [
{ id: 'a', position: { x: 60, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Ingest' } },
{ id: 'b', position: { x: 320, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Publish' } },
];
const edges = [{ id: 'e1', source: 'a', target: 'b' }];
const nodesB = [
{ id: 'a', position: { x: 60, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Ingest' } },
{ id: 'b', position: { x: 320, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Publish' } },
];
export default function Collaboration() {
const peers = useMemo(() => {
const room = `react-${Math.random().toString(36).slice(2)}`;
return {
a: { transport: new BroadcastChannelTransport({ name: room, actor: 'ana' }), actor: 'ana' },
b: { transport: new BroadcastChannelTransport({ name: room, actor: 'ben' }), actor: 'ben' },
};
}, []);
return <div style={{ display: 'flex', height: 400 }}><GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} collab={peers.a} style={{ flex: 1 }} /><GrafloriaFlow defaultNodes={structuredClone(nodes)} defaultEdges={edges} collab={peers.b} style={{ flex: 1 }} /></div>;
}
vue<script setup lang="ts"> import { BroadcastChannelTransport } from '@grafloria/engine'; import { GrafloriaFlow } from '@grafloria/vue'; const room = `vue-${Math.random().toString(36).slice(2)}`; const nodes = [ { id: 'a', position: { x: 60, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Ingest' } }, { id: 'b', position: { x: 320, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Publish' } }, ]; const edges = [{ id: 'e1', source: 'a', target: 'b' }]; const collabA = { transport: new BroadcastChannelTransport({ name: room, actor: 'ana' }), actor: 'ana' }; const collabB = { transport: new BroadcastChannelTransport({ name: room, actor: 'ben' }), actor: 'ben' }; const nodesB = [ { id: 'a', position: { x: 60, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Ingest' } }, { id: 'b', position: { x: 320, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Publish' } }, ]; </script> <template> <div style="display:flex;height:400px"><GrafloriaFlow :default-nodes="nodes" :default-edges="edges" :collab="collabA" style="flex:1" /><GrafloriaFlow :default-nodes="nodesB" :default-edges="edges" :collab="collabB" style="flex:1" /></div> </template>
Each framework component joins the session at mount and tears it down at unmount.
Preserve edits across a disconnect
Use MemoryHub to test this flow without a server. Disconnect both transports, edit both models, then reconnect and call the session's sync operation. The local op logs exchange the missed operations; both diagrams contain both peers' edits after the round.
For a production transport, call connect() and disconnect() without destroying the transport. Its onStatus() callback lets the adapter start anti-entropy when the channel reports that it is connected again. A send() while disconnected is a no-op, not an error, so do not treat it as delivery.
Add presence and comments
Presence is ephemeral. Pass presence: { name: 'Ana' } beside transport and actor in the component's collab object; the binding paints the other peer's cursor and selection without putting cursor traffic in the document op log. The same option shape works in Angular, React, and Vue.
Anchored comments are document collaboration data. Set comments: true on the mounted component, then read the store from the instance with getCommentStore(). A non-null CommentStore can create and reply to threads, resolve or reopen them, list threads for a node or link, and report unread messages. The store exposes onChange() for updating your comments UI. Pass a shared store when your host owns its lifetime; otherwise the comments: true option creates one for the canvas.
The component creates the store before onInit exposes the live instance. getCommentStore() is null when comments are not enabled. Use the framework's ready-made comment panel when you want the library's threaded UI; your own UI can subscribe to onChange() and use the store's thread methods.
What you get
- Two mounted peers display the same document after edits cross the transport.
- Edits made while disconnected remain in each peer's log and converge after reconnect.
- Presence cursors and selections travel as ephemeral awareness, not document edits.
- Comment threads attach to nodes or links and provide a synchronized, readable store.
Pitfalls
- Give every peer a distinct actor ID and reuse the same room/channel name.
- Keep a transport instance stable while its diagram is mounted; remount to change rooms.
- Implement
onStatus()for a custom transport, or reconnect catch-up cannot start. - A transport does not supply authentication, rooms, or persistence; provide those around the channel.
Demos
The Two tabs, live demo uses BroadcastChannelTransport and shows symmetric live edits with no server.
The Offline & reconnect demo disconnects both peers, edits each side, and reconnects them so both offline edits survive.
The Live cursors demo shows presence on a separate layer while the diagram remains unchanged.
Was this page helpful?