fix: resolve CRDT collaboration sync by registering broadcast listener before channel subscribe

The yjs-update broadcast listener was added after the Supabase channel
was already subscribed, which meant it never received messages. Moved
the listener registration to the builder chain before .subscribe()
(matching how cursor/node-lock listeners work), and removed the broken
isRemoteUpdateRef guard that caused ReferenceErrors preventing local
changes from reaching the CRDT.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-24 16:08:32 -03:00
parent dc7875eeea
commit 34815d70ee
3 changed files with 31 additions and 25 deletions

View File

@ -527,6 +527,12 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
const [characters, setCharacters] = useState<Character[]>(migratedData.characters) const [characters, setCharacters] = useState<Character[]>(migratedData.characters)
const [variables, setVariables] = useState<Variable[]>(migratedData.variables) const [variables, setVariables] = useState<Variable[]>(migratedData.variables)
// Refs to always have the latest characters/variables for the CRDT persist callback
const charactersRef = useRef(characters)
const variablesRef = useRef(variables)
charactersRef.current = characters
variablesRef.current = variables
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [showShare, setShowShare] = useState(false) const [showShare, setShowShare] = useState(false)
const [showHistory, setShowHistory] = useState(false) const [showHistory, setShowHistory] = useState(false)
@ -540,7 +546,6 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
const realtimeRef = useRef<RealtimeConnection | null>(null) const realtimeRef = useRef<RealtimeConnection | null>(null)
const crdtRef = useRef<CRDTManager | null>(null) const crdtRef = useRef<CRDTManager | null>(null)
const auditRef = useRef<AuditTrailRecorder | null>(null) const auditRef = useRef<AuditTrailRecorder | null>(null)
const isRemoteUpdateRef = useRef(false)
const cursorThrottleRef = useRef<number>(0) const cursorThrottleRef = useRef<number>(0)
const [collaborationNotifications, setCollaborationNotifications] = useState<CollaborationNotification[]>([]) const [collaborationNotifications, setCollaborationNotifications] = useState<CollaborationNotification[]>([])
const [nodeLocks, setNodeLocks] = useState<Map<string, NodeLockInfo>>(new Map()) const [nodeLocks, setNodeLocks] = useState<Map<string, NodeLockInfo>>(new Map())
@ -554,14 +559,10 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
const crdtManager = new CRDTManager({ const crdtManager = new CRDTManager({
onNodesChange: (crdtNodes: FlowchartNode[]) => { onNodesChange: (crdtNodes: FlowchartNode[]) => {
isRemoteUpdateRef.current = true
setNodes(toReactFlowNodes(crdtNodes)) setNodes(toReactFlowNodes(crdtNodes))
isRemoteUpdateRef.current = false
}, },
onEdgesChange: (crdtEdges: FlowchartEdge[]) => { onEdgesChange: (crdtEdges: FlowchartEdge[]) => {
isRemoteUpdateRef.current = true
setEdges(toReactFlowEdges(crdtEdges)) setEdges(toReactFlowEdges(crdtEdges))
isRemoteUpdateRef.current = false
}, },
onPersist: async (persistNodes: FlowchartNode[], persistEdges: FlowchartEdge[]) => { onPersist: async (persistNodes: FlowchartNode[], persistEdges: FlowchartEdge[]) => {
try { try {
@ -571,8 +572,8 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
flowchart_data: { flowchart_data: {
nodes: persistNodes, nodes: persistNodes,
edges: persistEdges, edges: persistEdges,
characters, characters: charactersRef.current,
variables, variables: variablesRef.current,
}, },
}) })
.eq('id', projectId) .eq('id', projectId)
@ -633,6 +634,9 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
return next return next
}) })
}, },
onCRDTUpdate: (update: number[]) => {
crdtManager.applyRemoteUpdate(update)
},
onChannelSubscribed: (channel) => { onChannelSubscribed: (channel) => {
crdtManager.connectChannel(channel) crdtManager.connectChannel(channel)
}, },
@ -678,7 +682,6 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
}, [edges]) }, [edges])
useEffect(() => { useEffect(() => {
if (isRemoteUpdateRef.current) return
crdtRef.current?.updateNodes(nodesForCRDT) crdtRef.current?.updateNodes(nodesForCRDT)
if (!isRevertingRef.current) { if (!isRevertingRef.current) {
auditRef.current?.recordNodeChanges(nodesForCRDT) auditRef.current?.recordNodeChanges(nodesForCRDT)
@ -686,7 +689,6 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
}, [nodesForCRDT]) }, [nodesForCRDT])
useEffect(() => { useEffect(() => {
if (isRemoteUpdateRef.current) return
crdtRef.current?.updateEdges(edgesForCRDT) crdtRef.current?.updateEdges(edgesForCRDT)
if (!isRevertingRef.current) { if (!isRevertingRef.current) {
auditRef.current?.recordEdgeChanges(edgesForCRDT) auditRef.current?.recordEdgeChanges(edgesForCRDT)

View File

@ -57,25 +57,23 @@ export class CRDTManager {
}, 'init') }, 'init')
} }
/** Connect to a Supabase Realtime channel for syncing updates */ /** Connect to a Supabase Realtime channel for outbound broadcasts */
connectChannel(channel: RealtimeChannel): void { connectChannel(channel: RealtimeChannel): void {
this.channel = channel this.channel = channel
}
// Listen for broadcast updates from other clients /** Apply a remote CRDT update received via broadcast */
channel.on('broadcast', { event: BROADCAST_EVENT }, (payload) => { applyRemoteUpdate(updateData: number[]): void {
if (this.isDestroyed) return if (this.isDestroyed) return
const data = payload.payload as { update?: number[] } | undefined const update = new Uint8Array(updateData)
if (data?.update) { this.isApplyingRemote = true
const update = new Uint8Array(data.update) Y.applyUpdate(this.doc, update, 'remote')
this.isApplyingRemote = true this.isApplyingRemote = false
Y.applyUpdate(this.doc, update, 'remote') // Notify React state of remote changes
this.isApplyingRemote = false this.notifyNodesChange()
// Notify React state of remote changes this.notifyEdgesChange()
this.notifyNodesChange() // Note: we do NOT schedulePersist here. Only the originating client
this.notifyEdgesChange() // persists its own changes to avoid write races and stale data overwrites.
this.schedulePersist()
}
})
} }
/** Apply local node changes to the Yjs document */ /** Apply local node changes to the Yjs document */

View File

@ -35,6 +35,7 @@ type RealtimeCallbacks = {
onChannelSubscribed?: (channel: RealtimeChannel) => void onChannelSubscribed?: (channel: RealtimeChannel) => void
onCursorUpdate?: (cursor: RemoteCursor) => void onCursorUpdate?: (cursor: RemoteCursor) => void
onNodeLockUpdate?: (lock: NodeLock | null, userId: string) => void onNodeLockUpdate?: (lock: NodeLock | null, userId: string) => void
onCRDTUpdate?: (update: number[]) => void
} }
const HEARTBEAT_INTERVAL_MS = 30_000 const HEARTBEAT_INTERVAL_MS = 30_000
@ -131,6 +132,11 @@ export class RealtimeConnection {
this.callbacks.onNodeLockUpdate?.(null, payload.userId) this.callbacks.onNodeLockUpdate?.(null, payload.userId)
} }
}) })
.on('broadcast', { event: 'yjs-update' }, ({ payload }) => {
if (payload?.update) {
this.callbacks.onCRDTUpdate?.(payload.update)
}
})
.subscribe(async (status) => { .subscribe(async (status) => {
if (this.isDestroyed) return if (this.isDestroyed) return