diff --git a/browser/src/app/ViewLayoutMultiPage.ts b/browser/src/app/ViewLayoutMultiPage.ts index 6c8863c975bc6..18056a6453539 100644 --- a/browser/src/app/ViewLayoutMultiPage.ts +++ b/browser/src/app/ViewLayoutMultiPage.ts @@ -176,6 +176,11 @@ class ViewLayoutMultiPage extends ViewLayoutNewBase { protected refreshVisibleAreaRectangle(): void { const documentAnchor = this.getDocumentAnchorSection(); + + // When the document container is hidden (e.g. BackstageView in CODA), the + // anchor section has zero size - bail out to avoid an infinite retry loop. + if (documentAnchor.size[0] <= 0 || documentAnchor.size[1] <= 0) return; + const view = cool.SimpleRectangle.fromCorePixels([ this.scrollProperties.viewX, this.scrollProperties.viewY, diff --git a/qt/test/fixtures/scrolling.odt b/qt/test/fixtures/scrolling.odt new file mode 100644 index 0000000000000..7c8f0df2f2c92 Binary files /dev/null and b/qt/test/fixtures/scrolling.odt differ diff --git a/qt/test/lib/file-dialog.ts b/qt/test/lib/file-dialog.ts new file mode 100644 index 0000000000000..4b10ed5ff162b --- /dev/null +++ b/qt/test/lib/file-dialog.ts @@ -0,0 +1,58 @@ +/* + * Copyright the Collabora Online contributors. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { join } from 'path'; +import * as webview from './webview.js'; + +/** + * Open a file from the test fixtures directory via the native Qt file dialog. + * + * Triggers .uno:Open, fills the filename field, clicks Open, switches to + * the new WebView, and waits for the document to finish loading. + */ +export async function openFixture( + webEngine: WebdriverIO.Browser, + native: WebdriverIO.Browser, + fileName: string, +): Promise { + // Use setTimeout so execute() returns before the modal dialog blocks. + await webEngine.execute(() => { + setTimeout(() => { + window.postMobileMessage('uno .uno:Open'); + }, 100); + }); + + const fileNameField = await native.$( + '//*[@accessibility-id="QApplication.QFileDialog.fileNameEdit"]', + ); + await fileNameField.waitForExist({ timeout: 10000 }); + + const filePath = join(process.env.CODA_QT_TEST_DOCUMENTS_DIR!, fileName); + await fileNameField.setValue(filePath); + + const openBtn = await native.$( + '//*[@accessibility-id="QApplication.QFileDialog.buttonBox.QPushButton"]', + ); + await openBtn.waitForExist({ timeout: 5000 }); + await openBtn.click(); + + await webview.switchToNewWebView(webEngine); + + await (webEngine as any).waitForCondition( + () => + typeof app !== 'undefined' && + app.map && + app.map._docLoaded === true, + { + timeout: 45000, + timeoutMsg: `Document did not load after opening ${fileName}`, + }, + ); +} diff --git a/qt/test/specs/multipage-backstage.spec.ts b/qt/test/specs/multipage-backstage.spec.ts new file mode 100644 index 0000000000000..1b5a0b4e3411e --- /dev/null +++ b/qt/test/specs/multipage-backstage.spec.ts @@ -0,0 +1,98 @@ +/* + * Copyright the Collabora Online contributors. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { openFixture } from '../lib/file-dialog.js'; + +// Regression test for https://github.com/CollaboraOnline/online/issues/15291 +// +// When BackstageView is shown while multi-page view is active, hiding +// #document-container triggers ResizeObservers that shrink the canvas to +// zero. ViewLayoutMultiPage.refreshVisibleAreaRectangle() then enters an +// infinite retry loop (no page rectangles intersect a zero-sized viewport), +// making the UI completely unresponsive. +describe('Multi-page view backstage (issue #15291)', () => { + it('should not freeze when opening backstage in multi-page view', async function () { + await openFixture(browser.webEngine, browser.native, 'scrolling.odt'); + + // Enable multi-page view. + await browser.webEngine.execute(() => { + app.dispatcher.dispatch('multipageview'); + }); + + // Wait for multi-page view to be active with multiple pages. + await browser.webEngine.waitForCondition( + () => { + const layout = app.activeDocument?.activeLayout; + return !!( + layout?.type === 'ViewLayoutMultiPage' && + layout.documentRectangles && + layout.documentRectangles.length > 1 + ); + }, + { + timeout: 15000, + timeoutMsg: + 'Multi-page view did not activate with multiple pages', + }, + ); + + // Wait for layout tasks to settle before triggering backstage. + await browser.webEngine.waitForCondition( + () => !app.layoutingService.hasTasksPending(), + { timeout: 10000, timeoutMsg: 'Layout tasks did not settle' }, + ); + + // Show the backstage view (same as clicking the File tab). + // Without the fix this causes an infinite layout task loop. + await browser.webEngine.execute(() => { + app.map.backstageView!.show(); + }); + + // Verify layout tasks drain. Without the fix, hasTasksPending() + // stays true forever due to the infinite retry loop in + // refreshVisibleAreaRectangle(). + await browser.webEngine.waitForCondition( + () => !app.layoutingService.hasTasksPending(), + { + timeout: 5000, + timeoutMsg: + 'Layout tasks did not drain - infinite loop detected (issue #15291)', + }, + ); + + // Close the backstage. + await browser.webEngine.execute(() => { + app.map.backstageView!.hide(); + }); + + // Verify multi-page view recovered after backstage is closed. + await browser.webEngine.waitForCondition( + () => { + const container = document.getElementById( + 'document-container', + ); + if (!container || container.classList.contains('hidden')) + return false; + + const layout = app.activeDocument?.activeLayout; + if (!layout || layout.type !== 'ViewLayoutMultiPage') + return false; + + const vr = layout.viewedRectangle; + return !!(vr && vr.pWidth > 0 && vr.pHeight > 0); + }, + { + timeout: 15000, + timeoutMsg: + 'Multi-page view did not recover after backstage', + }, + ); + }); +}); diff --git a/qt/test/specs/open-file.spec.ts b/qt/test/specs/open-file.spec.ts index e39a5d40c0695..abdb1d9fdebbf 100644 --- a/qt/test/specs/open-file.spec.ts +++ b/qt/test/specs/open-file.spec.ts @@ -8,46 +8,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { join } from 'path'; -import * as webview from '../lib/webview.js'; +import { openFixture } from '../lib/file-dialog.js'; describe('Open existing file', () => { it('should open a .odt file via the native file dialog', async function () { - // Send the UNO Open command via the bridge. Use setTimeout so - // execute() returns before the modal dialog blocks the WebView. - await browser.webEngine.execute(() => { - setTimeout(() => { - window.postMobileMessage('uno .uno:Open'); - }, 100); - }); - - // Wait for the filename field; accessibility-id is locale-stable. - const fileNameField = await browser.native.$( - '//*[@accessibility-id="QApplication.QFileDialog.fileNameEdit"]', - ); - await fileNameField.waitForExist({ timeout: 10000 }); - - const filePath = join(process.env.CODA_QT_TEST_DOCUMENTS_DIR!, 'simple.odt'); - await fileNameField.setValue(filePath); - - // First QPushButton is "Open" - const openBtn = await browser.native.$( - '//*[@accessibility-id="QApplication.QFileDialog.buttonBox.QPushButton"]', - ); - await openBtn.waitForExist({ timeout: 5000 }); - await openBtn.click(); - - await webview.switchToNewWebView(browser.webEngine); - - // Use _docLoaded; opening a file may not auto-focus the cursor. - await browser.webEngine.waitForCondition( - () => - typeof app !== 'undefined' && app.map && app.map._docLoaded === true, - { - timeout: 45000, - timeoutMsg: 'Document editor did not load after opening file', - }, - ); + await openFixture(browser.webEngine, browser.native, 'simple.odt'); const state = await browser.webEngine.execute(() => ({ docLoaded: app.map._docLoaded, diff --git a/qt/test/wdio.d.ts b/qt/test/wdio.d.ts index 8fd32ba959fa4..8e6ac33047efd 100644 --- a/qt/test/wdio.d.ts +++ b/qt/test/wdio.d.ts @@ -31,5 +31,23 @@ declare const app: { map: { _docLoaded: boolean; getDocType(): string; + backstageView?: { + show(): void; + hide(): void; + toggle(): void; + }; + }; + dispatcher: { + dispatch(action: string): void; + }; + activeDocument?: { + activeLayout?: { + type: string; + documentRectangles?: Array; + viewedRectangle?: { pWidth: number; pHeight: number }; + }; + }; + layoutingService: { + hasTasksPending(): boolean; }; };