Virtual Scrolling 2.0 #3573
No reviewers
Labels
No labels
BE Request
Backlog
Better behavior
Blocked by deps
Bug
Code Review
Discussion
Doing
Feature Request / Enhancement
In the shining bright future maybe
It's complicated
MS Edge
Mememoon (or other niche browsers)
Missing API
Missing feature
Missing l10n/i18n
NL1
NL2
NL4
Need to verify on develop
RELEASE BLOCKER
Reassign or Close
Refactor
Regression
Safari
To Do
User story
Waiting on godot
accessibility
better documentation
chore
confirmed
easy ticket
incident
l10n update
mastoapi
mobile
needs design
needs-info
needs-review
stupid
No milestone
No project
No assignees
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
pleroma/pleroma-fe!3573
Loading…
Reference in a new issue
No description provided.
Delete branch "virtual-scrolling-2.0"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Internal changes:
Conversationcomponent to use Composition APIuseTreeConversationTopologycomposableConversationandChat viewshare is separated intouseConversationcomposableuseVirtualScrollingcomposablestatusorspacer.vue-virtual-scrollerit accounts for "suspend" state, i.e. posts with playing media or open reply form don't get replaced by spacerVirtual Scrolling 2.0to WIP: Virtual Scrolling 2.0Local review findings by astra
Reviewed commit
1d9b458e7482963846cd83b2f4aeaa8c7bdc9b58againstdevelopatb90938c7bca9fb1e58c783e0a24a4f937616f614.1. P1 — Navigation throws for status IDs starting with a digit
src/components/conversation/conversation.js:85The selector interpolates the ID without quotes:
An attribute-selector value beginning with a digit is invalid CSS. Calling
diveIntoStatus('123456789012345')rejects with aSyntaxError, before reaching the scrolling helper. This affects navigation throughtryScrollTo, including inline expansion/collapse and thread-dive actions. Quote and escape the attribute value.Reproduced in a focused Chromium test using the component's actual setup/method.
2. P2 — Hidden posts throw during mounting
src/components/status/status.js:600–602When
hideStatusis true, the rootv-ifrenders a comment node.this.resizeObserver.observe(this.$el)then throws:Observe the actual element ref and account for hidden/visible transitions, rather than unconditionally observing
$elonce at mount.Reproduced in Chromium by mounting Status with
hideStatusforced true.3. P2 — The tree-depth setting is capped at three visible levels
src/composables/useTreeConversationTopology.js:17–18This reverses the previous lower-bound check. With
maxDepthInThread = 6, a depth-two node is markedhidden, so its children are not expanded despite being within the configured depth. This should useMath.max(1, maxDepth).Reproduced with a six-post chain in a focused test.
4. P2 — Layout shifts can leave visible posts virtualized
src/composables/useVirtualScrolling.js:88–91Boundary refreshes watch viewport height, scroll Y, the conversation's own total height, and its body ref. They do not track changes to its position caused by other elements.
For example, collapsing an earlier inline conversation can move later spacer-only conversations into the viewport without changing any of those watched inputs. The later posts remain spacers until another refresh trigger occurs. Visibility needs to be refreshed for external layout changes as well as local height/scroll changes.
Reproduced in a mounted Chromium fixture: shrinking a preceding sibling from 2,000px to zero moves the conversation into view, but its chart entry remains
spacerinstead ofstatus.5. P2 — Firefox compatibility issue in the scrolling helper
src/composables/useScrollPosition.js:27–30The helper unconditionally calls the native DOM method
element.scrollIntoViewIfNeeded(options), with no fallback or polyfill found in the source. Can I Use lists this method as unsupported in Firefox; MDN describes it as proprietary/WebKit-specific.The affected navigation actions are therefore expected to throw when this helper is reached in Firefox. Ordinary scrolling does not invoke it. A rejection also leaves
inProgresstrue because resetting it is not in afinallyblock. Prefer standardscrollIntoViewwith visibility checks andtry/finally.Compatibility/source finding, not an application-level Firefox reproduction. A direct Firefox verification attempt was blocked by a Playwright page-creation error. This is distinct from the numeric-selector issue above; an alphabetic-leading ID would avoid that earlier failure.
Verification
fixed
fixed
fixed
fixed
We no longer do scroll to element, instead rely on scroll compensation of virtual scrolling
I added virtualization to timelines, it should fix this issue
Expanding a repeated status will not show the thread of the original

