Do not display collaboration errors in case of reconnects (#10678)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-03-25 09:37:26 +07:00
committed by GitHub
parent 801c60b8ce
commit 5a9e02e6db
3 changed files with 185 additions and 4 deletions
+3 -1
View File
@@ -13,7 +13,9 @@
"build:watch": "compile ui",
"_phase:build": "compile ui",
"_phase:format": "format src",
"_phase:validate": "compile validate"
"_phase:validate": "compile validate",
"test": "jest --passWithNoTests --silent",
"_phase:test": "jest --passWithNoTests --silent"
},
"devDependencies": {
"@hcengineering/platform-rig": "workspace:^0.7.19",
@@ -0,0 +1,155 @@
import { createRemoteProvider } from './utils'
const baseDestroy = jest.fn()
let lastConfig: any
jest.mock('@hcengineering/core', () => ({
generateId: () => 'guid-1'
}))
jest.mock('@hcengineering/presentation', () => ({
__esModule: true,
default: {
metadata: {
Token: 'token',
CollaboratorUrl: 'collaboratorUrl',
WorkspaceUuid: 'workspaceUuid'
}
}
}))
jest.mock('@hcengineering/platform', () => {
const getMetadata = jest.fn()
const setPlatformStatus = jest.fn()
class Status {
severity: any
code: any
params: any
constructor (severity: any, code: any, params: any) {
this.severity = severity
this.code = code
this.params = params
}
}
return {
OK: { code: 'OK' },
Severity: { ERROR: 'ERROR' },
Status,
getMetadata,
setPlatformStatus
}
})
jest.mock('@hcengineering/collaborator-client', () => {
const encodeDocumentId = jest.fn()
return { encodeDocumentId }
})
jest.mock('../plugin', () => ({
__esModule: true,
default: {
string: {
CannotConnectToCollaborationService: 'CannotConnectToCollaborationService'
}
}
}))
jest.mock('./hocuspocus', () => ({
HocuspocusCollabProvider: class {
destroy: () => void
constructor (config: any) {
lastConfig = config
this.destroy = baseDestroy
}
}
}))
describe('createRemoteProvider reconnect grace behavior', () => {
const RECONNECT_GRACE_MS = 5000
const platformMock = jest.requireMock('@hcengineering/platform')
const collaboratorClientMock = jest.requireMock('@hcengineering/collaborator-client')
beforeEach(() => {
jest.useFakeTimers()
jest.clearAllMocks()
baseDestroy.mockReset()
lastConfig = undefined
platformMock.getMetadata.mockImplementation((key: string) => {
if (key === 'token') return 'token-1'
if (key === 'collaboratorUrl') return 'wss://collab.example/ws'
if (key === 'workspaceUuid') return 'ws-1'
return undefined
})
collaboratorClientMock.encodeDocumentId.mockReturnValue('encoded-doc-id')
})
afterEach(() => {
jest.useRealTimers()
})
it('does not report error before grace timeout', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
createRemoteProvider({} as any, 'doc-1' as any, null)
lastConfig.onClose({ event: { code: 1006 } })
jest.advanceTimersByTime(RECONNECT_GRACE_MS - 1)
expect(errorSpy).not.toHaveBeenCalled()
expect(platformMock.setPlatformStatus).not.toHaveBeenCalled()
errorSpy.mockRestore()
})
it('starts grace timer once and does not postpone on repeated 1006 closes', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
createRemoteProvider({} as any, 'doc-1' as any, null)
lastConfig.onClose({ event: { code: 1006 } })
jest.advanceTimersByTime(2000)
lastConfig.onClose({ event: { code: 1006 } })
jest.advanceTimersByTime(2000)
lastConfig.onClose({ event: { code: 1006 } })
jest.advanceTimersByTime(1000)
expect(errorSpy).toHaveBeenCalledTimes(1)
expect(platformMock.setPlatformStatus).toHaveBeenCalledTimes(1)
errorSpy.mockRestore()
})
it('clears pending error when reconnect succeeds in grace window', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
createRemoteProvider({} as any, 'doc-1' as any, null)
lastConfig.onClose({ event: { code: 1006 } })
jest.advanceTimersByTime(RECONNECT_GRACE_MS - 1000)
lastConfig.onConnect()
jest.advanceTimersByTime(RECONNECT_GRACE_MS + 1000)
expect(errorSpy).not.toHaveBeenCalled()
expect(platformMock.setPlatformStatus).toHaveBeenCalledTimes(1)
expect(platformMock.setPlatformStatus).toHaveBeenCalledWith({ code: 'OK' })
errorSpy.mockRestore()
})
it('clears pending error timer on provider destroy', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
const provider = createRemoteProvider({} as any, 'doc-1' as any, null)
lastConfig.onClose({ event: { code: 1006 } })
void provider.destroy()
jest.advanceTimersByTime(RECONNECT_GRACE_MS + 1000)
expect(baseDestroy).toHaveBeenCalledTimes(1)
expect(errorSpy).not.toHaveBeenCalled()
expect(platformMock.setPlatformStatus).not.toHaveBeenCalled()
errorSpy.mockRestore()
})
})
@@ -24,6 +24,9 @@ import plugin from '../plugin'
import { HocuspocusCollabProvider } from './hocuspocus'
import { type Provider } from './types'
/** After idle/tab sleep the WS often closes with 1006; Hocuspocus reconnects. Defer user-visible errors. */
const COLLABORATOR_RECONNECT_GRACE_MS = 5000
function getDocumentId (doc: CollaborativeDoc): string {
const workspace = getMetadata(presentation.metadata.WorkspaceUuid) ?? ''
return encodeDocumentId(workspace, doc)
@@ -35,6 +38,15 @@ export function createRemoteProvider (ydoc: Ydoc, doc: CollaborativeDoc, content
const documentId = getDocumentId(doc)
let reconnectGraceTimeout: ReturnType<typeof setTimeout> | undefined
const clearReconnectGrace = (): void => {
if (reconnectGraceTimeout !== undefined) {
clearTimeout(reconnectGraceTimeout)
reconnectGraceTimeout = undefined
}
}
const provider = new HocuspocusCollabProvider({
url: collaboratorUrl,
name: documentId,
@@ -42,17 +54,29 @@ export function createRemoteProvider (ydoc: Ydoc, doc: CollaborativeDoc, content
token,
parameters: { content },
onConnect: () => {
clearReconnectGrace()
void setPlatformStatus(OK)
},
onClose: (data) => {
if (data.event.code === 1006) {
console.error('Failed to connect to collaborator', data.event)
const status = new Status(Severity.ERROR, plugin.string.CannotConnectToCollaborationService, {})
void setPlatformStatus(status)
if (reconnectGraceTimeout === undefined) {
reconnectGraceTimeout = setTimeout(() => {
reconnectGraceTimeout = undefined
console.error('Failed to connect to collaborator', data.event)
const status = new Status(Severity.ERROR, plugin.string.CannotConnectToCollaborationService, {})
void setPlatformStatus(status)
}, COLLABORATOR_RECONNECT_GRACE_MS)
}
}
}
})
const baseDestroy = provider.destroy.bind(provider)
provider.destroy = (): void => {
clearReconnectGrace()
baseDestroy()
}
return provider
}