Skip to content

perf: windowed card rendering on large stacks + fix Tiptap mount race in Description#8140

Open
devshoa03 wants to merge 2 commits into
nextcloud:mainfrom
devshoa03:perf/stack-windowing-description-race-fix
Open

perf: windowed card rendering on large stacks + fix Tiptap mount race in Description#8140
devshoa03 wants to merge 2 commits into
nextcloud:mainfrom
devshoa03:perf/stack-windowing-description-race-fix

Conversation

@devshoa03

@devshoa03 devshoa03 commented Jul 7, 2026

Copy link
Copy Markdown

Summary

On boards with many cards per list, clicking a card to open the detail
sidebar is very slow. We profiled this on Deck 1.17.4 and measured an
INP (Interaction to Next Paint) of 2,649ms on click, with ~10.7s
of cumulative forced-reflow time attributable to that single click.

The root cause is layout thrashing spread across a few independent
problems in the card-open path. This PR fixes two of them:

1. src/components/board/Stack.vue — unbounded card rendering per stack

Every card in a stack is rendered in full at once, regardless of
column length. On a column with hundreds of cards this produces
thousands of DOM nodes (one board we tested against hit 4,942
elements in a single column), so any layout-invalidating interaction —
such as opening the card sidebar — forces the browser to lay out that
entire subtree.

We considered a standard virtual-scroll/recycling approach, but Deck
uses vue-smooth-dnd for drag-and-drop, which resolves drop positions
against the real DOM children of the drag container, by index.
Recycling an arbitrary middle slice of the list would desync those
indices from the underlying card array and break drag-and-drop.

Instead, this PR renders a contiguous prefix of the card list
(the first 30 cards), growing it by 30 as the user scrolls near the
bottom of the stack (and immediately expanding to the full list when a
new card is added). Because the rendered set is always a prefix
starting at index 0, the existing index-based drag-and-drop math
(payloadForCard / onDropCard) is untouched and stays correct.
Stacks at or below the 30-card threshold render exactly as before.

Known, intentional limitation: a card cannot be dragged directly
into the not-yet-rendered tail of a very large stack until that part
of the list has been scrolled into view at least once. We consider
this an acceptable trade-off given it preserves 100% of the existing
drag-and-drop correctness, but flagging it explicitly for review.

2. src/components/card/Description.vue — Tiptap/OCA.Text mount race

Description is keyed by card.id, so Vue destroys and remounts it
every time the selected card changes. setupEditor() calls the async
window.OCA.Text.createEditor(...); if the user switches cards
quickly, that promise can resolve after beforeDestroy() has already
run, binding a live Tiptap/ProseMirror editor to an already-detached
DOM element. That editor is then never destroyed, and interacting with
it (or with the stale this.editor reference from the old instance)
throws "the editor view is not available".

This PR adds an isDestroyed flag (set in beforeDestroy()) that is
checked before starting editor creation, before assigning the resolved
editor to the instance (destroying it immediately instead, if the
component was torn down in the meantime), inside every
createEditor() callback (onLoaded / onUpdate / onFileInsert),
and at the other call sites that touch this.editor. It also defers
the createEditor() call by one $nextTick() so editor setup doesn't
compete with the sidebar/modal's own open transition, and resets
this.editor to null after teardown.

Known, intentional limitation: this narrows but does not fully
eliminate
the race. Vue 2 does not await an async beforeDestroy()
hook before proceeding, so there remains, in principle, a narrower
window in the same microtask sequence. This is a meaningful reduction
of the failure window, not a complete fix.

What's intentionally not in this PR

A third contributor to the same click-lag problem is in
src/components/cards/CardItem.vue, where a <transition-group>
around the sidebar/card list causes additional forced reflow, plus a
full-list re-render on modal open. #8112 already fixes exactly this,
so it is deliberately excluded here to avoid duplicating that
open PR. We are not requesting any changes to #8112 or commenting on
it; it's referenced here purely for context on the full picture.

Measurement, honestly stated

We measured before/after INP and forced-reflow time on a production
instance with all three fixes applied together (this PR's two
fixes + the CardItem.vue fix from #8112):

Before After (all 3 fixes)
INP on card click 2,649ms 275ms
Cumulative forced-reflow time ~10.7s 632ms

We do not have isolated measurements for this PR's two fixes alone
(Stack.vue windowing + Description.vue race guard), so we are not
claiming that combined result for this PR by itself. Both changes are
independently justified on their own merits (bounding DOM size, and
removing a real error condition), but for the full measured
improvement we'd recommend maintainers consider merging this PR
together with #8112.

TODO

Checklist

  • Code is properly formatted
  • Sign-off message is added to all commits
  • Tests (unit, integration, api and/or acceptance) are included
  • Documentation (manuals or wiki) has been updated or is not required

devshoa added 2 commits July 7, 2026 14:56
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 <devshoa@gmail.com>
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 <devshoa@gmail.com>
@devshoa03 devshoa03 force-pushed the perf/stack-windowing-description-race-fix branch from 23d0e69 to eb33751 Compare July 7, 2026 07:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants