Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions cypress/e2e/sync.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,23 @@ 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 })
cy.get('#editor-container .document-status', { timeout: 30000 })
.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
Expand Down
44 changes: 26 additions & 18 deletions src/components/Editor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<CollisionResolveDialog v-if="isResolvingConflict" :sync-error="syncError" />
<Wrapper v-if="displayed"
:is-resolving-conflict="isResolvingConflict"
:has-connection-issue="hasConnectionIssue"
:has-connection-issue="requireReconnect"
:content-loaded="contentLoaded"
:show-outline-outside="showOutlineOutside"
@read-only-toggled="readOnlyToggled"
Expand All @@ -28,7 +28,7 @@
:dirty="dirty"
:sessions="filteredSessions"
:sync-error="syncError"
:has-connection-issue="hasConnectionIssue" />
:has-connection-issue="requireReconnect" />
</ReadonlyBar>
</slot>
</div>
Expand All @@ -43,7 +43,7 @@
:dirty="dirty"
:sessions="filteredSessions"
:sync-error="syncError"
:has-connection-issue="hasConnectionIssue"
:has-connection-issue="requireReconnect"
@editor-width-change="handleEditorWidthChange" />
<slot name="header" />
</MenuBar>
Expand All @@ -59,7 +59,7 @@
<DocumentStatus :idle="idle"
:lock="lock"
:sync-error="syncError"
:has-connection-issue="hasConnectionIssue"
:has-connection-issue="requireReconnect"
@reconnect="reconnect" />
</Wrapper>
<Assistant v-if="hasEditor" />
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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() {
Expand All @@ -262,7 +265,6 @@ export default {
dirty: false,
contentLoaded: false,
syncError: null,
hasConnectionIssue: false,
hasEditor: false,
readOnly: true,
openReadOnlyEnabled: OCA.Text.OpenReadOnlyEnabled,
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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')
})
Expand All @@ -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,
Expand All @@ -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
}

Expand Down
58 changes: 58 additions & 0 deletions src/components/Editor/useDelayedFlag.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
29 changes: 29 additions & 0 deletions src/components/Editor/useDelayedFlag.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>): { delayed: Ref<boolean> } {

let timeout: ReturnType<typeof setTimeout> | undefined
const delayed = ref(input.value)

watch(input, (val) => {
if (timeout) {
clearTimeout(timeout)
}
const delay = val ? 5000 : 200
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we still wait 200ms when switching back? Can't we do it instantly?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My idea was to reduce the risk of a flickering state. Basically if the error goes away for a brief moment and comes back right after.

But thinking about it again I cannot see how that would happen within 200 ms. The error will only go away after the push request - which may be triggered by a successful sync. So then the next sync will be longer than 200 ms away. So I guess we can save the 200 ms and just remove the flag immediately.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All right, that sounds good the still to keep.

timeout = setTimeout(() => {
delayed.value = val
}, delay)

})

return { delayed }
}
Loading