Connect each diagram instance to a transport and give each peer its own actor id. Connected peers share edits; after a transport reconnects, the session exchanges missed operations so the replicas converge.
Connect two peers
Use the component's collab prop when both diagrams run in a supported framework. Each peer gets its own transport connection and actor id, while both transports connect through the same room. The framework examples use MemoryHub to put two visible peers on one page; edits cross between their canvases. The plain JavaScript render entry point mounts a diagram, but does not expose that component prop.
For the framework bindings and typed graph data, see Execute and compute flows; this page adds the per-peer collab configuration that connects mounted diagrams to the same sync room.
html<!-- The plain JavaScript render() API mounts a diagram but does not expose a collab option. -->
<div id="app" style="height: 460px"></div>
<script type="module">
import { render } from '@grafloria/element';
const container = document.getElementById('app');
const instance = render({
nodes: [
{ id: 'a', label: 'Alpha', position: { x: 80, y: 90 }, size: { width: 150, height: 66 } },
{ id: 'b', label: 'Beta', position: { x: 320, y: 90 }, size: { width: 150, height: 66 } },
],
edges: [{ id: 'e1', source: 'a', target: 'b' }],
}, container);
instance.fitView();
</script>
tsimport { Component } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import { MemoryHub } from '@grafloria/engine';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';
const hub = new MemoryHub();
@Component({
standalone: true,
imports: [DiagramCanvasComponent],
template: `
<div style="display:flex; height:460px">
<grafloria-diagram-canvas [(nodes)]="nodesA" [(edges)]="edgesA"
[collab]="collabA" style="display:block; flex:1" />
<grafloria-diagram-canvas [(nodes)]="nodesB" [(edges)]="edgesB"
[collab]="collabB" style="display:block; flex:1" />
</div>
`,
})
export class AppComponent {
nodesA: NodeSpec[] = [
{ id: 'a', label: 'Alpha', position: { x: 80, y: 90 }, size: { width: 150, height: 66 } },
{ id: 'b', label: 'Beta', position: { x: 320, y: 90 }, size: { width: 150, height: 66 } },
];
edgesA: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b' }];
nodesB: NodeSpec[] = [
{ id: 'a', label: 'Alpha', position: { x: 80, y: 90 }, size: { width: 150, height: 66 } },
{ id: 'b', label: 'Beta', position: { x: 320, y: 90 }, size: { width: 150, height: 66 } },
];
edgesB: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b' }];
collabA = { transport: hub.connect('ana'), actor: 'ana' };
collabB = { transport: hub.connect('bo'), actor: 'bo' };
}
tsximport { component$, noSerialize, useSignal, useVisibleTask$, type NoSerialize } from '@builder.io/qwik';
import { GrafloriaFlow, type GrafloriaCollabOptions } from '@grafloria/qwik';
import { MemoryHub } from '@grafloria/engine';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';
const nodes: NodeSpec[] = [
{ id: 'a', label: 'Alpha', position: { x: 80, y: 90 }, size: { width: 150, height: 66 } },
{ id: 'b', label: 'Beta', position: { x: 320, y: 90 }, size: { width: 150, height: 66 } },
];
const edges: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b' }];
export default component$(() => {
const collabA = useSignal<NoSerialize<GrafloriaCollabOptions> | undefined>();
const collabB = useSignal<NoSerialize<GrafloriaCollabOptions> | undefined>();
useVisibleTask$(() => {
const hub = new MemoryHub();
collabA.value = noSerialize({ transport: hub.connect('ana'), actor: 'ana' });
collabB.value = noSerialize({ transport: hub.connect('bo'), actor: 'bo' });
});
const peerA = collabA.value;
const peerB = collabB.value;
return (
<div style={{ display: 'flex', height: '460px' }}>
{peerA && peerB && (
<>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} collab={peerA}
style={{ display: 'block', flex: '1' }} />
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} collab={peerB}
style={{ display: 'block', flex: '1' }} />
</>
)}
</div>
);
});
tsximport { useMemo } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import { MemoryHub } from '@grafloria/engine';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';
const nodes: NodeSpec[] = [
{ id: 'a', label: 'Alpha', position: { x: 80, y: 90 }, size: { width: 150, height: 66 } },
{ id: 'b', label: 'Beta', position: { x: 320, y: 90 }, size: { width: 150, height: 66 } },
];
const edges: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b' }];
export default function App() {
const peers = useMemo(() => {
const hub = new MemoryHub();
return {
a: { transport: hub.connect('ana'), actor: 'ana' },
b: { transport: hub.connect('bo'), actor: 'bo' },
};
}, []);
return (
<div style={{ display: 'flex', height: 460 }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} collab={peers.a}
style={{ display: 'block', flex: 1 }} />
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} collab={peers.b}
style={{ display: 'block', flex: 1 }} />
</div>
);
}
vue<script setup lang="ts"> import { GrafloriaFlow } from '@grafloria/vue'; import { MemoryHub } from '@grafloria/engine'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const hub = new MemoryHub(); const nodesA: NodeSpec[] = [ { id: 'a', label: 'Alpha', position: { x: 80, y: 90 }, size: { width: 150, height: 66 } }, { id: 'b', label: 'Beta', position: { x: 320, y: 90 }, size: { width: 150, height: 66 } }, ]; const nodesB: NodeSpec[] = [ { id: 'a', label: 'Alpha', position: { x: 80, y: 90 }, size: { width: 150, height: 66 } }, { id: 'b', label: 'Beta', position: { x: 320, y: 90 }, size: { width: 150, height: 66 } }, ]; const edgesA: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b' }]; const edgesB: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b' }]; const collabA = { transport: hub.connect('ana'), actor: 'ana' }; const collabB = { transport: hub.connect('bo'), actor: 'bo' }; </script> <template> <div style="display:flex; height:460px"> <GrafloriaFlow :default-nodes="nodesA" :default-edges="edgesA" :collab="collabA" style="display:block; flex:1" /> <GrafloriaFlow :default-nodes="nodesB" :default-edges="edgesB" :collab="collabB" style="display:block; flex:1" /> </div> </template>
Each Angular, Qwik, React, and Vue version mounts two canvases with the same two nodes and connecting edge. Move a node in either pane: the other pane receives the operation and renders the edit. The framework canvas joins a CRDT sync session when it mounts and leaves when it unmounts; set up its collaboration options before mounting.
The diagram data is the document peers converge on. CRDT operations merge per property, so concurrent changes to different properties of the same node—for example, one peer moving it while another renames it—both survive. This is not whole-node last-write-wins replacement.
Options and boundaries
| Option | Type | Default | What it does |
|---|---|---|---|
collab | Object containing a transport and actor id | Unset | Joins the canvas to a sync session. Give each peer a distinct actor id and connect its transport to the same room. |
The collaboration option is fixed for the lifetime of a mounted instance. If a peer needs a new transport or actor, mount an instance with that configuration rather than changing collab in place. In Qwik, create transports in useVisibleTask$ and retain them with Qwik's non-serializable state handling; a live transport is not serializable application data.
Grafloria starts an anti-entropy round when a transport reports that it has reconnected, exchanging the operations each peer missed. Grafloria supplies convergence and the operation log, not your product's rooms, authentication, or storage; implement those at the transport and application layers.
See the synchronization states
In the conflict-resolution demo, peer A moves a node while peer B renames it. The status readouts disagree before exchange and show both edits after they converge. The initial view shows both peers with the same Draft node.
Open the live conflict-resolution demo.
For disconnected edits, open the offline-and-reconnect demo. Disconnect both peers, edit each side, and reconnect; the missed operations are exchanged and both diagrams converge. Its initial view shows two matching diagrams with Alpha connected to Beta.
Open the live offline-and-reconnect demo.
For actual cross-tab collaboration without a server, open the two-tabs demo in two browser tabs. It uses BroadcastChannel for transport, so edits in either tab reach the other; each tab keeps its own viewport. Its initial view shows the same two-node diagram in both panes.
Pitfalls
- Use one hub/room for peers that share a document, but a distinct actor id for each peer. The sender does not receive its own echo from
MemoryHub. - A shared transport has to report status changes if you rely on automatic reconnect catch-up.
- The plain JavaScript
renderAPI mounts a diagram but does not accept the frameworkcollabprop. Its sample above demonstrates mounting only; it does not connect peers. Use a framework binding's collaboration prop for peer synchronization. - Do not treat the collaboration channel as persistence or authorization. The app still owns rooms, identity, and storage.
Related
- Add presence and comments — add live cursors and anchored threads to a collaboration session.
- The graph model and document — understand the document peers synchronize.
- Command history and edits — understand how local edits become operations.
Was this page helpful?