diff --git a/src/components/Controls.spec.js b/src/components/Controls.spec.js new file mode 100644 index 000000000..09344c68e --- /dev/null +++ b/src/components/Controls.spec.js @@ -0,0 +1,72 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it, jest } from '@jest/globals' +import Controls from './Controls.vue' + +jest.mock('@nextcloud/auth', () => ({})) +jest.mock('@nextcloud/event-bus', () => ({})) +jest.mock('@nextcloud/vue', () => ({}), { virtual: true }) +jest.mock('@vueuse/components', () => ({}), { virtual: true }) +jest.mock('pinia', () => ({ + mapActions: () => ({}), + mapState: () => ({}), + mapWritableState: () => ({}), +})) +jest.mock('../views/CreateNewCardCustomPicker.vue', () => ({})) +jest.mock('./SessionList.vue', () => ({})) +jest.mock('../mixins/labelStyle.js', () => ({})) +jest.mock('../sessions.js', () => ({})) +jest.mock('../stores/board.js', () => ({})) +jest.mock('../stores/card.js', () => ({})) +jest.mock('../stores/settings.js', () => ({})) +jest.mock('../stores/stack.js', () => ({})) +jest.mock('vue-material-design-icons/ArchiveOutline.vue', () => ({})) +jest.mock('vue-material-design-icons/ArrowCollapseVertical.vue', () => ({})) +jest.mock('vue-material-design-icons/ArrowExpandVertical.vue', () => ({})) +jest.mock('vue-material-design-icons/ChartGantt.vue', () => ({})) +jest.mock('vue-material-design-icons/FilterOffOutline.vue', () => ({})) +jest.mock('vue-material-design-icons/FilterOutline.vue', () => ({})) +jest.mock('vue-material-design-icons/ImageMultipleOutline.vue', () => ({})) +jest.mock('vue-material-design-icons/TableColumnPlusAfter.vue', () => ({})) +jest.mock('vue-material-design-icons/ViewColumn.vue', () => ({})) + +describe('Controls page title', () => { + it('does not access a router in an embedded widget', () => { + const context = { + isFullApp: false, + board: { title: 'Board' }, + get $route() { + throw new Error('Widgets do not have a router') + }, + } + expect(Controls.computed.pageTitle.call(context)).toBe('') + }) + + it('does not change the host title in widget mode', () => { + expect(() => Controls.methods.setPageTitle.call({ isFullApp: false }, 'Card - Board')).not.toThrow() + }) + + it('keeps the board title in the full app', () => { + expect(Controls.computed.pageTitle.call({ + isFullApp: true, + board: { title: 'Board' }, + $route: { params: {} }, + })).toBe('Board') + }) + + it('keeps the active card title in the full app', () => { + expect(Controls.computed.pageTitle.call({ + isFullApp: true, + board: { title: 'Board' }, + $route: { params: { cardId: '7' } }, + cardById: (id) => id === 7 ? { title: 'Card' } : null, + })).toBe('Card - Board') + }) + + it('handles an unloaded board in the full app', () => { + expect(Controls.computed.pageTitle.call({ isFullApp: true, board: null })).toBe('') + }) +}) diff --git a/src/components/Controls.vue b/src/components/Controls.vue index 2c09e1fbe..27768109c 100644 --- a/src/components/Controls.vue +++ b/src/components/Controls.vue @@ -426,7 +426,7 @@ export default { ...mapState(useCardStore, ['cardById']), ...mapWritableState(useSettingsStore, ['searchQuery']), pageTitle() { - if (!this.board?.title) { + if (!this.isFullApp || !this.board?.title) { return '' } const cardTitle = this.$route.params.cardId && this.cardById(Number(this.$route.params.cardId))?.title diff --git a/src/components/board/Board.spec.js b/src/components/board/Board.spec.js new file mode 100644 index 000000000..dafd1061d --- /dev/null +++ b/src/components/board/Board.spec.js @@ -0,0 +1,245 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { unsubscribe } from '@nextcloud/event-bus' +import Board from './Board.vue' +import { showError } from '../../helpers/errors.js' +import logger from '../../logger.js' +import { createSession } from '../../sessions.js' + +jest.mock('@nextcloud/event-bus', () => ({ subscribe: jest.fn(), unsubscribe: jest.fn() })) +jest.mock('@nextcloud/vue', () => ({}), { virtual: true }) +jest.mock('pinia', () => ({ mapActions: () => ({}), mapState: () => ({}) })) +jest.mock('vue3-smooth-dnd', () => ({})) +jest.mock('vue-material-design-icons/Check.vue', () => ({})) +jest.mock('../card/CardSidebar.vue', () => ({})) +jest.mock('../Controls.vue', () => ({})) +jest.mock('../icons/DeckIcon.vue', () => ({})) +jest.mock('../search/GlobalSearchResults.vue', () => ({})) +jest.mock('./GanttView.vue', () => ({})) +jest.mock('./Stack.vue', () => ({})) +jest.mock('../../helpers/errors.js', () => ({ showError: jest.fn() })) +jest.mock('../../logger.js', () => ({ __esModule: true, default: { error: jest.fn() } })) +jest.mock('../../sessions.js', () => ({ createSession: jest.fn() })) +jest.mock('../../stores/board.js', () => ({})) +jest.mock('../../stores/card.js', () => ({})) +jest.mock('../../stores/settings.js', () => ({})) +jest.mock('../../stores/stack.js', () => ({})) + +function context() { + return { + id: 7, + disposed: false, + loadId: 0, + sessionClosing: null, + loadBoardById: jest.fn().mockResolvedValue(), + loadStacks: jest.fn().mockResolvedValue(), + loadArchivedStacks: jest.fn().mockResolvedValue(), + session: { close: jest.fn().mockResolvedValue() }, + stopMouseDrag: jest.fn(), + openCardModal: jest.fn(), + closeSession() { + return Board.methods.closeSession.call(this) + }, + } +} + +function deferred() { + let resolve + let reject + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +beforeEach(() => { + jest.clearAllMocks() + createSession.mockReturnValue({ close: jest.fn().mockResolvedValue() }) +}) + +describe('board widget lifecycle', () => { + it('closes the session and removes listeners on unmount', async () => { + const board = context() + const session = board.session + Board.beforeUnmount.call(board) + await Promise.resolve() + expect(board.disposed).toBe(true) + expect(session.close).toHaveBeenCalledTimes(1) + expect(board.stopMouseDrag).toHaveBeenCalledTimes(1) + expect(unsubscribe).toHaveBeenCalledWith('deck:card:open-modal', board.openCardModal) + }) + + it('reports session cleanup failures', async () => { + const board = context() + const error = new Error('Session close failed') + board.session.close.mockRejectedValue(error) + Board.beforeUnmount.call(board) + await new Promise(setImmediate) + expect(logger.error).toHaveBeenCalledWith('Failed to close board session', { error }) + }) + + it('does not load stacks or start a session after unmount during board loading', async () => { + const board = context() + const loading = Board.methods.fetchData.call(board) + board.disposed = true + await loading + expect(board.loadStacks).not.toHaveBeenCalled() + expect(createSession).not.toHaveBeenCalled() + }) + + it('does not start a session after unmount during stack loading', async () => { + const board = context() + board.loadStacks.mockImplementation(async () => { + board.disposed = true + }) + await Board.methods.fetchData.call(board) + expect(createSession).not.toHaveBeenCalled() + }) + + it('does not restart a session after unmount while closing the previous session', async () => { + const board = context() + board.session.close.mockImplementation(async () => { + board.disposed = true + }) + await Board.methods.fetchData.call(board) + expect(createSession).not.toHaveBeenCalled() + }) + + it('does not toggle archived content after unmount during archived-stack loading', async () => { + const board = context() + board.$route = { params: { cardId: '9' } } + board.cardById = jest.fn() + board.toggleShowArchived = jest.fn() + board.loadArchivedStacks.mockImplementation(async () => { + board.disposed = true + }) + await Board.methods.fetchData.call(board) + expect(board.toggleShowArchived).not.toHaveBeenCalled() + expect(createSession).not.toHaveBeenCalled() + }) + + it('still starts a session after a successful board load', async () => { + const board = context() + await Board.methods.fetchData.call(board) + expect(createSession).toHaveBeenCalledWith(7) + expect(showError).not.toHaveBeenCalled() + }) + + it('does not leak sessions when reloads overlap while closing the previous session', async () => { + const board = context() + const previousSession = board.session + const closing = deferred() + previousSession.close.mockReturnValue(closing.promise) + const first = Board.methods.fetchData.call(board) + await new Promise(setImmediate) + const second = Board.methods.fetchData.call(board) + await new Promise(setImmediate) + expect(createSession).not.toHaveBeenCalled() + closing.resolve() + await Promise.all([first, second]) + + expect(previousSession.close).toHaveBeenCalledTimes(1) + expect(createSession).toHaveBeenCalledTimes(1) + const currentSession = board.session + Board.beforeUnmount.call(board) + await new Promise(setImmediate) + expect(currentSession.close).toHaveBeenCalledTimes(1) + expect(board.session).toBeNull() + }) + + it('shares an in-flight close between a reload and unmount', async () => { + const board = context() + const previousSession = board.session + const closing = deferred() + previousSession.close.mockReturnValue(closing.promise) + const loading = Board.methods.fetchData.call(board) + await new Promise(setImmediate) + Board.beforeUnmount.call(board) + closing.resolve() + await loading + + expect(previousSession.close).toHaveBeenCalledTimes(1) + expect(createSession).not.toHaveBeenCalled() + }) + + it('does not continue a superseded load after the board changes', async () => { + const board = context() + const firstBoard = deferred() + board.loadBoardById.mockReturnValueOnce(firstBoard.promise) + const first = Board.methods.fetchData.call(board) + board.id = 8 + await Board.methods.fetchData.call(board) + firstBoard.resolve() + await first + + expect(board.loadStacks).toHaveBeenCalledTimes(1) + expect(board.loadStacks).toHaveBeenCalledWith(8) + expect(createSession).toHaveBeenCalledTimes(1) + expect(createSession).toHaveBeenCalledWith(8) + }) + + it('keeps the loading indicator until the latest load finishes', async () => { + const board = context() + const firstBoard = deferred() + const secondBoard = deferred() + board.loadBoardById.mockReturnValueOnce(firstBoard.promise).mockReturnValueOnce(secondBoard.promise) + const first = Board.methods.fetchData.call(board) + const second = Board.methods.fetchData.call(board) + firstBoard.resolve() + await first + expect(board.loading).toBe(true) + secondBoard.resolve() + await second + expect(board.loading).toBe(false) + }) + + it('logs stale failures without interrupting the latest load', async () => { + const board = context() + const firstBoard = deferred() + const secondBoard = deferred() + const error = new Error('Previous board load failed') + board.loadBoardById.mockReturnValueOnce(firstBoard.promise).mockReturnValueOnce(secondBoard.promise) + const first = Board.methods.fetchData.call(board) + const second = Board.methods.fetchData.call(board) + firstBoard.reject(error) + await first + expect(logger.error).toHaveBeenCalledWith('Failed to load board', { error }) + expect(showError).not.toHaveBeenCalled() + expect(board.loading).toBe(true) + secondBoard.resolve() + await second + }) + + it('reports close failures without starting a replacement session', async () => { + const board = context() + const error = new Error('Session close failed') + board.session.close.mockRejectedValue(error) + await Board.methods.fetchData.call(board) + expect(showError).toHaveBeenCalledWith(error) + expect(createSession).not.toHaveBeenCalled() + expect(board.sessionClosing).toBeNull() + expect(board.loading).toBe(false) + }) + + it('reports board loading failures', async () => { + const board = context() + const error = new Error('Board load failed') + board.loadBoardById.mockRejectedValue(error) + await Board.methods.fetchData.call(board) + expect(showError).toHaveBeenCalledWith(error) + expect(createSession).not.toHaveBeenCalled() + }) + + it('only opens cards belonging to this widget store', () => { + const board = { cardById: (id) => id === 9 ? { id: 9 } : null, localModal: null } + Board.methods.openCardModal.call(board, 8) + expect(board.localModal).toBeNull() + Board.methods.openCardModal.call(board, 9) + expect(board.localModal).toBe(9) + }) +}) diff --git a/src/components/board/Board.vue b/src/components/board/Board.vue index 0bda17df3..a90fc7477 100644 --- a/src/components/board/Board.vue +++ b/src/components/board/Board.vue @@ -153,6 +153,10 @@ export default { currentScrollPosX: null, currentMousePosX: null, localModal: null, + disposed: false, + loadId: 0, + session: null, + sessionClosing: null, } }, @@ -213,7 +217,11 @@ export default { }, beforeUnmount() { - this.session?.close() + this.disposed = true + this.stopMouseDrag() + this.closeSession().catch((error) => { + logger.error('Failed to close board session', { error }) + }) unsubscribe('deck:card:open-modal', this.openCardModal) }, @@ -221,33 +229,67 @@ export default { ...mapActions(useBoardStore, ['loadBoardById', 'toggleShowArchived']), ...mapActions(useStackStore, ['loadStacks', 'loadArchivedStacks', 'createStack', 'orderStack']), openCardModal(cardId) { - this.localModal = cardId + if (this.cardById(cardId)) { + this.localModal = cardId + } + }, + + closeSession() { + if (!this.sessionClosing && this.session) { + const session = this.session + this.session = null + // Reloads and unmount share one close operation. + this.sessionClosing = session.close().finally(() => { + this.sessionClosing = null + }) + } + return this.sessionClosing ?? Promise.resolve() }, async fetchData() { + const loadId = ++this.loadId + const boardId = this.id + const isCurrentLoad = () => !this.disposed && loadId === this.loadId this.loading = true + // Any await can resume after unmount or a newer load. Only the current + // load may continue with further work or create a session. try { - await this.loadBoardById(this.id) - await this.loadStacks(this.id) + await this.loadBoardById(boardId) + if (!isCurrentLoad()) { + return + } + await this.loadStacks(boardId) + if (!isCurrentLoad()) { + return + } const routeCardId = this.$route?.params?.cardId ? parseInt(this.$route.params.cardId) : null // If an archived card is requested, and we cannot find it in the current we load the archived stacks instead if (routeCardId && !this.cardById(routeCardId)) { - await this.loadArchivedStacks(this.id) + await this.loadArchivedStacks(boardId) + if (!isCurrentLoad()) { + return + } if (this.cardById(routeCardId)) { this.toggleShowArchived(true) } } - this.session?.close() - this.session = createSession(this.id) + await this.closeSession() + if (!isCurrentLoad()) { + return + } + this.session = createSession(boardId) } catch (e) { - this.loading = false logger.error('Failed to load board', { error: e }) - showError(e) + if (isCurrentLoad()) { + showError(e) + } } finally { - this.loading = false + if (isCurrentLoad()) { + this.loading = false + } } }, diff --git a/src/components/card/Description.spec.js b/src/components/card/Description.spec.js new file mode 100644 index 000000000..22c19321f --- /dev/null +++ b/src/components/card/Description.spec.js @@ -0,0 +1,145 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals' +import { reactive } from 'vue' +import Description from './Description.vue' +import logger from '../../logger.js' + +jest.mock('@nextcloud/dialogs', () => ({ showWarning: jest.fn() }), { virtual: true }) +jest.mock('@nextcloud/files', () => ({ formatFileSize: jest.fn() }), { virtual: true }) +jest.mock('@nextcloud/router', () => ({ generateUrl: jest.fn() }), { virtual: true }) +jest.mock('@nextcloud/vue', () => ({}), { virtual: true }) +jest.mock('pinia', () => ({ mapActions: () => ({}), mapState: () => ({}) })) +jest.mock('vue-easymde', () => ({})) +jest.mock('vue-material-design-icons/Paperclip.vue', () => ({})) +jest.mock('./AttachmentList.vue', () => ({})) +jest.mock('../../logger.js', () => ({ __esModule: true, default: { error: jest.fn() } })) +jest.mock('../../stores/card.js', () => ({})) +jest.mock('../../stores/settings.js', () => ({})) + +const originalWindow = global.window + +function context(collaborative = true) { + return reactive({ + collaborative, + canEdit: true, + card: { id: 20, description: 'Existing description' }, + $refs: { editor: {} }, + editor: null, + disposed: false, + descriptionSaveTimeout: null, + saveDescription: jest.fn().mockResolvedValue(), + updateDescription: jest.fn(), + destroyEditor() { + return Description.methods.destroyEditor.call(this) + }, + }) +} + +beforeEach(() => { + jest.clearAllMocks() + global.window = { OCA: { Text: { + createCollaborativeEditor: jest.fn(), + createMarkdownContentEditor: jest.fn(), + } } } +}) + +afterEach(() => { + global.window = originalWindow +}) + +describe('description editor lifecycle', () => { + it.each([true, false])('keeps Text instances with private fields out of Vue reactivity (collaborative: %s)', async (collaborative) => { + class Editor { + #destroyed = false + destroy() { + this.#destroyed = true + } + + get destroyed() { + return this.#destroyed + } + } + const editor = new Editor() + const description = context(collaborative) + window.OCA.Text.createCollaborativeEditor.mockResolvedValue(editor) + window.OCA.Text.createMarkdownContentEditor.mockResolvedValue(editor) + + await Description.methods.setupEditor.call(description) + expect(description.editor).toBe(editor) + await Description.beforeUnmount.call(description) + expect(editor.destroyed).toBe(true) + expect(description.editor).toBeNull() + expect(logger.error).not.toHaveBeenCalled() + }) + + it('passes the card context and permissions to Text', async () => { + const description = context() + description.canEdit = false + window.OCA.Text.createCollaborativeEditor.mockResolvedValue({ destroy: jest.fn() }) + await Description.methods.setupEditor.call(description) + expect(window.OCA.Text.createCollaborativeEditor).toHaveBeenCalledWith({ + context: { type: 'deck_card', id: 20 }, + el: description.$refs.editor, + readOnly: true, + }) + }) + + it('does not create an editor after unmount during initial cleanup', async () => { + const description = context() + const setup = Description.methods.setupEditor.call(description) + await Description.beforeUnmount.call(description) + await setup + expect(window.OCA.Text.createCollaborativeEditor).not.toHaveBeenCalled() + }) + + it.each([true, false])('destroys an editor resolved after unmount (collaborative: %s)', async (collaborative) => { + let resolveEditor + const loading = new Promise((resolve) => { + resolveEditor = resolve + }) + window.OCA.Text.createCollaborativeEditor.mockReturnValue(loading) + window.OCA.Text.createMarkdownContentEditor.mockReturnValue(loading) + const description = context(collaborative) + const setup = Description.methods.setupEditor.call(description) + await Promise.resolve() + await Promise.resolve() + await Description.beforeUnmount.call(description) + const editor = { destroy: jest.fn() } + resolveEditor(editor) + await setup + expect(editor.destroy).toHaveBeenCalledTimes(1) + expect(description.editor).toBeNull() + }) + + it('destroys an existing instance only once', async () => { + const description = context() + const editor = { destroy: jest.fn() } + description.editor = editor + await description.destroyEditor() + await description.destroyEditor() + expect(editor.destroy).toHaveBeenCalledTimes(1) + }) + + it('saves pending non-collaborative content before destroying the editor', async () => { + const description = context(false) + const destroy = jest.fn(() => expect(description.saveDescription).toHaveBeenCalled()) + description.editor = { destroy } + await description.destroyEditor() + expect(destroy).toHaveBeenCalledTimes(1) + }) + + it('reports editor destruction failures', async () => { + const description = context() + const error = new Error('Unmount failed') + description.editor = { destroy: jest.fn(() => { + throw error + }) } + await description.destroyEditor() + expect(logger.error).toHaveBeenCalledWith('Error destroying text editor', { error }) + expect(description.editor).toBeNull() + }) +}) diff --git a/src/components/card/Description.vue b/src/components/card/Description.vue index 09caf774f..2907a4605 100644 --- a/src/components/card/Description.vue +++ b/src/components/card/Description.vue @@ -78,6 +78,7 @@ import MarkdownIt from 'markdown-it' import MarkdownItLinkAttributes from 'markdown-it-link-attributes' import MarkdownItTaskCheckbox from 'markdown-it-task-checkbox' import { mapActions, mapState } from 'pinia' +import { markRaw } from 'vue' import VueEasymde from 'vue-easymde' import PaperclipIcon from 'vue-material-design-icons/Paperclip.vue' import AttachmentList from './AttachmentList.vue' @@ -130,6 +131,7 @@ export default { return { textAppAvailable: !!window.OCA?.Text?.createCollaborativeEditor, editor: null, + disposed: false, keyExitState: 0, descriptionOld: '', description: '', @@ -225,6 +227,7 @@ export default { }, async beforeUnmount() { + this.disposed = true await this.destroyEditor() }, @@ -236,38 +239,46 @@ export default { ...mapActions(useSettingsStore, ['setHasCardSaveError']), async setupEditor() { await this.destroyEditor() - if (this.collaborative) { - this.editor = await window.OCA.Text.createCollaborativeEditor({ - context: { type: 'deck_card', id: this.card.id }, - el: this.$refs.editor, - readOnly: !this.canEdit, - }) + if (this.disposed) { return } - this.editor = await window.OCA.Text.createMarkdownContentEditor({ - el: this.$refs.editor, - content: this.card.description, - readOnly: !this.canEdit, - onUpdate: ({ markdown }) => { - if (this.description === markdown) { - return - } - this.description = markdown - this.updateDescription() - }, - }) + const editor = this.collaborative + ? await window.OCA.Text.createCollaborativeEditor({ + context: { type: 'deck_card', id: this.card.id }, + el: this.$refs.editor, + readOnly: !this.canEdit, + }) + : await window.OCA.Text.createMarkdownContentEditor({ + el: this.$refs.editor, + content: this.card.description, + readOnly: !this.canEdit, + onUpdate: ({ markdown }) => { + if (this.description === markdown) { + return + } + this.description = markdown + this.updateDescription() + }, + }) + if (this.disposed) { + editor.destroy() + return + } + // Text embeds use native private fields and must not be proxied by Vue. + this.editor = markRaw(editor) }, async destroyEditor() { + const editor = this.editor + this.editor = null clearTimeout(this.descriptionSaveTimeout) if (!this.collaborative) { await this.saveDescription() } try { - this?.editor?.destroy() + editor?.destroy() } catch (e) { - // Ignore errors during editor destruction - logger.debug('Error destroying text editor', { error: e }) + logger.error('Error destroying text editor', { error: e }) } }, diff --git a/src/init-reference.js b/src/init-reference.js index bfa84a4cb..4ad0db30b 100644 --- a/src/init-reference.js +++ b/src/init-reference.js @@ -5,6 +5,7 @@ import { translate, translatePlural } from '@nextcloud/l10n' import { NcCustomPickerRenderResult, registerCustomPickerElement, registerWidget } from '@nextcloud/vue' +import { disposePinia } from 'pinia' import './shared-init.js' @@ -42,16 +43,36 @@ registerWidget('deck-card', async (el, { richObjectType, richObject, accessible app.mount(el) }) -const boardApps = {} +const boardApps = new WeakMap() +function destroyBoardWidget(el) { + const entry = boardApps.get(el) + boardApps.delete(el) + if (entry?.app) { + try { + entry.app.unmount() + } finally { + disposePinia(entry.pinia) + } + } +} + registerWidget('deck-board', async (el, { richObjectType, richObject, accessible, interactive }) => { + destroyBoardWidget(el) + const entry = {} + boardApps.set(el, entry) const { default: BoardReferenceWidget } = await import('./views/BoardReferenceWidget.vue') - const { app } = await createVueApp(BoardReferenceWidget, { richObjectType, richObject, accessible, interactive }) - boardApps[el] = app + if (boardApps.get(el) !== entry) { + return + } + const { app, pinia } = await createVueApp(BoardReferenceWidget, { richObjectType, richObject, accessible, interactive }) + if (boardApps.get(el) !== entry) { + disposePinia(pinia) + return + } + entry.app = app + entry.pinia = pinia app.mount(el) -}, (el) => { - boardApps[el]?.unmount() - delete boardApps[el] -}) +}, destroyBoardWidget) registerWidget('deck-comment', async (el, { richObjectType, richObject, accessible }) => { const { default: CommentReferenceWidget } = await import('./views/CommentReferenceWidget.vue') diff --git a/src/init-reference.spec.js b/src/init-reference.spec.js new file mode 100644 index 000000000..b4717f48f --- /dev/null +++ b/src/init-reference.spec.js @@ -0,0 +1,125 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { registerWidget } from '@nextcloud/vue' +import { createPinia, disposePinia } from 'pinia' +import { createApp } from 'vue' + +import './init-reference.js' + +jest.mock('@nextcloud/l10n', () => ({ translate: jest.fn(), translatePlural: jest.fn() })) +jest.mock('@nextcloud/vue', () => ({ registerWidget: jest.fn(), registerCustomPickerElement: jest.fn() }), { virtual: true }) +jest.mock('./shared-init.js', () => ({})) +jest.mock('./views/BoardReferenceWidget.vue', () => ({})) +jest.mock('vue', () => ({ createApp: jest.fn() })) +jest.mock('pinia', () => ({ createPinia: jest.fn(), disposePinia: jest.fn() })) + +const [, render, destroy] = registerWidget.mock.calls.find(([type]) => type === 'deck-board') +const props = { richObjectType: 'deck-board', richObject: { board: { id: 7 } }, accessible: true, interactive: true } + +beforeEach(() => { + createApp.mockReset() + createPinia.mockReset() + disposePinia.mockReset() + global.window = { OC: {}, OCA: {} } + createPinia.mockImplementation(() => ({})) + createApp.mockImplementation(() => ({ + use: jest.fn(), + directive: jest.fn(), + mount: jest.fn(), + unmount: jest.fn(), + config: { globalProperties: {} }, + })) +}) + +describe('board reference widget registration', () => { + it('mounts the interactive board with its own Pinia instance', async () => { + const element = {} + await render(element, props) + const app = createApp.mock.results[0].value + expect(createApp).toHaveBeenCalledWith(expect.anything(), props) + expect(app.use).toHaveBeenCalledWith(createPinia.mock.results[0].value) + expect(app.mount).toHaveBeenCalledWith(element) + destroy(element) + }) + + it('keeps different DOM elements isolated even if their string representations match', async () => { + const first = {} + const second = {} + await render(first, props) + await render(second, props) + const firstApp = createApp.mock.results[0].value + const secondApp = createApp.mock.results[1].value + destroy(first) + expect(firstApp.unmount).toHaveBeenCalledTimes(1) + expect(secondApp.unmount).not.toHaveBeenCalled() + expect(disposePinia).toHaveBeenCalledWith(createPinia.mock.results[0].value) + destroy(second) + expect(secondApp.unmount).toHaveBeenCalledTimes(1) + }) + + it('does not mount if destroyed before the lazy component loads', async () => { + const element = {} + const rendering = render(element, props) + destroy(element) + await rendering + expect(createApp).not.toHaveBeenCalled() + }) + + it('does not mount if destroyed while creating the Vue app', async () => { + const element = {} + const create = createApp.getMockImplementation() + createApp.mockImplementationOnce((...args) => { + const app = create(...args) + destroy(element) + return app + }) + await render(element, props) + expect(createApp).toHaveBeenCalledTimes(1) + expect(createApp.mock.results[0].value.mount).not.toHaveBeenCalled() + expect(disposePinia).toHaveBeenCalledTimes(1) + }) + + it('replaces a mounted app when the same element is rendered again', async () => { + const element = {} + await render(element, props) + const first = createApp.mock.results[0].value + await render(element, props) + expect(first.unmount).toHaveBeenCalledTimes(1) + destroy(element) + expect(createApp.mock.results[1].value.unmount).toHaveBeenCalledTimes(1) + }) + + it('does not let an older pending render overwrite a replacement', async () => { + const element = {} + const first = render(element, props) + destroy(element) + const second = render(element, props) + await Promise.all([first, second]) + expect(createApp).toHaveBeenCalledTimes(1) + expect(createApp.mock.results[0].value.mount).toHaveBeenCalledTimes(1) + destroy(element) + }) + + it('makes repeated cleanup harmless', async () => { + const element = {} + await render(element, props) + destroy(element) + destroy(element) + expect(createApp.mock.results[0].value.unmount).toHaveBeenCalledTimes(1) + expect(disposePinia).toHaveBeenCalledTimes(1) + }) + + it('disposes the Pinia instance even if unmount fails', async () => { + const element = {} + await render(element, props) + createApp.mock.results[0].value.unmount.mockImplementation(() => { + throw new Error('Unmount failed') + }) + expect(() => destroy(element)).toThrow('Unmount failed') + expect(disposePinia).toHaveBeenCalledTimes(1) + }) +})