fix ghost node panel presence cleanup

This commit is contained in:
hjlarry 2026-04-13 10:23:21 +08:00
parent d3e9a34917
commit e3b72df552
2 changed files with 63 additions and 6 deletions

View File

@ -72,7 +72,7 @@ type CollaborationManagerInternals = {
emitGraphResyncRequest: () => void
broadcastCurrentGraph: () => void
requestInitialSyncIfNeeded: () => void
cleanupNodePanelPresence: (activeClientIds: Set<string>, activeUserIds: Set<string>) => void
cleanupNodePanelPresence: (activeClientIds: Set<string>) => void
recordGraphSyncDiagnostic: (
stage: 'nodes_subscribe' | 'edges_subscribe' | 'nodes_import_apply' | 'edges_import_apply' | 'schedule_graph_import_emit' | 'graph_import_emit' | 'start_import_log' | 'finalize_import_log',
status: 'triggered' | 'skipped' | 'applied' | 'queued' | 'emitted' | 'snapshot',
@ -426,6 +426,65 @@ describe('CollaborationManager socket and subscription behavior', () => {
expect(errorSpy).toHaveBeenCalled()
})
it('removes stale node panel viewers by inactive client even when the same user is still online in another tab', () => {
const { manager, internals } = setupManagerWithDoc()
const socket = createMockSocket('socket-tab-b')
internals.nodePanelPresence = {
'n-1': {
'socket-tab-a': {
userId: 'u-1',
username: 'Alice',
clientId: 'socket-tab-a',
timestamp: 1,
},
'socket-tab-b': {
userId: 'u-1',
username: 'Alice',
clientId: 'socket-tab-b',
timestamp: 2,
},
},
}
const presenceUpdates: NodePanelPresenceMap[] = []
manager.onNodePanelPresenceUpdate((presence) => {
presenceUpdates.push(presence)
})
internals.setupSocketEventListeners(socket as unknown as Socket)
socket.trigger('online_users', {
users: [{
user_id: 'u-1',
username: 'Alice',
avatar: '',
sid: 'socket-tab-b',
}],
})
expect(internals.nodePanelPresence).toEqual({
'n-1': {
'socket-tab-b': {
userId: 'u-1',
username: 'Alice',
clientId: 'socket-tab-b',
timestamp: 2,
},
},
})
expect(presenceUpdates.at(-1)).toEqual({
'n-1': {
'socket-tab-b': {
userId: 'u-1',
username: 'Alice',
clientId: 'socket-tab-b',
timestamp: 2,
},
},
})
})
it('setupSubscriptions applies import updates and emits merged graph payload', () => {
const { manager, internals } = setupManagerWithDoc()
const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {

View File

@ -416,16 +416,14 @@ export class CollaborationManager {
this.eventEmitter.emit('nodePanelPresence', this.getNodePanelPresenceSnapshot())
}
private cleanupNodePanelPresence(activeClientIds: Set<string>, activeUserIds: Set<string>): void {
private cleanupNodePanelPresence(activeClientIds: Set<string>): void {
let hasChanges = false
Object.entries(this.nodePanelPresence).forEach(([nodeId, viewers]) => {
Object.keys(viewers).forEach((clientId) => {
const viewer = viewers[clientId]
const clientActive = activeClientIds.has(clientId)
const userActive = viewer?.userId ? activeUserIds.has(viewer.userId) : false
if (!clientActive && !userActive) {
if (!clientActive) {
delete viewers[clientId]
hasChanges = true
}
@ -1513,7 +1511,7 @@ export class CollaborationManager {
delete this.cursors[userId]
})
this.cleanupNodePanelPresence(onlineClientIds, onlineUserIds)
this.cleanupNodePanelPresence(onlineClientIds)
// Update leader information
if (data.leader && typeof data.leader === 'string')