Skip to content

Commit ef7f251

Browse files
authored
Merge pull request #7093 from nextcloud/fix/reload-message
Fix: writing during short connection failures
2 parents 5533e70 + 1676004 commit ef7f251

4 files changed

Lines changed: 123 additions & 20 deletions

File tree

cypress/e2e/sync.spec.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,23 @@ describe('Sync', () => {
6969
.should('include', 'after the lost connection')
7070
})
7171

72+
it('handles brief network outages', () => {
73+
cy.intercept('**/apps/text/session/*/*', req => req.destroy()).as('dead')
74+
cy.wait('@dead', { timeout: 30000 })
75+
// bring back the network connection
76+
cy.intercept('**/apps/text/session/*/*', req => { req.continue() }).as('alive')
77+
cy.wait('@alive', { timeout: 30000 })
78+
cy.getContent().type('staying alive')
79+
cy.getContent().should('contain', 'staying alive')
80+
})
81+
7282
it('reconnects via button after a short lost connection', () => {
7383
cy.intercept('**/apps/text/session/*/*', req => req.destroy()).as('dead')
7484
cy.wait('@dead', { timeout: 30000 })
7585
cy.get('#editor-container .document-status', { timeout: 30000 })
7686
.should('contain', 'The document could not be loaded.')
7787
cy.get('#editor-container .document-status')
7888
.find('.button.primary').click()
79-
cy.get('.toastify').should('contain', 'Connection failed.')
80-
cy.get('.toastify', { timeout: 30000 }).should('not.exist')
8189
cy.get('#editor-container .document-status', { timeout: 30000 })
8290
.should('contain', 'The document could not be loaded.')
8391
// bring back the network connection

src/components/Editor.vue

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
<CollisionResolveDialog v-if="isResolvingConflict" :sync-error="syncError" />
1515
<Wrapper v-if="displayed"
1616
:is-resolving-conflict="isResolvingConflict"
17-
:has-connection-issue="hasConnectionIssue"
17+
:has-connection-issue="requireReconnect"
1818
:content-loaded="contentLoaded"
1919
:show-outline-outside="showOutlineOutside"
2020
@read-only-toggled="readOnlyToggled"
@@ -28,7 +28,7 @@
2828
:dirty="dirty"
2929
:sessions="filteredSessions"
3030
:sync-error="syncError"
31-
:has-connection-issue="hasConnectionIssue" />
31+
:has-connection-issue="requireReconnect" />
3232
</ReadonlyBar>
3333
</slot>
3434
</div>
@@ -43,7 +43,7 @@
4343
:dirty="dirty"
4444
:sessions="filteredSessions"
4545
:sync-error="syncError"
46-
:has-connection-issue="hasConnectionIssue"
46+
:has-connection-issue="requireReconnect"
4747
@editor-width-change="handleEditorWidthChange" />
4848
<slot name="header" />
4949
</MenuBar>
@@ -59,7 +59,7 @@
5959
<DocumentStatus :idle="idle"
6060
:lock="lock"
6161
:sync-error="syncError"
62-
:has-connection-issue="hasConnectionIssue"
62+
:has-connection-issue="requireReconnect"
6363
@reconnect="reconnect" />
6464
</Wrapper>
6565
<Assistant v-if="hasEditor" />
@@ -127,6 +127,7 @@ import CollisionResolveDialog from './CollisionResolveDialog.vue'
127127
import { generateRemoteUrl } from '@nextcloud/router'
128128
import { fetchNode } from '../services/WebdavClient.ts'
129129
import SuggestionsBar from './SuggestionsBar.vue'
130+
import { useDelayedFlag } from './Editor/useDelayedFlag.ts'
130131
131132
export default {
132133
name: 'Editor',
@@ -244,7 +245,9 @@ export default {
244245
const maxWidth = Math.floor(value) - 36
245246
el.value.style.setProperty('--widget-full-width', `${maxWidth}px`)
246247
})
247-
return { el, width }
248+
const hasConnectionIssue = ref(false)
249+
const { delayed: requireReconnect } = useDelayedFlag(hasConnectionIssue)
250+
return { el, width, hasConnectionIssue, requireReconnect }
248251
},
249252
250253
data() {
@@ -262,7 +265,6 @@ export default {
262265
dirty: false,
263266
contentLoaded: false,
264267
syncError: null,
265-
hasConnectionIssue: false,
266268
hasEditor: false,
267269
readOnly: true,
268270
openReadOnlyEnabled: OCA.Text.OpenReadOnlyEnabled,
@@ -353,6 +355,14 @@ export default {
353355
window.removeEventListener('beforeunload', this.saveBeforeUnload)
354356
}
355357
},
358+
requireReconnect(val) {
359+
if (val) {
360+
this.emit('sync-service:error')
361+
}
362+
if (this.$editor?.isEditable === val) {
363+
this.$editor.setEditable(!val)
364+
}
365+
},
356366
},
357367
mounted() {
358368
if (this.active && (this.hasDocumentParameters)) {
@@ -595,7 +605,7 @@ export default {
595605
this.document = document
596606
597607
this.syncError = null
598-
const editable = this.editMode && !this.hasConnectionIssue
608+
const editable = this.editMode && !this.requireReconnect
599609
if (this.$editor.isEditable !== editable) {
600610
this.$editor.setEditable(editable)
601611
}
@@ -618,7 +628,13 @@ export default {
618628
},
619629
620630
onSync({ steps, document }) {
621-
this.hasConnectionIssue = this.$syncService.backend.fetcher === 0 || !this.$providers[0].wsconnected || this.$syncService.pushError > 0
631+
this.hasConnectionIssue = this.$syncService.backend.fetcher === 0
632+
|| !this.$providers[0].wsconnected
633+
|| this.$syncService.pushError > 0
634+
if (this.$syncService.pushError > 0) {
635+
// successfully received steps - so let's try and also push
636+
this.$syncService.sendStepsNow()
637+
}
622638
this.$nextTick(() => {
623639
this.emit('sync-service:sync')
624640
})
@@ -628,11 +644,6 @@ export default {
628644
},
629645
630646
onError({ type, data }) {
631-
this.$nextTick(() => {
632-
this.$editor?.setEditable(false)
633-
this.emit('sync-service:error')
634-
})
635-
636647
if (type === ERROR_TYPE.LOAD_ERROR) {
637648
this.syncError = {
638649
type,
@@ -647,11 +658,8 @@ export default {
647658
data,
648659
}
649660
}
650-
if (type === ERROR_TYPE.CONNECTION_FAILED && !this.hasConnectionIssue) {
651-
this.hasConnectionIssue = true
652-
OC.Notification.showTemporary(t('text', 'Connection failed.'))
653-
}
654-
if (type === ERROR_TYPE.SOURCE_NOT_FOUND) {
661+
if (type === ERROR_TYPE.CONNECTION_FAILED
662+
|| type === ERROR_TYPE.SOURCE_NOT_FOUND) {
655663
this.hasConnectionIssue = true
656664
}
657665
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { afterEach, expect, test, vi } from 'vitest'
7+
import { useDelayedFlag } from './useDelayedFlag'
8+
import { nextTick, ref, watch } from 'vue'
9+
10+
afterEach(() => {
11+
vi.useRealTimers()
12+
})
13+
14+
test('useDelayedFlag defaults to provided ref value', () => {
15+
[true, false].forEach(val => {
16+
const { delayed } = useDelayedFlag(ref(val))
17+
expect(delayed.value).toBe(val)
18+
})
19+
})
20+
21+
test('switches slowly to true', async () => {
22+
vi.useFakeTimers()
23+
const input = ref(false)
24+
const { delayed } = useDelayedFlag(input)
25+
input.value = true
26+
await nextTick()
27+
vi.advanceTimersByTime(3000)
28+
expect(delayed.value).toBe(false)
29+
vi.advanceTimersByTime(5000)
30+
expect(delayed.value).toBe(true)
31+
})
32+
33+
test('switches fast to false', async () => {
34+
vi.useFakeTimers()
35+
const input = ref(true)
36+
const { delayed } = useDelayedFlag(input)
37+
input.value = false
38+
await nextTick()
39+
expect(delayed.value).toBe(true)
40+
vi.advanceTimersByTime(300)
41+
expect(delayed.value).toBe(false)
42+
})
43+
44+
test('does not flip flop', async () => {
45+
vi.useFakeTimers()
46+
const input = ref(false)
47+
const { delayed } = useDelayedFlag(input)
48+
const probe = vi.fn()
49+
watch(delayed, probe)
50+
input.value = true
51+
await nextTick()
52+
vi.advanceTimersByTime(1000)
53+
input.value = false
54+
await nextTick()
55+
vi.advanceTimersByTime(5000)
56+
expect(delayed.value).toBe(false)
57+
expect(probe).not.toBeCalled()
58+
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { ref, watch, type Ref } from 'vue'
7+
8+
/**
9+
* Delay the changing of the boolean
10+
* @param input - ref to react to
11+
*/
12+
export function useDelayedFlag(input: Ref<boolean>): { delayed: Ref<boolean> } {
13+
14+
let timeout: ReturnType<typeof setTimeout> | undefined
15+
const delayed = ref(input.value)
16+
17+
watch(input, (val) => {
18+
if (timeout) {
19+
clearTimeout(timeout)
20+
}
21+
const delay = val ? 5000 : 200
22+
timeout = setTimeout(() => {
23+
delayed.value = val
24+
}, delay)
25+
26+
})
27+
28+
return { delayed }
29+
}

0 commit comments

Comments
 (0)