diff --git a/cypress/e2e/sync.spec.js b/cypress/e2e/sync.spec.js index c4a6a6138ff..fce1e3edbb2 100644 --- a/cypress/e2e/sync.spec.js +++ b/cypress/e2e/sync.spec.js @@ -69,6 +69,16 @@ describe('Sync', () => { .should('include', 'after the lost connection') }) + it('handles brief network outages', () => { + cy.intercept('**/apps/text/session/*/*', req => req.destroy()).as('dead') + cy.wait('@dead', { timeout: 30000 }) + // bring back the network connection + cy.intercept('**/apps/text/session/*/*', req => { req.continue() }).as('alive') + cy.wait('@alive', { timeout: 30000 }) + cy.getContent().type('staying alive') + cy.getContent().should('contain', 'staying alive') + }) + it('reconnects via button after a short lost connection', () => { cy.intercept('**/apps/text/session/*/*', req => req.destroy()).as('dead') cy.wait('@dead', { timeout: 30000 }) @@ -76,8 +86,6 @@ describe('Sync', () => { .should('contain', 'The document could not be loaded.') cy.get('#editor-container .document-status') .find('.button.primary').click() - cy.get('.toastify').should('contain', 'Connection failed.') - cy.get('.toastify', { timeout: 30000 }).should('not.exist') cy.get('#editor-container .document-status', { timeout: 30000 }) .should('contain', 'The document could not be loaded.') // bring back the network connection diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 216d2c1f6a8..5967323b091 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -14,7 +14,7 @@ + :has-connection-issue="requireReconnect" /> @@ -43,7 +43,7 @@ :dirty="dirty" :sessions="filteredSessions" :sync-error="syncError" - :has-connection-issue="hasConnectionIssue" + :has-connection-issue="requireReconnect" @editor-width-change="handleEditorWidthChange" /> @@ -59,7 +59,7 @@ @@ -127,6 +127,7 @@ import CollisionResolveDialog from './CollisionResolveDialog.vue' import { generateRemoteUrl } from '@nextcloud/router' import { fetchNode } from '../services/WebdavClient.ts' import SuggestionsBar from './SuggestionsBar.vue' +import { useDelayedFlag } from './Editor/useDelayedFlag.ts' export default { name: 'Editor', @@ -244,7 +245,9 @@ export default { const maxWidth = Math.floor(value) - 36 el.value.style.setProperty('--widget-full-width', `${maxWidth}px`) }) - return { el, width } + const hasConnectionIssue = ref(false) + const { delayed: requireReconnect } = useDelayedFlag(hasConnectionIssue) + return { el, width, hasConnectionIssue, requireReconnect } }, data() { @@ -262,7 +265,6 @@ export default { dirty: false, contentLoaded: false, syncError: null, - hasConnectionIssue: false, hasEditor: false, readOnly: true, openReadOnlyEnabled: OCA.Text.OpenReadOnlyEnabled, @@ -353,6 +355,14 @@ export default { window.removeEventListener('beforeunload', this.saveBeforeUnload) } }, + requireReconnect(val) { + if (val) { + this.emit('sync-service:error') + } + if (this.$editor?.isEditable === val) { + this.$editor.setEditable(!val) + } + }, }, mounted() { if (this.active && (this.hasDocumentParameters)) { @@ -595,7 +605,7 @@ export default { this.document = document this.syncError = null - const editable = this.editMode && !this.hasConnectionIssue + const editable = this.editMode && !this.requireReconnect if (this.$editor.isEditable !== editable) { this.$editor.setEditable(editable) } @@ -618,7 +628,13 @@ export default { }, onSync({ steps, document }) { - this.hasConnectionIssue = this.$syncService.backend.fetcher === 0 || !this.$providers[0].wsconnected || this.$syncService.pushError > 0 + this.hasConnectionIssue = this.$syncService.backend.fetcher === 0 + || !this.$providers[0].wsconnected + || this.$syncService.pushError > 0 + if (this.$syncService.pushError > 0) { + // successfully received steps - so let's try and also push + this.$syncService.sendStepsNow() + } this.$nextTick(() => { this.emit('sync-service:sync') }) @@ -628,11 +644,6 @@ export default { }, onError({ type, data }) { - this.$nextTick(() => { - this.$editor?.setEditable(false) - this.emit('sync-service:error') - }) - if (type === ERROR_TYPE.LOAD_ERROR) { this.syncError = { type, @@ -647,11 +658,8 @@ export default { data, } } - if (type === ERROR_TYPE.CONNECTION_FAILED && !this.hasConnectionIssue) { - this.hasConnectionIssue = true - OC.Notification.showTemporary(t('text', 'Connection failed.')) - } - if (type === ERROR_TYPE.SOURCE_NOT_FOUND) { + if (type === ERROR_TYPE.CONNECTION_FAILED + || type === ERROR_TYPE.SOURCE_NOT_FOUND) { this.hasConnectionIssue = true } diff --git a/src/components/Editor/useDelayedFlag.spec.ts b/src/components/Editor/useDelayedFlag.spec.ts new file mode 100644 index 00000000000..e76a2194717 --- /dev/null +++ b/src/components/Editor/useDelayedFlag.spec.ts @@ -0,0 +1,58 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { afterEach, expect, test, vi } from 'vitest' +import { useDelayedFlag } from './useDelayedFlag' +import { nextTick, ref, watch } from 'vue' + +afterEach(() => { + vi.useRealTimers() +}) + +test('useDelayedFlag defaults to provided ref value', () => { + [true, false].forEach(val => { + const { delayed } = useDelayedFlag(ref(val)) + expect(delayed.value).toBe(val) + }) +}) + +test('switches slowly to true', async () => { + vi.useFakeTimers() + const input = ref(false) + const { delayed } = useDelayedFlag(input) + input.value = true + await nextTick() + vi.advanceTimersByTime(3000) + expect(delayed.value).toBe(false) + vi.advanceTimersByTime(5000) + expect(delayed.value).toBe(true) +}) + +test('switches fast to false', async () => { + vi.useFakeTimers() + const input = ref(true) + const { delayed } = useDelayedFlag(input) + input.value = false + await nextTick() + expect(delayed.value).toBe(true) + vi.advanceTimersByTime(300) + expect(delayed.value).toBe(false) +}) + +test('does not flip flop', async () => { + vi.useFakeTimers() + const input = ref(false) + const { delayed } = useDelayedFlag(input) + const probe = vi.fn() + watch(delayed, probe) + input.value = true + await nextTick() + vi.advanceTimersByTime(1000) + input.value = false + await nextTick() + vi.advanceTimersByTime(5000) + expect(delayed.value).toBe(false) + expect(probe).not.toBeCalled() +}) diff --git a/src/components/Editor/useDelayedFlag.ts b/src/components/Editor/useDelayedFlag.ts new file mode 100644 index 00000000000..eec96ccd1f2 --- /dev/null +++ b/src/components/Editor/useDelayedFlag.ts @@ -0,0 +1,29 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { ref, watch, type Ref } from 'vue' + +/** + * Delay the changing of the boolean + * @param input - ref to react to + */ +export function useDelayedFlag(input: Ref): { delayed: Ref } { + + let timeout: ReturnType | undefined + const delayed = ref(input.value) + + watch(input, (val) => { + if (timeout) { + clearTimeout(timeout) + } + const delay = val ? 5000 : 200 + timeout = setTimeout(() => { + delayed.value = val + }, delay) + + }) + + return { delayed } +}