Repository: nextcloud/text
Pull request #7093: Fix: writing during short connection failures
Author: login: max-nextcloud
Labels: name: bug
description: Something isn't working
Created: 2025-04-10T07:57:50Z | Merged: 2025-04-11T05:31:37Z

Fixes: #6877 

## enh(sync): add useDelayedFlag

Allow changing a flag with different delays:
* Switch it on slow.
* Switch it off fast.

Useful for errors - so they only show after a while but are resolved quickly.

Example:

const input = ref(false)
const { delayed } = useDelayedFlag(input)
...
input.value = true
// 5 seconds later delayed.value will be true
...
input.value = false
// 200 ms later delayed.value will be true

## fix(sync): allow writing during short connection failures

Delay the error message and allow editing for 5 seconds.
Also speed up the recovery by triggering push after a successful sync.



Diff:
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 @@
 		<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"
@@ -28,7 +28,7 @@
 								:dirty="dirty"
 								:sessions="filteredSessions"
 								:sync-error="syncError"
-								:has-connection-issue="hasConnectionIssue" />
+								:has-connection-issue="requireReconnect" />
 						</ReadonlyBar>
 					</slot>
 				</div>
@@ -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>
@@ -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" />
@@ -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<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
+		timeout = setTimeout(() => {
+			delayed.value = val
+		}, delay)
+
+	})
+
+	return { delayed }
+}


Reviews:
user: login: juliusknorr
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T10:56:11Z
user: login: juliusknorr
body: Small question inline, otherwise very nice
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T10:56:23Z
user: login: max-nextcloud
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T15:35:12Z
user: login: juliusknorr
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T05:31:14Z
user: login: juliusknorr
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-11T05:31:20Z

Review comments:
diff_hunk: @@ -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
path: src/components/Editor/useDelayedFlag.ts
user: login: juliusknorr
body: Why would we still wait 200ms when switching back? Can't we do it instantly?
created_at: 2025-04-10T10:56:11Z
author_association: MEMBER
diff_hunk: @@ -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
path: src/components/Editor/useDelayedFlag.ts
user: login: max-nextcloud
body: 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.
created_at: 2025-04-10T15:35:12Z
author_association: COLLABORATOR
diff_hunk: @@ -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
path: src/components/Editor/useDelayedFlag.ts
user: login: juliusknorr
body: All right, that sounds good the still to keep.
created_at: 2025-04-11T05:31:13Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-10T08:00:56Z
body: ## [Codecov](https://app.codecov.io/gh/nextcloud/text/pull/7093?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nextcloud) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 51.86%. Comparing base [(`1b2e929`)](https://app.codecov.io/gh/nextcloud/text/commit/1b2e929a8f434045752d7b3cd29ca49fd6c5bf1b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nextcloud) to head [(`1676004`)](https://app.codecov.io/gh/nextcloud/text/commit/1676004f3da6fe28e80111c0c3e50e79ad01c9b3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nextcloud).
> Report is 6 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #7093      +/-   ##
==========================================
+ Coverage   51.83%   51.86%   +0.02%     
==========================================
  Files         477      478       +1     
  Lines       40681    40705      +24     
  Branches      988      993       +5     
==========================================
+ Hits        21089    21112      +23     
- Misses      19487    19488       +1     
  Partials      105      105              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/nextcloud/text/pull/7093?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nextcloud).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nextcloud).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: juliusknorr
created_at: 2025-04-11T05:31:43Z
body: /backport to stable31
author_association: MEMBER
user: login: juliusknorr
created_at: 2025-04-11T05:31:45Z
body: /backport to stable30
author_association: MEMBER