From 5a9e02e6db3a19f7b44105ddfbbd7fc431c2f818 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Wed, 25 Mar 2026 09:37:26 +0700 Subject: [PATCH] Do not display collaboration errors in case of reconnects (#10678) Signed-off-by: Artem Savchenko --- plugins/text-editor-resources/package.json | 4 +- .../src/provider/utils.test.ts | 155 ++++++++++++++++++ .../src/provider/utils.ts | 30 +++- 3 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 plugins/text-editor-resources/src/provider/utils.test.ts diff --git a/plugins/text-editor-resources/package.json b/plugins/text-editor-resources/package.json index 94dfc604c0..3d10ccdc64 100644 --- a/plugins/text-editor-resources/package.json +++ b/plugins/text-editor-resources/package.json @@ -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", diff --git a/plugins/text-editor-resources/src/provider/utils.test.ts b/plugins/text-editor-resources/src/provider/utils.test.ts new file mode 100644 index 0000000000..ce5bd8670b --- /dev/null +++ b/plugins/text-editor-resources/src/provider/utils.test.ts @@ -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() + }) +}) diff --git a/plugins/text-editor-resources/src/provider/utils.ts b/plugins/text-editor-resources/src/provider/utils.ts index 373f5de3d9..84f2771fbb 100644 --- a/plugins/text-editor-resources/src/provider/utils.ts +++ b/plugins/text-editor-resources/src/provider/utils.ts @@ -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 | 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 }