From 3e40663949e10cabd43c847478884729e687629b Mon Sep 17 00:00:00 2001 From: Srijnabhargav Date: Thu, 28 May 2026 01:21:35 +0530 Subject: [PATCH 1/6] feat: add VirtualList component powered by virtua Introduces a generic VirtualList component backed by the virtua Virtualizer. Supports end-reach callbacks (with async lock), configurable overscan, and a semantic ul/li structure via VirtuaListContainer. Pairs with the existing CustomVirtuaScrollbars from @rocket.chat/ui-client. --- .../VirtualList/VirtuaListContainer.tsx | 24 ++++ .../components/VirtualList/VirtualList.tsx | 124 ++++++++++++++++++ .../client/components/VirtualList/index.ts | 1 + 3 files changed, 149 insertions(+) create mode 100644 apps/meteor/client/components/VirtualList/VirtuaListContainer.tsx create mode 100644 apps/meteor/client/components/VirtualList/VirtualList.tsx create mode 100644 apps/meteor/client/components/VirtualList/index.ts diff --git a/apps/meteor/client/components/VirtualList/VirtuaListContainer.tsx b/apps/meteor/client/components/VirtualList/VirtuaListContainer.tsx new file mode 100644 index 0000000000000..afcffac6ad716 --- /dev/null +++ b/apps/meteor/client/components/VirtualList/VirtuaListContainer.tsx @@ -0,0 +1,24 @@ +import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'; +import { forwardRef } from 'react'; + +const listResetStyle = { + margin: 0, + padding: 0, + listStyle: 'none', +} as const; + +export type VirtuaListContainerProps = { + children: ReactNode; + style: CSSProperties; +} & Omit, 'children' | 'style'>; + +export const VirtuaListContainer = forwardRef(function VirtuaListContainer( + { children, style, ...props }, + ref, +) { + return ( +
    + {children} +