WIP: Virtual Scrolling 2.0to Virtual Scrolling 2.0fixed now
/oc review
opencode-agent:job=84:generation=1
Review of
virtual-scrolling-2.0@6be2c6bc56d722ab513d386da14453a7b60a5eeaagainstdevelop@b90938c7bca9fb1e58c783e0a24a4f937616f614(82 commits, 36 files, +2607/−1178), triggered by the/oc reviewrequest in this PR.Verification performed
biome check: clean.stylelint '**/*.scss' '**/*.vue': clean.Findings
P2 —
user_profile.js:37: providedprofileUserIdis permanentlynullThe function form of
provideis evaluated once at init, whenthis.userIdis stillnull(it is only assigned later, increated()→load(), asynchronously for name-based profile URLs). Plain provide values are not reactive, so every descendant injectsprofileUserId: nullforever. Consequences:Status.shouldNotMute()(status.js:391) loses the "don't mute the profile owner's own posts on their timeline" exemption — on a user profile page, posts by that user matching mute words will now render as muted, a behavior regression vs.develop.in-profileattributesuser_profile.vuestill puts on<Timeline>are dead fallthrough attributes now thatTimelineno longer declares the prop — the wholeTimeline → Conversation → Statuschain silently depends on the broken injection.Fix: provide a computed, which Vue unwraps and keeps reactive for injectors:
provide() { return { profileUserId: computed(() => this.userId) } }P3 —
useConversation.js:27: ref compared to a constant —streamingEnabledis alwaysfalsemastoUserSocketStatusis a ref here (in the oldmapStateversion this was ported from,this.mastoUserSocketStatuswas an unwrapped number). Comparing the ref object toWSConnectionStatus.JOINED(1) is always false, and since the ref is never.value-accessed, the computed doesn't even track socket state. Effect:fetchStatus(newVal)at useConversation.js:193 now fires on every focus change even when the streaming socket is joined — redundant fetching, and the "skip when streaming" optimization is silently dead.Fix:
mastoUserSocketStatus.value === WSConnectionStatus.JOINED.(Note: the new
useConversation.spec.jsdoesn't cover this branch, which is why it slipped through.)P3 —
developer_tab.vue:33: dead "virtual scrolling" debug toggleThis PR removes the
virtualScrollingdefinition fromdefault_config_state.js, butdeveloper_tab.vuestill renders<BooleanSetting path="virtualScrolling">. The toggle now points at a nonexistent config and does nothing, since virtualization is unconditionally on for timelines. Either remove the toggle or repoint it at whatever should control the new behavior.P4 —
conversation.js:129:legnthtypolastStatusis alwaysundefined, sogetStatusClasses()never applies-last— andconversation.scss's new.conversation-status:not(.-last) { border-bottom: ... }therefore also draws a trailing border under the last status in linear view.P4 —
timeline.scss:7: dead rule left over from the old implementation.vue-recycle-scroller.timeline { overflow: visible; }— the new timeline is a plain<div class="timeline" role="feed">; no element in it ever getsvue-recycle-scroller. Looks like a leftover from avue-virtual-scroller-based iteration.Nits / robustness
useVirtualScrolling.scrollTo()dereferenceselement.topwithout checking thatfind()matched; anif (!element) returnguard would make thewatch(focusedId)path inconversation.js:312resilient if a focused id is ever absent from the chart.heights/unsuspendibleIdsmaps only shrink viareset(); the Timeline instance never resets them, so they grow monotonically during long scrolling sessions. Fine for now, but a prune would help very long timelines.<div>s intimeline.vuelackaria-hidden="true", unlike the conversation spacers which have it.useConversation.fetchConversation(): the "status already known" branch has no error handling, so a failed context fetch is an unhandled rejection (loadErroronly covers the initial-fetch branch). Pre-existing in the old component, but a good opportunity now that it moved into a composable.conversation.jsstill imports/registersChatMessageListandPostStatusFormalthough neither appears in the template (carried over from base), andonUnmounted(() => { timelineChange(null, timelineRef.value) // ???? })carries an unresolved????comment into the PR.Overall
The direction is sound and clearly better than the old index-approximation approach: the
fullyLoadedgate (with its excellent write-up inuseConversation.js) solves the fragmented-partial-conversation compensation problem, suspend-state handling is a real advantage overvue-virtual-scroller, and the new composables come with genuinely useful tests. The scroll-compensation heuristics are intricate but well-commented. I'd ask for the P2 (brokenprofileUserIdprovide) and the two P3s (deadstreamingEnabled, dead developer toggle) to be addressed before merge; nothing else looks structurally blocking.Reviewed commit
6be2c6bc56d722ab513d386da14453a7b60a5eeaagainstdevelopatb90938c7bca9fb1e58c783e0a24a4f937616f614./oc review
opencode-agent:job=85:generation=1
Review of
virtual-scrolling-2.0@4b33009ce0c01a8e69216fb29598987c49607504againstdevelop@d61049c744830e37c26e09013cfd04128f7ed3be(merge-baseb90938c7bca9fb1e58c783e0a24a4f937616f614; 85 commits, 36 files, +2607/−1190), triggered by the/oc reviewrequest in this PR.Verification performed
biome check: clean.stylelint '**/*.scss' '**/*.vue': clean.Status of the previous round (job=84)
All prior findings are fixed in
1f90d85505+4b33009ce0: theprofileUserIdcomputed provide, themastoUserSocketStatus.valuecomparison, the dead developer toggle, thelegnthtypo, the dead.vue-recycle-scroller.timelinerule, thescrollTomissing-anchor guard,aria-hiddenon timeline spacers, unifiedfetchConversationerror handling, and the unusedChatMessageList/PostStatusFormimports. Nice turnaround.New findings
P1 — Conversation permalinks are broken: route param renamed, page component not updated
src/boot/routes.jsrenames the conversation route from/notice/:idto/notice/:statusId(and addsprops: true), andstatus.vue/draft.vue/notification.vue/report.vuenow all link withparams: { statusId: ... }. Butsrc/components/conversation-page/conversation-page.js:9still reads the old key:$route.params.idis now alwaysundefinedfor this route (the quotes route is separate and unaffected).props: truedoesn't save it: ConversationPage declares nostatusIdprop, so the passed prop becomes a fallthrough attribute while the computed — which shadows the name — feedsundefinedinto<Conversation :status-id="statusId" is-page>. Result: clicking any status timestamp/permalink (or opening/notice/<id>directly) lands on the conversation page withstatusId = undefined→fetchConversation()fetches/api/v1/statuses/undefined→ the page shows the load-error spinner instead of the conversation. This is the primary "open a thread" navigation path. Fix:return this.$route.params.statusId(or declare the prop and drop the computed).P3 —
useVirtualScrolling.scrollTo(): the new not-found guard leaves the boundary watchers paused foreveruseVirtualScrolling.js:287-293callspauseWatchers(), then — on the new miss path added in4b33009c—console.error(...)andreturnwithoutresumeWatchers(). A single missed anchor (focused id absent from the chart) permanently freezestopScrollBoundary/bottomScrollBoundaryupdates for that instance, i.e. items stop flipping betweenstatusandspacerfor the rest of its lifetime. In Conversation an expand/collapse cycle recovers via theenabledwatcher, but Timeline'senabledis a constantref(true), so there it would never recover. Wrap the body intry/finally(orresumeWatchers()before the early return).P3 —
status.jshideStatuswatcher can leave a re-shown Status permanently unobservedstatus.js:621-626toggles the ResizeObserver in a watcher with the default (pre) flush, which runs before re-render, sothis.$refs.rootis the stale value: on un-hide it seesundefined(the hidden state) and callsdisconnect(); after the element re-mounts nothing re-observes it, and the initial measurement inmounted()doesn't re-run — height updates (andheightChangeevents) silently stop for that status. Use{ flush: 'post' }(orwatch(() => this.$refs.root, ...)).P3 —
thread_tree.js: per-node O(n) subtree recomputation is O(n²) per conversation updatetotalReplyCount/totalReplyDepthare computed on every ThreadTree instance, each recursively walking the whole conversation's reply graph (the per-evaluationsizes/depthsmemo doesn't help across instances). That's O(n²) work on every conversation change — previously these maps were computed once in the parent and passed down. This PR is specifically about making huge threads fast, and tree view is where the giant threads live. Consider hoisting both maps intouseTreeConversationTopology(computed once per conversation) and providing them.P4 — Dead
heightChangewiring in ThreadTreeEvery ThreadTree node creates a
ResizeObserver(thread_tree.js:20,27-31) and emitsheightChange, but nothing listens:conversation.vue's top-level<ThreadTree>(lines 127-136) doesn't bind@height-change, and the recursive child binding inthread_tree.vueno longer forwards it either. Harmless dead code plus one extra ResizeObserver per node — either wire it to a tree-level chart or drop it.P4 —
useConversation.js:99-103: unguarded store lookup during renderwipeUserStatuses(blocking a user) can delete the conversation's entry from the store map; if that happens while the conversation is expanded andfullyLoaded, this computed throwsTypeError: undefined.keysduring render. The main status surviving makes it unlikely, but a?? new Map()fallback (or an explicit check that setsfullyLoaded = false) is cheap insurance.Nits
timeline.js:163:onUnmounted(() => { timelineChange(null, timelineRef.value) // ???? })— the unresolved????is still carried along; worth resolving the question or deleting the comment.useConversation.js:105:status.type != 'repeat'—!==for consistency.useInterfaceSizes.js:parseInt(string.slice(0, -3), 10)on--navbarSize/--panelHeaderSizeyieldsNaNif a theme ever omits the variable, silently poisoningoffsetand thus all boundary math. A default (likeApp.scss'svar(--navbarSize, 3.5rem)) would harden it.useVirtualScrolling.js:183: grouped spacers join all member ids into thev-forkey (spacer.id = [...spacer.ids].join()) — for long stretches outside the viewport this builds (and diffs) very long strings on every chart update. A stable synthetic key (e.g. first id + index) would be cheaper.Overall
The re-enable of timeline scroll compensation (
32115f47) looks right, and the fixes from the previous round are all in properly — including the regression-prone ones (the computedprovideunwraps correctly through Options-API inject, andstreamingEnablednow tracks socket state). The one thing I'd consider blocking is the P1 permalink regression: it breaks every conversation link in the UI, the test suite doesn't cover the conversation page, and it's a two-line fix. ThescrollTo/hideStatuswatcher issues are the next priorities as they silently and permanently degrade virtualization until a remount.Reviewed commit
4b33009ce0c01a8e69216fb29598987c49607504againstdevelopatd61049c744830e37c26e09013cfd04128f7ed3be./oc review
opencode-agent:job=86:generation=1
Review of
virtual-scrolling-2.0@61de78b7ea52ae7d97c216acecb3d5ed03807707againstdevelop@d61049c744830e37c26e09013cfd04128f7ed3be(merge-baseb90938c7bca9fb1e58c783e0a24a4f937616f614; 102 commits, 46 files, +2676/−1543), triggered by the/oc reviewrequest in this PR.Verification performed
biome check: clean.stylelint '**/*.scss' '**/*.vue': clean.vite build): passed.4b33009ce0).Status of the previous round (job=85)
All findings are fixed and verified in source:
conversation-pageis gone entirely; theconversationroute now mountsConversationdirectly withprops: (route) => ({ statusId: route.params.statusId, isPage: true }), and every permalink (status.vue,notification.vue,report.vue,draft.vue, bothbuttons_definitions.jsactions) usesparams: { statusId }. Cleanest possible fix.scrollTowatcher freeze:pauseWatchers()now happens only after the not-found guard, and the success path still ends inresumeWatchers()— no path leaves the boundary watchers paused anymore.hideStatuswatcher: now{ flush: 'post' }, so it observes the freshly re-mounted root instead of the stale ref.totalReplyCount/totalReplyDepthare computed once inuseTreeConversationTopologyand provided down as Maps;ThreadTreejust injects them. Per-node work is now O(1).heightChangewiring: the ResizeObserver and the emit were removed fromThreadTree(trees aren't virtualized).?? new Map()guard added. I also checkedwipeUserStatuses— it removes fromconversationsandallStatusesatomically, so the per-status lookup can't realistically miss afterwards.!==, the// ????comment, reading the real--navbar-height/--panel-header-heightvariables, and stablei${index}spacer keys — all addressed.New findings
Nothing blocking this round. Small notes:
Nit —
old_default_config_state.js:111: stalevirtualScrolling: truedefault. The setting was removed fromdefault_config_state.jsand the developer tab, but the old-config migration defaults still carry it. Harmless (nothing reads it anymore), but it can go.Nit —
streamingEnabledbranch still untested.useConversation.spec.jscovers unknown-status, expand, statusId-change and replies flows, but not the socket-state branch (the fixedmastoUserSocketStatus.valuecomparison). A test would pin it so it can't regress back to a constant.Note — branch is behind
develop. The PR diff currently shows the recently-merged "search by author" feature (UserSelectorInput, theaccountIdsearch param, related i18n keys) being removed. That's an artifact of the branch predating those develop commits (merge-baseb90938c7), not an intentional revert — the PR reports mergeable, but merging or rebasing develop before final merge would keep the diff honest.Note — PR description drift. The body says chat view shares
useConversation, but no chat component imports it (onlyConversationdoes). Cosmetic.Also verified while re-reading the new wiring: the thread-display toggle chain is correct —
StatusemitstoggleThreadDisplaywithout a payload, but the button only renders whenthreadDisplayStateis provided (i.e. insideThreadTree), andThreadTreere-emits with its ownstatusId, soConversation.toggleThreadDisplay(id)always receives the right node.Overall
Every previously reported issue is fixed properly — including the regression-prone ones — the two riskiest paths (permalink navigation, diving/thread re-rooting) got dedicated fix commits plus test updates, and the border restyling is scoped correctly (
:not(.-last)now only applies in linear view). Suite, linters and production build are green at the exact head. Nothing blocking from my side; just update the branch with develop before merge so the diff stops claiming to delete the search-by-author feature.Reviewed commit
61de78b7ea52ae7d97c216acecb3d5ed03807707againstdevelopatd61049c744830e37c26e09013cfd04128f7ed3be.