From 172ff87b5b3ca6bbc27f5805f8cbf76fd1265b31 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:29:02 +0530 Subject: [PATCH] feat: Replace `vue-virtual-scroller` with `virtua` for chat list virtualization (#13642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request Template ## Description This PR replaces `vue-virtual-scroller` with [`virtua`](https://github.com/inokawa/virtua/#benchmark) for the conversation list virtualization. ### Changes - Replace `vue-virtual-scroller` (`DynamicScroller`/`DynamicScrollerItem`) with `virtua`'s `Virtualizer` component - Remove `IntersectionObserver`-based infinite scroll in favor of `Virtualizer`'s `@scroll` event with offset-based bottom detection - Remove `useEventListener` scroll binding and `intersectionObserverOptions` computed - Simplify item rendering — no more `DynamicScrollerItem` wrapper or `size-dependencies` tracking; `virtua` measures items automatically ## Type of change - [x] New feature (non-breaking change which adds functionality) ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../dashboard/components/ChatList.vue | 138 ++++++------------ package.json | 2 +- pnpm-lock.yaml | 56 ++++--- 3 files changed, 73 insertions(+), 123 deletions(-) diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 3b2a66929..a060bcc4f 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -17,10 +17,7 @@ import { useFunctionGetter, } from 'dashboard/composables/store.js'; -// [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate -// from the current one. But we should consider using tanstack virtual in the future -// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable -import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'; +import { Virtualizer } from 'virtua/vue'; import ChatListHeader from './ChatListHeader.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import ConversationFilter from 'next/filter/ConversationFilter.vue'; @@ -29,9 +26,9 @@ import ChatTypeTabs from './widgets/ChatTypeTabs.vue'; import ConversationItem from './ConversationItem.vue'; import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue'; import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue'; -import IntersectionObserver from './IntersectionObserver.vue'; import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; +import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue'; import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue'; import { useUISettings } from 'dashboard/composables/useUISettings'; @@ -46,7 +43,6 @@ import { useSnakeCase, } from 'dashboard/composables/useTransformKeys'; import { useEmitter } from 'dashboard/composables/emitter'; -import { useEventListener } from '@vueuse/core'; import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes'; import { emitter } from 'shared/helpers/mitt'; @@ -70,8 +66,6 @@ import { matchesFilters } from '../store/modules/conversations/helpers/filterHel import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events'; import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js'; -import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; - const props = defineProps({ conversationInbox: { type: [String, Number], default: 0 }, teamId: { type: [String, Number], default: 0 }, @@ -91,9 +85,9 @@ const store = useStore(); const resolveAttributesModalRef = ref(null); const conversationListRef = ref(null); -const conversationDynamicScroller = ref(null); +const virtualListRef = ref(null); -provide('contextMenuElementTarget', conversationDynamicScroller); +provide('contextMenuElementTarget', virtualListRef); const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME); const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN); @@ -161,12 +155,6 @@ const { const { checkMissingAttributes } = useConversationRequiredAttributes(); // computed -const intersectionObserverOptions = computed(() => { - return { - root: conversationListRef.value, - rootMargin: '100px 0px 100px 0px', - }; -}); const hasAppliedFilters = computed(() => { return appliedFilters.value.length !== 0; @@ -384,18 +372,6 @@ function setFiltersFromUISettings() { function emitConversationLoaded() { emit('conversationLoad'); - // [VITE] removing this since the library has changed - // nextTick(() => { - // // Addressing a known issue in the virtual list library where dynamically added items - // // might not render correctly. This workaround involves a slight manual adjustment - // // to the scroll position, triggering the list to refresh its rendering. - // const virtualList = conversationListRef.value; - // const scrollToOffset = virtualList?.scrollToOffset; - // const currentOffset = virtualList?.getOffset() || 0; - // if (scrollToOffset) { - // scrollToOffset(currentOffset + 1); - // } - // }); } function fetchFilteredConversations(payload) { @@ -607,16 +583,13 @@ function loadMoreConversations() { } } -// Add a method to handle scroll events -function handleScroll() { - const scroller = conversationDynamicScroller.value; - if (scroller && scroller.hasScrollbar) { - const { scrollTop, scrollHeight, clientHeight } = scroller.$el; - if (scrollHeight - (scrollTop + clientHeight) < 100) { - loadMoreConversations(); - } - } -} +// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll. +// If the list doesn’t fill the viewport, loading can stall. +// IntersectionObserver triggers as soon as the sentinel is visible. +const intersectionObserverOptions = computed(() => ({ + root: conversationListRef.value, + rootMargin: '100px 0px 100px 0px', +})); function updateAssigneeTab(selectedTab) { if (activeAssigneeTab.value !== selectedTab) { @@ -822,8 +795,6 @@ useEmitter('fetch_conversation_stats', () => { store.dispatch('conversationStats/get', conversationFilters.value); }); -useEventListener(conversationDynamicScroller, 'scroll', handleScroll); - onMounted(() => { store.dispatch('setChatListFilters', conversationFilters.value); setFiltersFromUISettings(); @@ -977,61 +948,42 @@ watch(conversationFilters, (newVal, oldVal) => { />
- {{ $t('CHAT_LIST.EOF') }} -
-+ {{ $t('CHAT_LIST.EOF') }} +
+