From 860a9f7af08a4303007574c09f8426920308b52a Mon Sep 17 00:00:00 2001 From: devhoa Date: Tue, 7 Jul 2026 14:48:03 +0700 Subject: [PATCH 1/2] perf(board): render cards in a growing prefix window on large stacks Stacks with many cards (observed: boards with 100+ cards per list) render every card's full component tree at once, producing thousands of DOM nodes. Any layout-invalidating interaction on such a board (e.g. opening the card detail sidebar) then has to lay out that entire subtree, which is a major contributor to card-open jank. Render only a growing prefix of `cardsByStack` (first 30, +30 as the user scrolls near the bottom of the stack, or immediately extended to the full list when a new card is added) once a stack exceeds 30 cards. Stacks at or below the threshold are unaffected. A true virtualized/recycling window (rendering an arbitrary middle slice) was intentionally avoided: vue-smooth-dnd resolves drop positions against the real DOM children of the drag container by index, so recycling would desync drop-index math from the underlying data array. A prefix window is always anchored at index 0, so the existing index-based drag-and-drop logic (payloadForCard/onDropCard) needs no changes and stays correct. Known limitation: a card cannot be dropped directly into the not-yet-rendered tail of a very large stack until that portion has been scrolled into view at least once. Signed-off-by: devhoa --- src/components/board/Stack.vue | 71 +++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/components/board/Stack.vue b/src/components/board/Stack.vue index 0531548e68..2924254d30 100644 --- a/src/components/board/Stack.vue +++ b/src/components/board/Stack.vue @@ -101,7 +101,8 @@ - - + @@ -161,6 +162,27 @@ import CardItem from '../cards/CardItem.vue' import '@nextcloud/dialogs/style.css' +// Rendering every card of every stack at once does not scale: boards with +// many cards per list (50+) end up with thousands of DOM nodes, which makes +// every layout-invalidating interaction (e.g. opening the card sidebar) slow. +// +// A full virtual-scroller/recycling approach was intentionally avoided here: +// vue-smooth-dnd (see node_modules/vue-smooth-dnd) measures the *real* DOM +// children of the drag container at runtime (via the underlying smooth-dnd +// library) and maps drop positions back to array indices 1:1 with rendered +// children. Recycling/windowing an arbitrary middle slice of the list would +// desync those indices and break drag-and-drop. +// +// Instead we render a contiguous *prefix* of the card list (cards 0..N) and +// grow that prefix as the user scrolls down within the stack, or when a new +// card is added. Because it is always a prefix starting at index 0, the +// existing index-based drag-and-drop math (payloadForCard/onDropCard) stays +// correct without any offset adjustments. Lists at or below the threshold +// are rendered in full, unchanged from previous behaviour. +const CARD_RENDER_THRESHOLD = 30 +const CARD_RENDER_BATCH_SIZE = 30 +const CARD_RENDER_SCROLL_MARGIN = 400 + export default { name: 'Stack', components: { @@ -201,6 +223,7 @@ export default { total: 0, current: null, }, + renderedCardCount: CARD_RENDER_THRESHOLD, } }, computed: { @@ -223,6 +246,15 @@ export default { isDoneColumn() { return !!this.stack.isDoneColumn }, + // See the comment above CARD_RENDER_THRESHOLD for why this is a + // growing prefix of `cardsByStack` rather than a virtualized/ + // recycled window. + visibleCards() { + if (this.cardsByStack.length <= CARD_RENDER_THRESHOLD) { + return this.cardsByStack + } + return this.cardsByStack.slice(0, this.renderedCardCount) + }, dragHandleSelector() { return this.canEdit && !this.showArchived ? null : '.no-drag' }, @@ -249,6 +281,11 @@ export default { mounted() { this.setupAutoscrollOnDrag() + this.setupCardWindowScrollListener() + }, + + beforeDestroy() { + this.teardownCardWindowScrollListener() }, methods: { @@ -333,6 +370,9 @@ export default { }) this.newCardTitle = '' this.showAddCard = true + // A newly created card must be visible immediately, even if it + // landed past the currently rendered prefix of a windowed list. + this.renderedCardCount = this.cardsByStack.length this.$nextTick(() => { this.$refs.newCardInput.focus() this.animate = false @@ -380,6 +420,33 @@ export default { timer = window.setInterval(() => autoscroll(e), 25) }) }, + setupCardWindowScrollListener() { + const el = this.$refs.dndContainer?.$el + if (!el) { + return + } + this.cardWindowScrollEl = el + this.onCardWindowScroll = () => { + if (this.renderedCardCount >= this.cardsByStack.length) { + return + } + const { scrollTop, scrollHeight, clientHeight } = el + if (scrollHeight - (scrollTop + clientHeight) < CARD_RENDER_SCROLL_MARGIN) { + this.renderedCardCount = Math.min( + this.cardsByStack.length, + this.renderedCardCount + CARD_RENDER_BATCH_SIZE, + ) + } + } + el.addEventListener('scroll', this.onCardWindowScroll, { passive: true }) + }, + teardownCardWindowScrollListener() { + if (this.cardWindowScrollEl && this.onCardWindowScroll) { + this.cardWindowScrollEl.removeEventListener('scroll', this.onCardWindowScroll) + } + this.cardWindowScrollEl = null + this.onCardWindowScroll = null + }, }, } From eb337510b3342be8d87bb5f087268b821a820383 Mon Sep 17 00:00:00 2001 From: devhoa Date: Tue, 7 Jul 2026 14:48:14 +0700 Subject: [PATCH 2/2] fix(card): guard Tiptap editor mount against fast card switches Description is keyed by card.id and destroyed/remounted whenever the selected card changes. setupEditor() calls the async window.OCA.Text.createEditor(...), which can still be resolving when the user switches to another card and beforeDestroy() runs. When that happens the resolved editor is bound to an already-detached DOM element, and any later interaction with it (or with the stale `this.editor` reference) throws "the editor view is not available". Add an `isDestroyed` flag, set in beforeDestroy() and checked: - before starting editor creation, - before assigning the resolved editor to `this.editor` (destroying it immediately instead if the component was torn down while it was being created), - in every createEditor() callback (onLoaded/onUpdate/onFileInsert), and - everywhere else `this.editor` is used outside of the lifecycle methods. Also defer the createEditor() call by one tick ($nextTick) so editor setup doesn't compete with the card sidebar/modal's own open transition, and reset `this.editor` to null in destroyEditor(). Known limitation: this narrows but does not fully close the race. Vue 2 does not await an async beforeDestroy() hook before proceeding with teardown/remount, so in principle isDestroyed could still be set after a check but before the next line runs in the same microtask sequence. This is a meaningful reduction of the failure window, not a complete fix. Signed-off-by: devhoa --- src/components/card/Description.vue | 51 +++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/src/components/card/Description.vue b/src/components/card/Description.vue index 1bd3457b91..d89e610057 100644 --- a/src/components/card/Description.vue +++ b/src/components/card/Description.vue @@ -117,6 +117,12 @@ export default { return { textAppAvailable: !!window.OCA?.Text?.createEditor, editor: null, + // Set in beforeDestroy() and checked by every async callback that + // touches `this.editor`/the component instance, so that work left + // over from a card switch (this component is remounted per + // card.id, see CardSidebarTabDetails.vue) never reaches into a + // torn-down editor/ProseMirror view. + isDestroyed: false, keyExitState: 0, descriptionOld: '', description: '', @@ -181,7 +187,7 @@ export default { this.descriptionOld = newCard.description this.description = newCard.description - if (this.editor) { + if (this.editor && !this.isDestroyed) { this.editor.setContent(this.description) } showWarning(t('deck', 'The description has been changed by another user.'), { timeout: 3000 }) @@ -192,22 +198,43 @@ export default { this.setupEditor() }, async beforeDestroy() { + this.isDestroyed = true await this.destroyEditor() }, methods: { async setupEditor() { await this.destroyEditor() + if (this.isDestroyed) { + return + } this.descriptionLastEdit = 0 this.descriptionOld = this.card.description this.description = this.card.description - this.editor = await window.OCA.Text.createEditor({ + + // Defer the (potentially expensive) editor mount until after the + // current render cycle - e.g. the card sidebar/modal opening + // transition - has settled, so it does not compete with it and + // so a fast card switch has a chance to flip `isDestroyed` + // before we ever touch window.OCA.Text. + await this.$nextTick() + if (this.isDestroyed || !this.$refs.editor) { + return + } + + const editor = await window.OCA.Text.createEditor({ el: this.$refs.editor, content: this.card.description, readOnly: !this.canEdit, onLoaded: () => { + if (this.isDestroyed) { + return + } this.descriptionLastEdit = 0 }, onUpdate: ({ markdown }) => { + if (this.isDestroyed) { + return + } if (this.description === markdown) { return } @@ -215,14 +242,32 @@ export default { this.updateDescription() }, onFileInsert: () => { + if (this.isDestroyed) { + return + } this.showAttachmentModal() }, }) + if (this.isDestroyed) { + // The component (and its DOM node passed as `el` above) was + // torn down while OCA.Text.createEditor() was still + // resolving - e.g. the user clicked another card before the + // editor finished mounting. Leaving this editor assigned to + // `this.editor` (or just dropped) is exactly what produces + // the "[tiptap error]: The editor view is not available" + // console error later, since it is bound to a detached + // element and nothing else will ever call destroy() on it. + editor?.destroy?.() + return + } + + this.editor = editor }, async destroyEditor() { await this.saveDescription() this?.editor?.destroy() + this.editor = null }, addKeyListeners() { this.$refs.markdownEditor.easymde.codemirror.on('keydown', (a, b) => { @@ -264,7 +309,7 @@ export default { // We need to strip those as text does not support rtl yet, so we cannot insert them separately const stripRTLO = (text) => text.replaceAll('\u202e', '') const fileName = stripRTLO(attachment.extendedData.info.filename) + '.' + stripRTLO(attachment.extendedData.info.extension) - if (this.editor) { + if (this.editor && !this.isDestroyed) { this.editor.insertAtCursor( asImage ? `${attachment.data}`