From cc0b1f8e41f5523630d42bcd654f2f5a4ad65465 Mon Sep 17 00:00:00 2001 From: arminfabritzek Date: Thu, 1 Oct 2026 13:47:59 +0200 Subject: [PATCH 1/6] fix: defer writable Text editor until entering edit mode Signed-off-by: arminfabritzek --- playwright/e2e/page-content.spec.ts | 20 +++++++ src/components/Page/TextEditor.vue | 1 + src/composables/useEditor.ts | 14 +++++ src/tests/useEditor.spec.ts | 92 +++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 src/tests/useEditor.spec.ts diff --git a/playwright/e2e/page-content.spec.ts b/playwright/e2e/page-content.spec.ts index 57d63e48ae..9f98ab26bd 100644 --- a/playwright/e2e/page-content.spec.ts +++ b/playwright/e2e/page-content.spec.ts @@ -11,6 +11,26 @@ import { test as editorTest } from '../support/fixtures/editor.ts' const test = mergeTests(createCollectiveTest, editorTest) test.describe('Page content', () => { + test('preview does not lock the page for WebDAV writers', async ({ user, page, collective }) => { + const collectivePage = await collective.createPage({ + title: 'Preview without a writable Text session', + content: 'Original content', + user, + page, + }) + await collectivePage.open(false) + await expect(collectivePage.getContent()).toContainText('Original content') + await expect(collectivePage.getModeButton(true)).toBeEnabled() + // A hidden writable editor would acquire a Text lock and reject this PUT. + await collectivePage.setContent({ content: 'Updated through WebDAV', user, page }) + await page.reload() + await collectivePage.waitForContent(false) + await expect(collectivePage.getContent()).toContainText('Updated through WebDAV') + // Lazy initialization must still allow switching from reading to editing. + await collectivePage.switchMode(true) + await expect(collectivePage.getContent(true)).toContainText('Updated through WebDAV') + }) + test('create whiteboard from attachments menu', async ({ user, page, collective, editor }) => { test.slow() await runOcc(['app:enable', '--force', 'whiteboard']) diff --git a/src/components/Page/TextEditor.vue b/src/components/Page/TextEditor.vue index a05e46dcdb..c65035abd8 100644 --- a/src/components/Page/TextEditor.vue +++ b/src/components/Page/TextEditor.vue @@ -128,6 +128,7 @@ export default { if (val === false) { this.stopEdit() } else if (val === true) { + await this.setupEditor() // Load full circle members for autocomplete when entering edit mode const circlesStore = useCirclesStore() if (!circlesStore.currentCircleMembersFullyLoaded && !this.isPublic) { diff --git a/src/composables/useEditor.ts b/src/composables/useEditor.ts index a55543adb3..49aa647873 100644 --- a/src/composables/useEditor.ts +++ b/src/composables/useEditor.ts @@ -81,6 +81,20 @@ export function useEditor(davContent: Ref) { return } + // Reading a page must not open a writable Text session or lock its file. + if (!pagesStore.isTextEdit) { + rootStore.done('editor') + return + } + + // Switching back from preview reuses the existing editor. + if (editorPromise) { + await editorPromise + return + } + + rootStore.load('editor') + // Define PageIcon as custom web component if (!window.customElements.get('page-icon')) { const PageIconCE = defineCustomElement({ diff --git a/src/tests/useEditor.spec.ts b/src/tests/useEditor.spec.ts new file mode 100644 index 0000000000..224ffb6501 --- /dev/null +++ b/src/tests/useEditor.spec.ts @@ -0,0 +1,92 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type * as Vue from 'vue' + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { useEditor } from '../composables/useEditor.ts' + +const mocks = vi.hoisted(() => ({ + pages: { currentPage: { id: 42 }, currentPageId: 42, isTextEdit: false, hasOutline: () => false, pageFilePath: () => 'Page.md' }, + collectives: { currentCollectiveCanEdit: true }, + root: { load: vi.fn(), done: vi.fn(), loading: () => false, shareTokenParam: null }, + beforeUnmount: [] as Array<() => void>, + createEditor: vi.fn(), +})) + +vi.mock('vue', async (importOriginal) => ({ + ...await importOriginal(), + onBeforeUnmount: (callback: () => void) => mocks.beforeUnmount.push(callback), +})) +vi.mock('@nextcloud/l10n', () => ({ t: (_app: string, message: string) => message })) +vi.mock('@nextcloud/vue/components/NcRichText', () => ({ getReferenceWithPicker: vi.fn() })) +vi.mock('../components/Icon/PageIcon.vue', () => ({ default: {} })) +vi.mock('../composables/useSearch.ts', () => ({ useSearch: vi.fn() })) +vi.mock('../stores/pages.js', () => ({ usePagesStore: () => mocks.pages })) +vi.mock('../stores/root.js', () => ({ useRootStore: () => mocks.root })) +vi.mock('../stores/collectives.js', () => ({ useCollectivesStore: () => mocks.collectives })) +vi.mock('../stores/circles.js', () => ({ useCirclesStore: () => ({}) })) +vi.mock('../stores/search.js', () => ({ useSearchStore: () => ({}) })) + +beforeEach(() => { + vi.clearAllMocks() + mocks.createEditor.mockResolvedValue({ destroy: vi.fn() }) + mocks.beforeUnmount.length = 0 + mocks.pages.isTextEdit = false + mocks.collectives.currentCollectiveCanEdit = true + vi.stubGlobal('window', { customElements: { get: () => true }, OCA: { Text: { createEditor: mocks.createEditor }, Collectives: { openLink: vi.fn() } } }) +}) + +describe('writable Text editor lifecycle', () => { + it('does not open a writable session when a page is only read', async () => { + const { setupEditor } = useEditor(ref('Existing page')) + await setupEditor() + expect(mocks.createEditor).not.toHaveBeenCalled() + expect(mocks.root.load).not.toHaveBeenCalled() + expect(mocks.root.done).toHaveBeenCalledWith('editor') + }) + + it('creates the editor on entering edit mode and reuses it on subsequent entries', async () => { + const instance = { destroy: vi.fn() } + mocks.createEditor.mockResolvedValue(instance) + const { setupEditor, editor } = useEditor(ref('Existing page')) + await setupEditor() + mocks.pages.isTextEdit = true + await Promise.all([setupEditor(), setupEditor()]) + expect(mocks.createEditor).toHaveBeenCalledTimes(1) + expect(mocks.createEditor).toHaveBeenCalledWith(expect.objectContaining({ fileId: 42, readOnly: false })) + expect(editor.value).toBe(instance) + mocks.pages.isTextEdit = false + await setupEditor() + mocks.pages.isTextEdit = true + await setupEditor() + expect(mocks.createEditor).toHaveBeenCalledTimes(1) + }) + + it('does not create an editor without collective edit permission', async () => { + mocks.collectives.currentCollectiveCanEdit = false + mocks.pages.isTextEdit = true + const { setupEditor, editor } = useEditor(ref('Existing page')) + await setupEditor() + expect(mocks.createEditor).not.toHaveBeenCalled() + expect(editor.value).toBeNull() + }) + + it('destroys an editor that finishes initialization after its page is unmounted', async () => { + let resolveEditor!: (value: { destroy: ReturnType }) => void + mocks.createEditor.mockReturnValue(new Promise((resolve) => { + resolveEditor = resolve + })) + mocks.pages.isTextEdit = true + const { setupEditor } = useEditor(ref('Existing page')) + const pending = setupEditor() + mocks.beforeUnmount.forEach((callback) => callback()) + const instance = { destroy: vi.fn() } + resolveEditor(instance) + await pending + expect(instance.destroy).toHaveBeenCalledOnce() + }) +}) From f53b98f89cb100e00f17b59f7a0a6f42e5d593c4 Mon Sep 17 00:00:00 2001 From: arminfabritzek Date: Thu, 1 Oct 2026 13:58:29 +0200 Subject: [PATCH 2/6] test: cover preview and repeated editing after lazy initialization Signed-off-by: arminfabritzek --- playwright/e2e/page-content.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/playwright/e2e/page-content.spec.ts b/playwright/e2e/page-content.spec.ts index 9f98ab26bd..10064b3a5f 100644 --- a/playwright/e2e/page-content.spec.ts +++ b/playwright/e2e/page-content.spec.ts @@ -29,6 +29,11 @@ test.describe('Page content', () => { // Lazy initialization must still allow switching from reading to editing. await collectivePage.switchMode(true) await expect(collectivePage.getContent(true)).toContainText('Updated through WebDAV') + await collectivePage.getContent(true).fill('Edited after entering edit mode') + await collectivePage.switchMode(false) + await expect(collectivePage.getContent()).toContainText('Edited after entering edit mode') + await collectivePage.switchMode(true) + await expect(collectivePage.getContent(true)).toContainText('Edited after entering edit mode') }) test('create whiteboard from attachments menu', async ({ user, page, collective, editor }) => { From a2bdaf5bee2569b0e4711b4eb9bcd376a42f2f41 Mon Sep 17 00:00:00 2001 From: arminfabritzek Date: Thu, 1 Oct 2026 14:40:49 +0200 Subject: [PATCH 3/6] test: cover lazy editor attachment regressions and failure paths Verify attachment files and persisted Markdown after reload. Wait for completed Text initialization before the WebDAV probe, and exercise initialization rejection, navigation and save failures. Signed-off-by: arminfabritzek --- playwright/e2e/editor-lifecycle.spec.ts | 66 +++++++++++ playwright/e2e/page-attachments.spec.ts | 145 +++++++++++++++++++++++ playwright/e2e/page-content.spec.ts | 8 +- playwright/support/helpers/textEditor.ts | 131 ++++++++++++++++++++ 4 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 playwright/e2e/editor-lifecycle.spec.ts create mode 100644 playwright/e2e/page-attachments.spec.ts create mode 100644 playwright/support/helpers/textEditor.ts diff --git a/playwright/e2e/editor-lifecycle.spec.ts b/playwright/e2e/editor-lifecycle.spec.ts new file mode 100644 index 0000000000..b22aa4dca4 --- /dev/null +++ b/playwright/e2e/editor-lifecycle.spec.ts @@ -0,0 +1,66 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test } from '../support/fixtures/create-collectives.ts' +import { observeTextEditors } from '../support/helpers/textEditor.ts' + +test('leaving during editor initialization does not affect the next page', async ({ user, page, collective }) => { + const pageErrors: string[] = [] + page.on('pageerror', (error) => pageErrors.push(error.message)) + await observeTextEditors(page) + const first = await collective.createPage({ title: 'Source', content: 'Source content', user, page }) + const second = await collective.createPage({ title: 'Destination', content: 'Destination content', user, page }) + await first.open(false) + await page.evaluate(() => { + (window as any).collectivesTextProbe.holdNext = true + }) + await first.getModeButton(true).click() + await page.waitForFunction(() => { + return (window as any).collectivesTextProbe.release !== null + }) + await page.evaluate(async (path) => { + await (window as any).OCA.Collectives.vueRouter.push(path) + }, second.getPageUrl().replace('/index.php/apps/collectives', '')) + await expect(second.getContent()).toContainText('Destination content') + await second.switchMode(true) + await expect(second.getContent(true)).toContainText('Destination content') + await page.evaluate(() => { + (window as any).collectivesTextProbe.release() + }) + await page.waitForFunction((id) => { + return (window as any).collectivesTextProbe.calls.some((call: { fileId: number, writable: boolean, resolved: boolean }) => call.fileId === id && call.writable && call.resolved) + }, first.data.id) + await expect(second.getContent(true)).toContainText('Destination content') + await second.getContent(true).fill('Destination saved after navigation') + await second.switchMode(false) + await expect(second.getContent()).toContainText('Destination saved after navigation') + await page.reload() + await expect(second.getContent()).toContainText('Destination saved after navigation') + await first.open(false) + await expect(first.getContent()).toContainText('Source content') + expect(pageErrors).toEqual([]) +}) + +test('editor initialization rejection can be retried without navigation', async ({ user, page, collective }) => { + const pageErrors: string[] = [] + page.on('pageerror', (error) => pageErrors.push(error.message)) + await observeTextEditors(page) + const cp = await collective.createPage({ title: 'Retry editor', content: 'Initial content', user, page }) + await cp.open(false) + await page.evaluate(() => { + (window as any).collectivesTextProbe.failNext = true + }) + await cp.getModeButton(true).click() + await expect(page.getByText('Could not load the editor. Please try again.')).toBeVisible() + await page.locator('.toastify').filter({ hasText: 'Could not load the editor.' }).getByRole('button', { name: 'Close', exact: true }).click() + await cp.switchMode(true) + await cp.getContent(true).fill('Saved after retry') + await cp.switchMode(false) + await expect(cp.getContent()).toContainText('Saved after retry') + await page.reload() + await expect(cp.getContent()).toContainText('Saved after retry') + expect(pageErrors).toEqual([]) +}) diff --git a/playwright/e2e/page-attachments.spec.ts b/playwright/e2e/page-attachments.spec.ts new file mode 100644 index 0000000000..4309d802e5 --- /dev/null +++ b/playwright/e2e/page-attachments.spec.ts @@ -0,0 +1,145 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test } from '../support/fixtures/create-collectives.ts' +import { observeTextEditors, waitForTextEditors } from '../support/helpers/textEditor.ts' +import { webdavUrl } from '../support/helpers/urls.ts' +import { PageSidebarSection } from '../support/sections/PageSidebarSection.ts' + +for (const action of ['Rename', 'Delete']) { + test(`${action} embedded attachment from fresh preview persists file and references`, async ({ user, page, collective }) => { + await observeTextEditors(page) + const cp = await collective.createPage({ title: `Attachment ${action}`, content: 'Image page', user, page }) + const src = await cp.uploadImage({ filename: 'test.png', user, page }) + await cp.setContent({ content: `Image page\n\n![test](${src})`, user, page }) + await cp.open(false) + await waitForTextEditors(page) + const sidebar = new PageSidebarSection(page) + const tab = await sidebar.openSidebarTab('Attachments') + const item = tab.locator('.attachment').filter({ hasText: 'test.png' }) + await item.getByRole('button', { name: 'Actions', exact: true }).click() + await page.getByRole('menuitem', { name: action, exact: true }).click() + if (action === 'Rename') { + await page.getByRole('textbox', { name: 'Attachment name' }).fill('renamed.png') + await page.getByRole('button', { name: 'Rename attachment', exact: true }).click() + } + const markdownUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, cp.data.fileName) + const oldUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, src) + await expect.poll(async () => { + const response = await page.request.get(markdownUrl) + return response.ok() ? !(await response.text()).includes(src) : false + }).toBe(true) + await expect.poll(async () => (await page.request.get(oldUrl)).status()).toBe(404) + if (action === 'Rename') { + const newUrl = oldUrl.replace('test.png', 'renamed.png') + expect((await page.request.get(newUrl)).status()).toBe(200) + await expect.poll(async () => { + const response = await page.request.get(markdownUrl) + return response.ok() ? response.text() : 'waiting for readable Markdown' + }).toContain('renamed.png') + } + await page.reload() + await cp.waitForContent(false) + if (action === 'Rename') { + await expect(cp.getContent().locator('img')).toHaveAttribute('src', /renamed\.png/) + } else { + await expect(cp.getContent().locator('img')).toHaveCount(0) + } + }) +} + +test('embedded attachment classification survives preview edit preview and reload', async ({ user, page, collective }) => { + const cp = await collective.createPage({ title: 'Attachment classification', content: 'Image page', user, page }) + const src = await cp.uploadImage({ filename: 'test.png', user, page }) + await cp.setContent({ content: `Image page\n\n![test](${src})`, user, page }) + await cp.open(false) + const tab = await new PageSidebarSection(page).openSidebarTab('Attachments') + const embedded = tab.locator('.attachment-list-embedded .attachment').filter({ hasText: 'test.png' }) + await expect(embedded).toBeVisible() + await embedded.getByRole('button', { name: 'Actions', exact: true }).click() + await expect(page.getByRole('menuitem', { name: 'View in page', exact: true })).toBeVisible() + await page.keyboard.press('Escape') + await cp.switchMode(true) + await cp.switchMode(false) + await expect(embedded).toBeVisible() + await page.reload() + await expect(embedded).toBeVisible() +}) + +test('failed editor initialization prevents attachment deletion and permits retry', async ({ user, page, collective }) => { + await observeTextEditors(page) + const cp = await collective.createPage({ title: 'Initialization failure', content: 'Image page', user, page }) + const src = await cp.uploadImage({ filename: 'test.png', user, page }) + await cp.setContent({ content: `Image page\n\n![test](${src})`, user, page }) + await cp.open(false) + await page.evaluate(() => { + (window as any).collectivesTextProbe.failNext = true + }) + const tab = await new PageSidebarSection(page).openSidebarTab('Attachments') + const item = tab.locator('.attachment').filter({ hasText: 'test.png' }) + await item.getByRole('button', { name: 'Actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Delete', exact: true }).click() + await expect(page.getByText('Failed to delete attachment')).toBeVisible() + const fileUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, src) + expect((await page.request.get(fileUrl)).status()).toBe(200) + await item.getByRole('button', { name: 'Actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Delete', exact: true }).click() + await expect.poll(async () => (await page.request.get(fileUrl)).status()).toBe(404) + await page.reload() + await expect(cp.getContent().locator('img')).toHaveCount(0) +}) + +test('failed preflight save prevents attachment deletion', async ({ user, page, collective }) => { + await observeTextEditors(page) + const cp = await collective.createPage({ title: 'Save failure', content: 'Image page', user, page }) + const src = await cp.uploadImage({ filename: 'test.png', user, page }) + await cp.setContent({ content: `Image page\n\n![test](${src})`, user, page }) + await cp.open(false) + await page.evaluate(() => { + (window as any).collectivesTextProbe.failSave = true + }) + const tab = await new PageSidebarSection(page).openSidebarTab('Attachments') + await tab.locator('.attachment').filter({ hasText: 'test.png' }).getByRole('button', { name: 'Actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Delete', exact: true }).click() + await expect(page.getByText('Failed to delete attachment')).toBeVisible() + const fileUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, src) + expect((await page.request.get(fileUrl)).status()).toBe(200) + await page.reload() + await expect(cp.getContent().locator('img')).toBeVisible() +}) + +test('failed final save reports partial rename and can be saved again', async ({ user, page, collective }) => { + await observeTextEditors(page) + const cp = await collective.createPage({ title: 'Final save failure', content: 'Image page', user, page }) + const src = await cp.uploadImage({ filename: 'test.png', user, page }) + await cp.setContent({ content: `Image page\n\n![test](${src})`, user, page }) + await cp.open(false) + await page.evaluate(() => { + (window as any).collectivesTextProbe.failSaveAfter = 1 + }) + const tab = await new PageSidebarSection(page).openSidebarTab('Attachments') + await tab.locator('.attachment').filter({ hasText: 'test.png' }).getByRole('button', { name: 'Actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Rename', exact: true }).click() + await page.getByRole('textbox', { name: 'Attachment name' }).fill('renamed.png') + await page.getByRole('button', { name: 'Rename attachment', exact: true }).click() + await expect(page.getByText('The attachment was renamed, but the page could not be saved. Keep the editor open and try saving again.')).toBeVisible() + await expect(page.locator('.toastify').filter({ hasText: /^Renamed attachment/ })).toHaveCount(0) + await page.locator('.toastify').filter({ hasText: 'The attachment was renamed, but the page could not be saved.' }).getByRole('button', { name: 'Close', exact: true }).click() + await expect(cp.getContent(true)).toBeVisible() + const fileUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, src.replace('test.png', 'renamed.png')) + expect((await page.request.get(fileUrl)).status()).toBe(200) + await page.evaluate(() => { + (window as any).collectivesTextProbe.failSaveAfter = -1 + }) + await cp.switchMode(false) + const markdownUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, cp.data.fileName) + await expect.poll(async () => { + const response = await page.request.get(markdownUrl) + return response.ok() ? response.text() : 'waiting for readable Markdown' + }).toContain('renamed.png') + await page.reload() + await expect(cp.getContent().locator('img')).toHaveAttribute('src', /renamed\.png/) +}) diff --git a/playwright/e2e/page-content.spec.ts b/playwright/e2e/page-content.spec.ts index 10064b3a5f..4c7a4e1edc 100644 --- a/playwright/e2e/page-content.spec.ts +++ b/playwright/e2e/page-content.spec.ts @@ -7,11 +7,13 @@ import { runOcc } from '@nextcloud/e2e-test-server/docker' import { expect, mergeTests } from '@playwright/test' import { test as createCollectiveTest } from '../support/fixtures/create-collectives.ts' import { test as editorTest } from '../support/fixtures/editor.ts' +import { observeTextEditors, waitForTextEditors } from '../support/helpers/textEditor.ts' const test = mergeTests(createCollectiveTest, editorTest) test.describe('Page content', () => { test('preview does not lock the page for WebDAV writers', async ({ user, page, collective }) => { + await observeTextEditors(page) const collectivePage = await collective.createPage({ title: 'Preview without a writable Text session', content: 'Original content', @@ -20,9 +22,13 @@ test.describe('Page content', () => { }) await collectivePage.open(false) await expect(collectivePage.getContent()).toContainText('Original content') - await expect(collectivePage.getModeButton(true)).toBeEnabled() + await waitForTextEditors(page) // A hidden writable editor would acquire a Text lock and reject this PUT. await collectivePage.setContent({ content: 'Updated through WebDAV', user, page }) + const writableCalls = await page.evaluate(() => { + return (window as any).collectivesTextProbe.calls.filter((call: { writable: boolean }) => call.writable).length + }) + expect(writableCalls).toBe(0) await page.reload() await collectivePage.waitForContent(false) await expect(collectivePage.getContent()).toContainText('Updated through WebDAV') diff --git a/playwright/support/helpers/textEditor.ts b/playwright/support/helpers/textEditor.ts new file mode 100644 index 0000000000..06029e0012 --- /dev/null +++ b/playwright/support/helpers/textEditor.ts @@ -0,0 +1,131 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' + +/** + * Observe the real Text API without replacing its session/network behavior. + * Test-controlled failures/delays are used only when explicitly armed. + * + * @param page Browser page before navigation. + */ +export async function observeTextEditors(page: Page) { + await page.addInitScript(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = window as any + const probe = w.collectivesTextProbe = { + calls: [] as Array<{ writable: boolean, loaded: boolean, resolved: boolean, fileId: number }>, + failNext: false, + holdNext: false, + failSave: false, + failSaveAfter: -1, + release: null as null | (() => void), + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const instrument = (text: any) => { + // Text installs createEditor after assigning the OCA.Text namespace. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wrap = (create: any) => async function(this: unknown, options: any) { + const writable = options.readOnly === false && options.useSession !== false + const call = { writable, loaded: false, resolved: false, fileId: options.fileId } + probe.calls.push(call) + if (writable && probe.failNext) { + probe.failNext = false + throw new Error('Injected editor initialization failure') + } + if (writable && probe.holdNext) { + probe.holdNext = false + await new Promise((resolve) => { + probe.release = resolve + }) + } + const instance = await create.call(this, { + ...options, + onLoaded: () => { + call.loaded = true + options.onLoaded?.() + }, + }) + call.resolved = true + if (writable) { + const save = instance.save.bind(instance) + instance.save = () => { + if (probe.failSave || probe.failSaveAfter === 0) { + return Promise.resolve(false) + } + if (probe.failSaveAfter > 0) { + probe.failSaveAfter-- + } + return save() + } + } + return instance + } + let wrapped = typeof text.createEditor === 'function' ? wrap(text.createEditor) : text.createEditor + Object.defineProperty(text, 'createEditor', { + configurable: true, + get: () => wrapped, + set: (value) => { + wrapped = wrap(value) + }, + }) + return text + } + // Core may replace window.OCA before Text installs its API. + const namespaces = new WeakSet() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const observeNamespace = (namespace: any) => { + if (namespaces.has(namespace)) { + return namespace + } + namespaces.add(namespace) + let text = namespace.Text + if (text) { + text = instrument(text) + } + Object.defineProperty(namespace, 'Text', { + configurable: true, + get: () => text, + set: (value) => { + text = instrument(value) + }, + }) + return namespace + } + let oca = observeNamespace(w.OCA || {}) + Object.defineProperty(w, 'OCA', { + configurable: true, + get: () => oca, + set: (value) => { + oca = observeNamespace(value) + }, + }) + }) +} + +/** + * Wait for every editor started during mounting, including a hidden writer. + * This deliberately does not require absence of a writer: the baseline must + * reach the WebDAV PUT and fail there with 423, after acquiring its Text lock. + * + * @param page Browser page with the Text observer installed. + */ +export async function waitForTextEditors(page: Page) { + await page.waitForFunction(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const calls = (window as any).collectivesTextProbe.calls + return calls.length > 0 && calls.every((call: { loaded: boolean, resolved: boolean }) => call.loaded && call.resolved) + }) + const hasWriter = await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (window as any).collectivesTextProbe.calls.some((call: { writable: boolean }) => call.writable) + }) + if (hasWriter) { + // Some Text releases broadcast onLoaded globally. A mounted editable + // document also proves the hidden session editor itself has initialized. + await page.locator('[data-cy-collectives="editor"] .ProseMirror[contenteditable="true"]').waitFor({ state: 'attached' }) + } + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) +} From bef9184a7f8a44540f9f97e9f8598b9b0ac458bd Mon Sep 17 00:00:00 2001 From: arminfabritzek Date: Thu, 1 Oct 2026 14:41:12 +0200 Subject: [PATCH 4/6] fix: initialize attachment editors and verify persisted references Await Text document readiness, serialize explicit attachment actions and keep their editor tied to the original page. Verify saved Markdown over DAV, report partial failures and allow initialization retries. Use reader attachment metadata in preview, including after editing. Signed-off-by: arminfabritzek --- src/components/Page/TextEditor.vue | 106 ++++---- .../PageSidebar/SidebarTabAttachments.vue | 33 ++- src/composables/useEditor.ts | 235 ++++++++++++++---- src/stores/pages.js | 59 ++++- src/tests/useEditor.spec.ts | 174 ++++++++++++- src/tests/util/attachmentMutation.spec.ts | 60 +++++ src/types.ts | 4 + src/util/attachmentMutation.ts | 33 +++ 8 files changed, 581 insertions(+), 123 deletions(-) create mode 100644 src/tests/util/attachmentMutation.spec.ts create mode 100644 src/util/attachmentMutation.ts diff --git a/src/components/Page/TextEditor.vue b/src/components/Page/TextEditor.vue index c65035abd8..02f9cb075c 100644 --- a/src/components/Page/TextEditor.vue +++ b/src/components/Page/TextEditor.vue @@ -31,6 +31,7 @@ import { showError } from '@nextcloud/dialogs' import { subscribe, unsubscribe } from '@nextcloud/event-bus' import { t } from '@nextcloud/l10n' import { useElementSize } from '@vueuse/core' +import escapeHtml from 'escape-html' import { mapActions, mapState } from 'pinia' import { ref, watch } from 'vue' import SkeletonLoading from '../SkeletonLoading.vue' @@ -69,14 +70,16 @@ export default { document.documentElement.style.setProperty('--text-container-width', value + 'px') }) const davContent = ref('') - const { contentLoaded, editor, editorContent, editorEl, pageContent, setupEditor } = useEditor(davContent) + const { contentLoaded, editor, editorContent, editorEl, pageContent, setupEditor, saveEditor } = useEditor(davContent) const { pageInfoBarPage, reader, readerEl, setupReader } = useReader(pageContent) - return { contentLoaded, davContent, editor, editorContent, editorEl, pageContent, pageInfoBarPage, reader, readerEl, setupEditor, setupReader, textContainer, width } + return { contentLoaded, davContent, editor, editorContent, editorEl, pageContent, pageInfoBarPage, reader, readerEl, setupEditor, saveEditor, setupReader, textContainer, width } }, data() { return { textEditWatcher: null, + mountedPageId: null, + disposed: false, unregisterCurrentSnapshotPreparer: null, } }, @@ -116,19 +119,35 @@ export default { }, async mounted() { + this.mountedPageId = this.currentPage.id this.unregisterCurrentSnapshotPreparer = this.registerCurrentSnapshotPreparer(() => this.prepareCurrentEditorSnapshot()) const readerPromise = this.setupReader(this.currentPage) const editorPromise = this.setupEditor() const pageContentPromise = this.getPageContent() Promise.all([readerPromise, editorPromise, pageContentPromise]).then(() => { - this.initEditMode() + if (!this.disposed && this.currentPage.id === this.mountedPageId) { + this.initEditMode() + } + }).catch((error) => { + console.error('Failed to load page', error) }) this.textEditWatcher = this.$watch('isTextEdit', async (val) => { if (val === false) { this.stopEdit() } else if (val === true) { - await this.setupEditor() + try { + await this.setupEditor() + } catch { + if (!this.disposed && this.currentPage.id === this.mountedPageId) { + showError(t('collectives', 'Could not load the editor. Please try again.')) + this.setTextPreview() + } + return + } + if (this.disposed || this.currentPage.id !== this.mountedPageId) { + return + } // Load full circle members for autocomplete when entering edit mode const circlesStore = useCirclesStore() if (!circlesStore.currentCircleMembersFullyLoaded && !this.isPublic) { @@ -137,14 +156,11 @@ export default { } }) subscribe('collectives:attachment:insert', this.insertAttachment) - subscribe('collectives:attachment:replaceFilename', this.replaceAttachmentFilename) - subscribe('collectives:attachment:removeReferences', this.removeAttachmentReferences) }, beforeUnmount() { + this.disposed = true this.unregisterCurrentSnapshotPreparer?.() - unsubscribe('collectives:attachment:removeReferences', this.removeAttachmentReferences) - unsubscribe('collectives:attachment:replaceFilename', this.replaceAttachmentFilename) unsubscribe('collectives:attachment:insert', this.insertAttachment) this.textEditWatcher() }, @@ -154,31 +170,22 @@ export default { ...mapActions(useRootStore, ['load', 'done']), ...mapActions(useVersionsStore, ['getVersions', 'registerCurrentSnapshotPreparer']), - ...mapActions(usePagesStore, ['setTextEdit', 'setTextPreview', 'touchPage']), + ...mapActions(usePagesStore, ['setTextEdit', 'setTextPreview', 'touchPage', 'runEditorAction']), ...mapActions(useCirclesStore, ['getCircleMembers']), - insertAttachment({ name }) { - // inspired by the fixedEncodeURIComponent function suggested in - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent + async insertAttachment({ name }) { const src = '.attachments.' + this.currentPage.id + '/' + encodeAttachmentFilename(name) - // simply get rid of brackets to make sure link text is valid - // as it does not need to be unique and matching the real file name - const alt = name.replaceAll(/[[\]]/g, '') - - this.editor.insertAtCursor(`${alt}`) - }, - - replaceAttachmentFilename({ pageId, oldName, newName }) { - // Only available since editorApi 1.4 - if (this.editor.replaceAttachmentFilename) { - this.editor.replaceAttachmentFilename(pageId, oldName, newName) - } - }, - - removeAttachmentReferences({ pageId, name }) { - // Only available since editorApi 1.4 - if (this.editor.removeAttachmentReferences) { - this.editor.removeAttachmentReferences(pageId, name) + const alt = escapeHtml(name.replaceAll(/[[\]]/g, '')) + try { + await this.runEditorAction(async (editor, save) => { + editor.insertAtCursor(`${alt}`) + if (await save() !== true) { + throw new Error('Could not save the inserted attachment.') + } + }) + } catch (error) { + console.error('Failed to insert attachment', error) + showError(t('collectives', 'Could not insert and save the attachment. Please try again.')) } }, @@ -199,11 +206,6 @@ export default { this.editor?.focus() }, - // called from the parent component as well - saveEditor() { - return this.editor.save() - }, - async prepareCurrentEditorSnapshot() { if (this.isTextEdit && this.editor && await this.saveEditor() !== true) { throw new Error('Could not save the current page before comparison.') @@ -212,6 +214,9 @@ export default { }, async stopEdit() { + if (!this.editor) { + return + } // switch back to edit if there's no content if (!this.pageContent?.trim()) { this.setTextEdit() @@ -221,24 +226,27 @@ export default { return } - const changed = this.editorContent && (this.editorContent !== this.davContent) - if (changed) { - // Save pending changes in editor - // TODO: detect missing connection and display warning - await this.saveEditor() - .catch(() => { - showError(t('collectives', 'Error saving the document. Please try again.')) - this.setTextEdit() - }) - - // Touch page to update last changed timestamp - this.touchPage() + try { + if (await this.saveEditor() !== true) { + throw new Error('The editor did not confirm saving.') + } + if (!this.disposed && this.currentPage.id === this.mountedPageId && this.editorContent !== this.davContent) { + this.touchPage() + } + } catch { + if (!this.disposed && this.currentPage.id === this.mountedPageId) { + showError(t('collectives', 'Error saving the document. Please try again.')) + this.setTextEdit() + } } }, async getPageContent() { - this.davContent = await this.fetchPageContent(this.currentPageDavUrl) - this.done('pageContent') + const content = await this.fetchPageContent(this.currentPageDavUrl) + if (!this.disposed && this.currentPage.id === this.mountedPageId) { + this.davContent = content + this.done('pageContent') + } }, }, } diff --git a/src/components/PageSidebar/SidebarTabAttachments.vue b/src/components/PageSidebar/SidebarTabAttachments.vue index 2fecfaddef..eac928ef39 100644 --- a/src/components/PageSidebar/SidebarTabAttachments.vue +++ b/src/components/PageSidebar/SidebarTabAttachments.vue @@ -180,7 +180,6 @@