+ ); +}); diff --git a/apps/meteor/client/components/VirtualList/VirtualList.tsx b/apps/meteor/client/components/VirtualList/VirtualList.tsx new file mode 100644 index 0000000000000..b3aa3ff2fde32 --- /dev/null +++ b/apps/meteor/client/components/VirtualList/VirtualList.tsx @@ -0,0 +1,124 @@ +import { CustomVirtuaScrollbars } from '@rocket.chat/ui-client'; +import type { ReactNode } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import type { VirtualizerHandle } from 'virtua'; +import { Virtualizer } from 'virtua'; + +import { VirtuaListContainer } from './VirtuaListContainer'; + +const NEAR_BOTTOM_THRESHOLD = -20; + +const scrollViewportStyle = { + height: '100%', + width: '100%', + overflow: 'auto', +} as const; + +type OnEndReached = () => void | Promise; + +const isThenable = (value: unknown): value is PromiseLike => typeof (value as PromiseLike | null)?.then === 'function'; + +type VirtualListProps = { + items: T[]; + totalCount: number; + renderItem: (item: T, index: number) => ReactNode; + estimateSize?: (index: number) => number; + overscan?: number; + onEndReached?: OnEndReached; +}; + +function VirtualList({ + items, + totalCount, + renderItem, + estimateSize = () => 120, + overscan, + onEndReached, +}: VirtualListProps) { + const virtualizerRef = useRef(null); + const onEndReachedRef = useRef(onEndReached); + const lastEndReachKeyRef = useRef(null); + + useEffect(() => { + onEndReachedRef.current = onEndReached; + }, [onEndReached]); + + const checkEndReached = useCallback( + (offset: number) => { + const handle = virtualizerRef.current; + const loadMore = onEndReachedRef.current; + if (!handle || !loadMore) { + return; + } + + const { scrollSize, viewportSize } = handle; + if (viewportSize <= 0) { + return; + } + + if (items.length >= totalCount) { + return; + } + + const nearBottom = offset - scrollSize + viewportSize >= NEAR_BOTTOM_THRESHOLD; + if (!nearBottom) { + return; + } + + const firstItemId = items[0]?._id ?? ''; + const lastItemId = items[items.length - 1]?._id ?? ''; + const key = `${firstItemId}:${lastItemId}:${items.length}:${totalCount}`; + if (lastEndReachKeyRef.current === key) { + return; + } + lastEndReachKeyRef.current = key; + + const releaseEndReachLock = () => { + if (lastEndReachKeyRef.current === key) { + lastEndReachKeyRef.current = null; + } + }; + + try { + const result = loadMore(); + if (isThenable(result)) { + void Promise.resolve(result).catch(releaseEndReachLock); + } + } catch { + releaseEndReachLock(); + } + }, + [items.length, totalCount], + ); + + const handleScroll = useCallback( + (offset: number) => { + checkEndReached(offset); + }, + [checkEndReached], + ); + + useLayoutEffect(() => { + const handle = virtualizerRef.current; + if (!handle) { + return; + } + checkEndReached(handle.scrollOffset); + }, [checkEndReached, items.length, totalCount]); + + return ( + +
+ + {items.map((item, index) => ( +
+ {renderItem(item, index)} +
+ ))} +
+
+
+ ); +} + +export default VirtualList; diff --git a/apps/meteor/client/components/VirtualList/index.ts b/apps/meteor/client/components/VirtualList/index.ts new file mode 100644 index 0000000000000..147ca7fe723c0 --- /dev/null +++ b/apps/meteor/client/components/VirtualList/index.ts @@ -0,0 +1 @@ +export { default as VirtualList } from './VirtualList'; From dde4c1099c88c6e5aba037ba5f18635393c40a73 Mon Sep 17 00:00:00 2001 From: Srijnabhargav Date: Thu, 28 May 2026 01:21:50 +0530 Subject: [PATCH 2/6] feat: migrate DiscussionsList from react-virtuoso to VirtualList Replaces the Virtuoso + VirtualizedScrollbars + useResizeObserver setup with the new VirtualList component. The loadMoreItems signature simplifies from (start, end) to () => void, deferring pagination logic to the data layer. --- .../Discussions/DiscussionsList.tsx | 33 ++++++++----------- .../components/DiscussionsListItem.tsx | 2 +- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.tsx b/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.tsx index a34b4d015d3b9..4ef59565af2f1 100644 --- a/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.tsx +++ b/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.tsx @@ -1,8 +1,7 @@ import type { IDiscussionMessage } from '@rocket.chat/core-typings'; import { Box, Icon, TextInput, Callout, Throbber } from '@rocket.chat/fuselage'; -import { useResizeObserver, useAutoFocus } from '@rocket.chat/fuselage-hooks'; +import { useAutoFocus } from '@rocket.chat/fuselage-hooks'; import { - VirtualizedScrollbars, ContextualbarHeader, ContextualbarIcon, ContextualbarContent, @@ -16,16 +15,16 @@ import { useSetting } from '@rocket.chat/ui-contexts'; import type { ChangeEvent, MouseEvent, RefObject } from 'react'; import { useCallback, useId } from 'react'; import { useTranslation } from 'react-i18next'; -import { Virtuoso } from 'react-virtuoso'; import DiscussionsListRow from './DiscussionsListRow'; import ResultsLiveRegion from '../../../../components/ResultsLiveRegion'; +import { VirtualList } from '../../../../components/VirtualList'; import { useGoToRoom } from '../../hooks/useGoToRoom'; type DiscussionsListProps = { itemCount: number; discussions: Array; - loadMoreItems: (start: number, end: number) => void; + loadMoreItems: () => void; isPending: boolean; isSuccess: boolean; onClose: () => void; @@ -56,15 +55,11 @@ function DiscussionsList({ const onClick = useCallback( (e: MouseEvent) => { const { drid } = e.currentTarget.dataset; - if (drid) goToRoom(drid); + if (drid) void goToRoom(drid); }, [goToRoom], ); - const { ref, contentBoxSize: { inlineSize = 378, blockSize = 1 } = {} } = useResizeObserver({ - debounceDelay: 200, - }); - return ( @@ -83,7 +78,7 @@ function DiscussionsList({ addon={} /> - + {isPending && ( @@ -99,19 +94,17 @@ function DiscussionsList({ {discussions.length === 0 && } {discussions.length > 0 && ( - - + undefined : (start) => loadMoreItems(start, Math.min(50, itemCount - start))} overscan={25} - data={discussions} - itemContent={(_, data) => } + onEndReached={isPending ? undefined : loadMoreItems} + renderItem={(discussion) => ( + + )} /> - + )} )} diff --git a/apps/meteor/client/views/room/contextualBar/Discussions/components/DiscussionsListItem.tsx b/apps/meteor/client/views/room/contextualBar/Discussions/components/DiscussionsListItem.tsx index fa1342f782f1f..0eea8986dfc51 100644 --- a/apps/meteor/client/views/room/contextualBar/Discussions/components/DiscussionsListItem.tsx +++ b/apps/meteor/client/views/room/contextualBar/Discussions/components/DiscussionsListItem.tsx @@ -41,7 +41,7 @@ const DiscussionListItem = ({ name = username, ts, dcount, - formatDate = (date: any) => date, + formatDate, dlm, className = [], emoji, From 4f26f661f5f1042adac214304e0887f141f3d7ab Mon Sep 17 00:00:00 2001 From: Srijnabhargav Date: Thu, 28 May 2026 01:21:59 +0530 Subject: [PATCH 3/6] test: add VirtualList spec and update DiscussionsList tests for virtua - New VirtualList.spec.tsx covers end-reach, overscan, and scroll behaviour using a mocked Virtualizer handle - DiscussionsList.spec.tsx updated for the virtua-backed VirtualList (new snapshot generated) - MessageList.spec.tsx: replace require('react') with jest.requireActual to silence @typescript-eslint/no-require-imports --- .../VirtualList/VirtualList.spec.tsx | 191 +++ .../room/MessageList/MessageList.spec.tsx | 2 +- .../Discussions/DiscussionsList.spec.tsx | 104 +- .../DiscussionsList.spec.tsx.snap | 1036 ++++++++++++++++- 4 files changed, 1307 insertions(+), 26 deletions(-) create mode 100644 apps/meteor/client/components/VirtualList/VirtualList.spec.tsx diff --git a/apps/meteor/client/components/VirtualList/VirtualList.spec.tsx b/apps/meteor/client/components/VirtualList/VirtualList.spec.tsx new file mode 100644 index 0000000000000..e7d7d81c9d2ee --- /dev/null +++ b/apps/meteor/client/components/VirtualList/VirtualList.spec.tsx @@ -0,0 +1,191 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'; +import * as React from 'react'; +import { Children, forwardRef, isValidElement } from 'react'; + +import VirtualList from './VirtualList'; + +const mockVirtualizerHandle = { + scrollToIndex: jest.fn(), + scrollTo: jest.fn(), + findItemIndex: jest.fn((offset: number) => offset), + scrollOffset: 0, + scrollSize: 1000, + viewportSize: 300, +}; + +type MockVListProps = { + children: ReactNode; + bufferSize?: number; + onScroll?: (offset: number) => void; + as?: React.ElementType; + item?: React.ElementType; + style?: CSSProperties; + className?: string; +}; + +jest.mock('virtua', () => { + return { + Virtualizer: React.forwardRef( + ( + { children, bufferSize, onScroll, as: asRoot = 'div', item: asItem = 'div', style, className }: MockVListProps, + ref: React.Ref, + ) => { + React.useImperativeHandle(ref, () => mockVirtualizerHandle); + const Root = asRoot; + const Item = asItem; + const wrapped = Children.map(children, (child, index) => { + const key = isValidElement(child) && child.key != null ? String(child.key) : `row-${index}`; + return {child}; + }); + + return ( + onScroll?.(mockVirtualizerHandle.scrollOffset)} + > + {wrapped} + + ); + }, + ), + }; +}); + +jest.mock('@rocket.chat/ui-client', () => ({ + ...jest.requireActual('@rocket.chat/ui-client'), + CustomVirtuaScrollbars: forwardRef>(function CustomVirtuaScrollbars( + { children, ...props }, + ref, + ) { + const content = isValidElement<{ children?: ReactNode }>(children) && children.type === 'div' ? children.props.children : children; + + return ( +
+ {content} +
+ ); + }), +})); + +const items = Array.from({ length: 10 }, (_, index) => ({ _id: `${index}` })); + +type VirtualListTestItem = (typeof items)[number]; + +const renderVirtualList = ( + props: Partial<{ + items: VirtualListTestItem[]; + totalCount: number; + renderItem: (item: VirtualListTestItem, index: number) => ReactNode; + estimateSize?: (index: number) => number; + overscan?: number; + onEndReached?: () => void | Promise; + }> = {}, +) => render(
{item._id}
} {...props} />); + +describe('VirtualList', () => { + beforeEach(() => { + mockVirtualizerHandle.scrollOffset = 0; + mockVirtualizerHandle.scrollSize = 1000; + mockVirtualizerHandle.viewportSize = 300; + }); + + it('has no accessibility violations', async () => { + const { container } = renderVirtualList(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('calls onEndReached when scrolled near the bottom', () => { + const onEndReached = jest.fn(); + + renderVirtualList({ onEndReached }); + expect(onEndReached).not.toHaveBeenCalled(); + + mockVirtualizerHandle.scrollOffset = 700; + fireEvent.scroll(screen.getByTestId('virtual-list')); + + expect(onEndReached).toHaveBeenCalledTimes(1); + }); + + it('does not call onEndReached when all items are loaded', () => { + const onEndReached = jest.fn(); + + renderVirtualList({ onEndReached, totalCount: items.length }); + mockVirtualizerHandle.scrollOffset = 700; + fireEvent.scroll(screen.getByTestId('virtual-list')); + + expect(onEndReached).not.toHaveBeenCalled(); + }); + + it('does not call onEndReached repeatedly for the same item count', () => { + const onEndReached = jest.fn(); + + renderVirtualList({ onEndReached }); + mockVirtualizerHandle.scrollOffset = 700; + fireEvent.scroll(screen.getByTestId('virtual-list')); + fireEvent.scroll(screen.getByTestId('virtual-list')); + + expect(onEndReached).toHaveBeenCalledTimes(1); + }); + + it('calls onEndReached after a same-size dataset reset', () => { + const onEndReached = jest.fn(); + const { rerender } = renderVirtualList({ onEndReached }); + mockVirtualizerHandle.scrollOffset = 700; + fireEvent.scroll(screen.getByTestId('virtual-list')); + + const resetItems = Array.from({ length: 10 }, (_, index) => ({ _id: `reset-${index}` })); + rerender(
{item._id}
} onEndReached={onEndReached} />); + fireEvent.scroll(screen.getByTestId('virtual-list')); + + expect(onEndReached).toHaveBeenCalledTimes(2); + }); + + it('passes overscan through to virtua buffer size', () => { + renderVirtualList({ overscan: 25 }); + + expect(screen.getByTestId('virtual-list')).toHaveAttribute('data-buffer-size', '25'); + }); + + it('allows onEndReached to retry after a failed load', async () => { + const onEndReached = jest.fn().mockRejectedValue(new Error('failed to load more items')); + + renderVirtualList({ onEndReached }); + mockVirtualizerHandle.scrollOffset = 700; + fireEvent.scroll(screen.getByTestId('virtual-list')); + await Promise.resolve(); + + fireEvent.scroll(screen.getByTestId('virtual-list')); + + expect(onEndReached).toHaveBeenCalledTimes(2); + }); + + it('allows onEndReached to retry after a synchronous throw', () => { + const onEndReached = jest + .fn() + .mockImplementationOnce(() => { + throw new Error('failed to load more items'); + }) + .mockImplementation(() => undefined); + + renderVirtualList({ onEndReached }); + mockVirtualizerHandle.scrollOffset = 700; + fireEvent.scroll(screen.getByTestId('virtual-list')); + fireEvent.scroll(screen.getByTestId('virtual-list')); + + expect(onEndReached).toHaveBeenCalledTimes(2); + }); + + it('calls onEndReached when the viewport is underfilled', () => { + const onEndReached = jest.fn(); + mockVirtualizerHandle.scrollSize = 200; + + renderVirtualList({ onEndReached }); + + expect(onEndReached).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/meteor/client/views/room/MessageList/MessageList.spec.tsx b/apps/meteor/client/views/room/MessageList/MessageList.spec.tsx index 6b92270b2ac07..60adcfba4c9a3 100644 --- a/apps/meteor/client/views/room/MessageList/MessageList.spec.tsx +++ b/apps/meteor/client/views/room/MessageList/MessageList.spec.tsx @@ -18,7 +18,7 @@ const mockVirtualizerHandle = { }; jest.mock('virtua', () => { - const React = require('react'); + const React = jest.requireActual('react'); return { VList: React.forwardRef( diff --git a/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.spec.tsx b/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.spec.tsx index 95f4b9bb6db3d..4300778cf898a 100644 --- a/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.spec.tsx +++ b/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.spec.tsx @@ -1,6 +1,9 @@ import { composeStories } from '@storybook/react'; -import { render } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { axe } from 'jest-axe'; +import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'; +import * as React from 'react'; +import { Children, forwardRef, isValidElement } from 'react'; import * as stories from './DiscussionsList.stories'; @@ -10,8 +13,103 @@ jest.mock('../../../../lib/rooms/roomCoordinator', () => ({ }, })); -const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); -test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { +const mockVirtualizerHandle = { + scrollToIndex: jest.fn(), + scrollTo: jest.fn(), + findItemIndex: jest.fn((offset: number) => offset), + scrollOffset: 0, + scrollSize: 1000, + viewportSize: 300, +}; + +type MockVListProps = { + children: ReactNode; + onScroll?: (offset: number) => void; + as?: React.ElementType; + item?: React.ElementType; + shift?: boolean; + style?: CSSProperties; + className?: string; +}; + +jest.mock('virtua', () => { + return { + Virtualizer: React.forwardRef( + ( + { children, onScroll, as: asRoot = 'div', item: asItem = 'div', shift: _shift, style, className }: MockVListProps, + ref: React.Ref, + ) => { + React.useImperativeHandle(ref, () => mockVirtualizerHandle); + const Root = asRoot; + const Item = asItem; + const wrapped = Children.map(children, (child, index) => { + const key = isValidElement(child) && child.key != null ? String(child.key) : `row-${index}`; + return {child}; + }); + + return ( + onScroll?.(mockVirtualizerHandle.scrollOffset)} + > + {wrapped} + + ); + }, + ), + }; +}); + +jest.mock('@rocket.chat/ui-client', () => ({ + ...jest.requireActual('@rocket.chat/ui-client'), + CustomVirtuaScrollbars: forwardRef>(function CustomVirtuaScrollbars( + { children, ...props }, + ref, + ) { + const content = isValidElement<{ children?: ReactNode }>(children) && children.type === 'div' ? children.props.children : children; + + return ( +
+ {content} +
+ ); + }), +})); + +const composed = composeStories(stories); +const testCases = Object.values(composed).map((Story) => [Story.storyName || 'Story', Story] as const); + +describe('DiscussionsList', () => { + it('renders Default with virtual list and discussion rows', () => { + const { Default } = composed; + render(); + + const list = screen.getByTestId('discussions-virtual-list'); + expect(list).toBeInTheDocument(); + expect(list.tagName.toLowerCase()).toBe('ul'); + expect(within(list).getAllByRole('listitem')).toHaveLength(10); + expect(within(list).getAllByText('user.name')).toHaveLength(10); + }); + + it('renders Empty without virtual list', () => { + const { Empty } = composed; + render(); + + expect(screen.queryByTestId('discussions-virtual-list')).not.toBeInTheDocument(); + expect(screen.getByText('No_Discussions_found')).toBeInTheDocument(); + }); + + it('renders Loading without virtual list', () => { + const { Loading } = composed; + render(); + + expect(screen.queryByTestId('discussions-virtual-list')).not.toBeInTheDocument(); + }); +}); + +test.each(testCases)('renders %s with stable structure', (_storyname, Story) => { const { baseElement } = render(); expect(baseElement).toMatchSnapshot(); }); diff --git a/apps/meteor/client/views/room/contextualBar/Discussions/__snapshots__/DiscussionsList.spec.tsx.snap b/apps/meteor/client/views/room/contextualBar/Discussions/__snapshots__/DiscussionsList.spec.tsx.snap index 9171767c3c0c3..a3a9046ce3631 100644 --- a/apps/meteor/client/views/room/contextualBar/Discussions/__snapshots__/DiscussionsList.spec.tsx.snap +++ b/apps/meteor/client/views/room/contextualBar/Discussions/__snapshots__/DiscussionsList.spec.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`renders Default without crashing 1`] = ` +exports[`renders Default with stable structure 1`] = `
-
-
+
    -
    -
    +
  • +
    +
    +
    +
    + +
    +
    +
    +
    +
    + + user.name + + + January 1st, 2024 + +
    +
    +
    +
    + Discussion 0 +
    +
    +
    +
    +
    +
    + +
    + 5 +
    +
    +
    + +
    + January 1st, 2024 +
    +
    +
    +
    +
    +
    +
    +
    +
  • +
  • +
    +
    +
    +
    + +
    +
    +
    +
    +
    + + user.name + + + January 1st, 2024 + +
    +
    +
    +
    + Discussion 1 +
    +
    +
    +
    +
    +
    + +
    + 5 +
    +
    +
    + +
    + January 1st, 2024 +
    +
    +
    +
    +
    +
    +
    +
    +
  • +
  • +
    +
    +
    +
    + +
    +
    +
    +
    +
    + + user.name + + + January 1st, 2024 + +
    +
    +
    +
    + Discussion 2 +
    +
    +
    +
    +
    +
    + +
    + 5 +
    +
    +
    + +
    + January 1st, 2024 +
    +
    +
    +
    +
    +
    +
    +
    +
  • +
  • +
    +
    +
    +
    + +
    +
    +
    +
    +
    + + user.name + + + January 1st, 2024 + +
    +
    +
    +
    + Discussion 3 +
    +
    +
    +
    +
    +
    + +
    + 5 +
    +
    +
    + +
    + January 1st, 2024 +
    +
    +
    +
    +
    +
    +
    +
    +
  • +
  • +
    +
    +
    +
    + +
    +
    +
    +
    +
    + + user.name + + + January 1st, 2024 + +
    +
    +
    +
    + Discussion 4 +
    +
    +
    +
    +
    +
    + +
    + 5 +
    +
    +
    + +
    + January 1st, 2024 +
    +
    +
    +
    +
    +
    +
    +
    +
  • +
  • +
    +
    +
    +
    + +
    +
    +
    +
    +
    + + user.name + + + January 1st, 2024 + +
    +
    +
    +
    + Discussion 5 +
    +
    +
    +
    +
    +
    + +
    + 5 +
    +
    +
    + +
    + January 1st, 2024 +
    +
    +
    +
    +
    +
    +
    +
    +
  • +
  • +
    +
    +
    +
    + +
    +
    +
    +
    +
    + + user.name + + + January 1st, 2024 + +
    +
    +
    +
    + Discussion 6 +
    +
    +
    +
    +
    +
    + +
    + 5 +
    +
    +
    + +
    + January 1st, 2024 +
    +
    +
    +
    +
    +
    +
    +
    +
  • +
  • +
    +
    +
    +
    + +
    +
    +
    +
    +
    + + user.name + + + January 1st, 2024 + +
    +
    +
    +
    + Discussion 7 +
    +
    +
    +
    +
    +
    + +
    + 5 +
    +
    +
    + +
    + January 1st, 2024 +
    +
    +
    +
    +
    +
    +
    +
    +
  • +
  • +
    +
    +
    +
    + +
    +
    +
    +
    +
    + + user.name + + + January 1st, 2024 + +
    +
    +
    +
    + Discussion 8 +
    +
    +
    +
    +
    +
    + +
    + 5 +
    +
    +
    + +
    + January 1st, 2024 +
    +
    +
    +
    +
    +
    +
    +
    +
  • +
  • +
    +
    +
    +
    + +
    +
    +
    +
    +
    + + user.name + + + January 1st, 2024 + +
    +
    +
    +
    + Discussion 9 +
    +
    +
    +
    +
    +
    + +
    + 5 +
    +
    +
    + +
    + January 1st, 2024 +
    +
    +
    +
    +
    +
    +
    +
    +
  • +
@@ -155,7 +1147,7 @@ exports[`renders Default without crashing 1`] = ` `; -exports[`renders Empty without crashing 1`] = ` +exports[`renders Empty with stable structure 1`] = `
`; -exports[`renders Loading without crashing 1`] = ` +exports[`renders Loading with stable structure 1`] = `
Date: Thu, 28 May 2026 17:31:03 +0530 Subject: [PATCH 4/6] fix: refresh VirtualList end-reach lock on dataset changes --- apps/meteor/client/components/VirtualList/VirtualList.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/meteor/client/components/VirtualList/VirtualList.tsx b/apps/meteor/client/components/VirtualList/VirtualList.tsx index b3aa3ff2fde32..0937632163455 100644 --- a/apps/meteor/client/components/VirtualList/VirtualList.tsx +++ b/apps/meteor/client/components/VirtualList/VirtualList.tsx @@ -38,6 +38,8 @@ function VirtualList({ const virtualizerRef = useRef(null); const onEndReachedRef = useRef(onEndReached); const lastEndReachKeyRef = useRef(null); + const firstItemId = items[0]?._id ?? ''; + const lastItemId = items[items.length - 1]?._id ?? ''; useEffect(() => { onEndReachedRef.current = onEndReached; @@ -65,8 +67,6 @@ function VirtualList({ return; } - const firstItemId = items[0]?._id ?? ''; - const lastItemId = items[items.length - 1]?._id ?? ''; const key = `${firstItemId}:${lastItemId}:${items.length}:${totalCount}`; if (lastEndReachKeyRef.current === key) { return; @@ -88,7 +88,7 @@ function VirtualList({ releaseEndReachLock(); } }, - [items.length, totalCount], + [firstItemId, items.length, lastItemId, totalCount], ); const handleScroll = useCallback( From f613e4c6225cdafeaa658b656b7b9d8b0844c225 Mon Sep 17 00:00:00 2001 From: srijna Date: Thu, 28 May 2026 17:51:26 +0530 Subject: [PATCH 5/6] fix: provide date formatter in discussions item stories --- .../Discussions/components/DiscussionsListItem.stories.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/meteor/client/views/room/contextualBar/Discussions/components/DiscussionsListItem.stories.tsx b/apps/meteor/client/views/room/contextualBar/Discussions/components/DiscussionsListItem.stories.tsx index b153fff663a65..bf65b173bd2ec 100644 --- a/apps/meteor/client/views/room/contextualBar/Discussions/components/DiscussionsListItem.stories.tsx +++ b/apps/meteor/client/views/room/contextualBar/Discussions/components/DiscussionsListItem.stories.tsx @@ -5,7 +5,8 @@ const message = { ts: new Date(0), username: 'guilherme.gazzo', dcount: 5, - dlm: new Date(0).toISOString(), + dlm: new Date(0), + formatDate: (date: Date) => date.toISOString(), }; const largeText = { From 4ba2638e9e2de836b13962371674685f829b4208 Mon Sep 17 00:00:00 2001 From: srijna Date: Fri, 29 May 2026 16:50:26 +0530 Subject: [PATCH 6/6] fix: address paginated virtual list review --- .../VirtualList/VirtualList.spec.tsx | 59 ++++++++++++----- .../components/VirtualList/VirtualList.tsx | 63 +++++++------------ .../client/components/VirtualList/index.ts | 2 +- .../Discussions/DiscussionsList.spec.tsx | 1 + .../Discussions/DiscussionsList.stories.tsx | 9 ++- .../Discussions/DiscussionsList.tsx | 7 ++- .../Discussions/DiscussionsListContextBar.tsx | 2 +- .../DiscussionsList.spec.tsx.snap | 40 +++--------- 8 files changed, 91 insertions(+), 92 deletions(-) diff --git a/apps/meteor/client/components/VirtualList/VirtualList.spec.tsx b/apps/meteor/client/components/VirtualList/VirtualList.spec.tsx index e7d7d81c9d2ee..332ed5ffb8776 100644 --- a/apps/meteor/client/components/VirtualList/VirtualList.spec.tsx +++ b/apps/meteor/client/components/VirtualList/VirtualList.spec.tsx @@ -1,10 +1,11 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import type { UseInfiniteQueryResult } from '@tanstack/react-query'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { axe } from 'jest-axe'; import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'; import * as React from 'react'; import { Children, forwardRef, isValidElement } from 'react'; -import VirtualList from './VirtualList'; +import PaginatedVirtualList from './VirtualList'; const mockVirtualizerHandle = { scrollToIndex: jest.fn(), @@ -62,6 +63,7 @@ jest.mock('@rocket.chat/ui-client', () => ({ { children, ...props }, ref, ) { + // eslint-disable-next-line testing-library/no-node-access const content = isValidElement<{ children?: ReactNode }>(children) && children.type === 'div' ? children.props.children : children; return ( @@ -81,38 +83,49 @@ const renderVirtualList = ( items: VirtualListTestItem[]; totalCount: number; renderItem: (item: VirtualListTestItem, index: number) => ReactNode; - estimateSize?: (index: number) => number; overscan?: number; - onEndReached?: () => void | Promise; + onEndReached?: UseInfiniteQueryResult['fetchNextPage']; }> = {}, -) => render(
{item._id}
} {...props} />); +) => render(
{item._id}
} {...props} />); -describe('VirtualList', () => { +const advanceDebouncedScroll = async () => { + await act(async () => { + await jest.advanceTimersByTimeAsync(300); + }); +}; + +describe('PaginatedVirtualList', () => { beforeEach(() => { mockVirtualizerHandle.scrollOffset = 0; mockVirtualizerHandle.scrollSize = 1000; mockVirtualizerHandle.viewportSize = 300; }); + afterEach(() => { + jest.useRealTimers(); + }); + it('has no accessibility violations', async () => { const { container } = renderVirtualList(); expect(await axe(container)).toHaveNoViolations(); }); - it('calls onEndReached when scrolled near the bottom', () => { - const onEndReached = jest.fn(); + it('calls onEndReached when scrolled near the bottom', async () => { + jest.useFakeTimers(); + const onEndReached = jest.fn().mockResolvedValue(undefined); renderVirtualList({ onEndReached }); expect(onEndReached).not.toHaveBeenCalled(); mockVirtualizerHandle.scrollOffset = 700; fireEvent.scroll(screen.getByTestId('virtual-list')); + await advanceDebouncedScroll(); expect(onEndReached).toHaveBeenCalledTimes(1); }); it('does not call onEndReached when all items are loaded', () => { - const onEndReached = jest.fn(); + const onEndReached = jest.fn().mockResolvedValue(undefined); renderVirtualList({ onEndReached, totalCount: items.length }); mockVirtualizerHandle.scrollOffset = 700; @@ -121,26 +134,33 @@ describe('VirtualList', () => { expect(onEndReached).not.toHaveBeenCalled(); }); - it('does not call onEndReached repeatedly for the same item count', () => { - const onEndReached = jest.fn(); + it('does not call onEndReached repeatedly for the same item count', async () => { + jest.useFakeTimers(); + const onEndReached = jest.fn().mockResolvedValue(undefined); renderVirtualList({ onEndReached }); mockVirtualizerHandle.scrollOffset = 700; fireEvent.scroll(screen.getByTestId('virtual-list')); fireEvent.scroll(screen.getByTestId('virtual-list')); + await advanceDebouncedScroll(); expect(onEndReached).toHaveBeenCalledTimes(1); }); - it('calls onEndReached after a same-size dataset reset', () => { - const onEndReached = jest.fn(); + it('calls onEndReached after a same-size dataset reset', async () => { + jest.useFakeTimers(); + const onEndReached = jest.fn().mockResolvedValue(undefined); const { rerender } = renderVirtualList({ onEndReached }); mockVirtualizerHandle.scrollOffset = 700; fireEvent.scroll(screen.getByTestId('virtual-list')); + await advanceDebouncedScroll(); const resetItems = Array.from({ length: 10 }, (_, index) => ({ _id: `reset-${index}` })); - rerender(
{item._id}
} onEndReached={onEndReached} />); + rerender( +
{item._id}
} onEndReached={onEndReached} />, + ); fireEvent.scroll(screen.getByTestId('virtual-list')); + await advanceDebouncedScroll(); expect(onEndReached).toHaveBeenCalledTimes(2); }); @@ -152,19 +172,22 @@ describe('VirtualList', () => { }); it('allows onEndReached to retry after a failed load', async () => { + jest.useFakeTimers(); const onEndReached = jest.fn().mockRejectedValue(new Error('failed to load more items')); renderVirtualList({ onEndReached }); mockVirtualizerHandle.scrollOffset = 700; fireEvent.scroll(screen.getByTestId('virtual-list')); - await Promise.resolve(); + await advanceDebouncedScroll(); fireEvent.scroll(screen.getByTestId('virtual-list')); + await advanceDebouncedScroll(); expect(onEndReached).toHaveBeenCalledTimes(2); }); - it('allows onEndReached to retry after a synchronous throw', () => { + it('allows onEndReached to retry after a synchronous throw', async () => { + jest.useFakeTimers(); const onEndReached = jest .fn() .mockImplementationOnce(() => { @@ -175,13 +198,15 @@ describe('VirtualList', () => { renderVirtualList({ onEndReached }); mockVirtualizerHandle.scrollOffset = 700; fireEvent.scroll(screen.getByTestId('virtual-list')); + await advanceDebouncedScroll(); fireEvent.scroll(screen.getByTestId('virtual-list')); + await advanceDebouncedScroll(); expect(onEndReached).toHaveBeenCalledTimes(2); }); it('calls onEndReached when the viewport is underfilled', () => { - const onEndReached = jest.fn(); + const onEndReached = jest.fn().mockResolvedValue(undefined); mockVirtualizerHandle.scrollSize = 200; renderVirtualList({ onEndReached }); diff --git a/apps/meteor/client/components/VirtualList/VirtualList.tsx b/apps/meteor/client/components/VirtualList/VirtualList.tsx index 0937632163455..e2275824f21a2 100644 --- a/apps/meteor/client/components/VirtualList/VirtualList.tsx +++ b/apps/meteor/client/components/VirtualList/VirtualList.tsx @@ -1,6 +1,8 @@ +import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; import { CustomVirtuaScrollbars } from '@rocket.chat/ui-client'; +import type { UseInfiniteQueryResult } from '@tanstack/react-query'; import type { ReactNode } from 'react'; -import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import { useCallback, useLayoutEffect, useRef } from 'react'; import type { VirtualizerHandle } from 'virtua'; import { Virtualizer } from 'virtua'; @@ -14,42 +16,34 @@ const scrollViewportStyle = { overflow: 'auto', } as const; -type OnEndReached = () => void | Promise; - -const isThenable = (value: unknown): value is PromiseLike => typeof (value as PromiseLike | null)?.then === 'function'; - -type VirtualListProps = { +type PaginatedVirtualListProps = { items: T[]; totalCount: number; renderItem: (item: T, index: number) => ReactNode; - estimateSize?: (index: number) => number; overscan?: number; - onEndReached?: OnEndReached; + onEndReached?: UseInfiniteQueryResult['fetchNextPage']; }; -function VirtualList({ +function PaginatedVirtualList({ items, totalCount, renderItem, - estimateSize = () => 120, overscan, onEndReached, -}: VirtualListProps) { +}: PaginatedVirtualListProps) { const virtualizerRef = useRef(null); - const onEndReachedRef = useRef(onEndReached); - const lastEndReachKeyRef = useRef(null); + const isEndReachedLockedRef = useRef(false); const firstItemId = items[0]?._id ?? ''; const lastItemId = items[items.length - 1]?._id ?? ''; - useEffect(() => { - onEndReachedRef.current = onEndReached; - }, [onEndReached]); + useLayoutEffect(() => { + isEndReachedLockedRef.current = false; + }, [firstItemId, items.length, lastItemId, totalCount]); const checkEndReached = useCallback( (offset: number) => { const handle = virtualizerRef.current; - const loadMore = onEndReachedRef.current; - if (!handle || !loadMore) { + if (!handle || !onEndReached) { return; } @@ -67,34 +61,27 @@ function VirtualList({ return; } - const key = `${firstItemId}:${lastItemId}:${items.length}:${totalCount}`; - if (lastEndReachKeyRef.current === key) { + if (isEndReachedLockedRef.current) { return; } - lastEndReachKeyRef.current = key; - - const releaseEndReachLock = () => { - if (lastEndReachKeyRef.current === key) { - lastEndReachKeyRef.current = null; - } - }; + isEndReachedLockedRef.current = true; try { - const result = loadMore(); - if (isThenable(result)) { - void Promise.resolve(result).catch(releaseEndReachLock); - } + void onEndReached().catch(() => { + isEndReachedLockedRef.current = false; + }); } catch { - releaseEndReachLock(); + isEndReachedLockedRef.current = false; } }, - [firstItemId, items.length, lastItemId, totalCount], + [items.length, onEndReached, totalCount], ); - const handleScroll = useCallback( + const handleScroll = useDebouncedCallback( (offset: number) => { checkEndReached(offset); }, + 300, [checkEndReached], ); @@ -104,16 +91,14 @@ function VirtualList({ return; } checkEndReached(handle.scrollOffset); - }, [checkEndReached, items.length, totalCount]); + }, [checkEndReached, firstItemId, items.length, lastItemId, totalCount]); return (
{items.map((item, index) => ( -
- {renderItem(item, index)} -
+
{renderItem(item, index)}
))}
@@ -121,4 +106,4 @@ function VirtualList({ ); } -export default VirtualList; +export default PaginatedVirtualList; diff --git a/apps/meteor/client/components/VirtualList/index.ts b/apps/meteor/client/components/VirtualList/index.ts index 147ca7fe723c0..4b475317de971 100644 --- a/apps/meteor/client/components/VirtualList/index.ts +++ b/apps/meteor/client/components/VirtualList/index.ts @@ -1 +1 @@ -export { default as VirtualList } from './VirtualList'; +export { default as PaginatedVirtualList } from './VirtualList'; diff --git a/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.spec.tsx b/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.spec.tsx index 4300778cf898a..92d28ff999dfa 100644 --- a/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.spec.tsx +++ b/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.spec.tsx @@ -68,6 +68,7 @@ jest.mock('@rocket.chat/ui-client', () => ({ { children, ...props }, ref, ) { + // eslint-disable-next-line testing-library/no-node-access const content = isValidElement<{ children?: ReactNode }>(children) && children.type === 'div' ? children.props.children : children; return ( diff --git a/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.stories.tsx b/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.stories.tsx index df2d0be0d1e01..30e64b52dc6d2 100644 --- a/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.stories.tsx +++ b/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.stories.tsx @@ -1,9 +1,16 @@ import { Contextualbar } from '@rocket.chat/ui-client'; import { action } from '@storybook/addon-actions'; import type { Meta, StoryFn } from '@storybook/react'; +import type { UseInfiniteQueryResult } from '@tanstack/react-query'; import DiscussionsList from './DiscussionsList'; +const loadMoreItemsAction = action('loadMoreItems'); +const loadMoreItems = (async (options) => { + loadMoreItemsAction(options); + return {} as Awaited>; +}) satisfies UseInfiniteQueryResult['fetchNextPage']; + export default { component: DiscussionsList, parameters: { @@ -13,7 +20,7 @@ export default { decorators: [(fn) => {fn()}], args: { text: '', - loadMoreItems: action('loadMoreItems'), + loadMoreItems, }, } satisfies Meta; diff --git a/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.tsx b/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.tsx index 4ef59565af2f1..bd6c2cba4d196 100644 --- a/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.tsx +++ b/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.tsx @@ -12,19 +12,20 @@ import { ContextualbarDialog, } from '@rocket.chat/ui-client'; import { useSetting } from '@rocket.chat/ui-contexts'; +import type { UseInfiniteQueryResult } from '@tanstack/react-query'; import type { ChangeEvent, MouseEvent, RefObject } from 'react'; import { useCallback, useId } from 'react'; import { useTranslation } from 'react-i18next'; import DiscussionsListRow from './DiscussionsListRow'; import ResultsLiveRegion from '../../../../components/ResultsLiveRegion'; -import { VirtualList } from '../../../../components/VirtualList'; +import { PaginatedVirtualList } from '../../../../components/VirtualList'; import { useGoToRoom } from '../../hooks/useGoToRoom'; type DiscussionsListProps = { itemCount: number; discussions: Array; - loadMoreItems: () => void; + loadMoreItems: UseInfiniteQueryResult['fetchNextPage']; isPending: boolean; isSuccess: boolean; onClose: () => void; @@ -95,7 +96,7 @@ function DiscussionsList({ {discussions.length === 0 && } {discussions.length > 0 && ( - { itemCount={itemCount} isPending={isPending} isSuccess={isSuccess} - loadMoreItems={() => fetchNextPage()} + loadMoreItems={fetchNextPage} text={text} onChangeFilter={handleTextChange} /> diff --git a/apps/meteor/client/views/room/contextualBar/Discussions/__snapshots__/DiscussionsList.spec.tsx.snap b/apps/meteor/client/views/room/contextualBar/Discussions/__snapshots__/DiscussionsList.spec.tsx.snap index a3a9046ce3631..add5b58b92ccb 100644 --- a/apps/meteor/client/views/room/contextualBar/Discussions/__snapshots__/DiscussionsList.spec.tsx.snap +++ b/apps/meteor/client/views/room/contextualBar/Discussions/__snapshots__/DiscussionsList.spec.tsx.snap @@ -102,9 +102,7 @@ exports[`renders Default with stable structure 1`] = ` style="margin: 0px; padding: 0px; list-style: none; height: 100%;" >
  • -
    +
  • -
    +
  • -
    +
  • -
    +
  • -
    +
  • -
    +
  • -
    +
  • -
    +
  • -
    +
  • -
    +