Virtualized List Rendering#
Element Web uses a layered virtualization stackβbuilt on react-virtuosoβto efficiently render large room and member lists. The primitives live in packages/shared-components/src/core/VirtualizedList/ and are consumed by VirtualizedRoomListView in packages/shared-components/src/room-list/VirtualizedRoomListView/ .
Scope note: The shared components described here power the new room list (Element Web). The legacy
MemberListcomponent inmatrix-react-sdkuses a separate, simpler approach covered in the Legacy section below.
Core Abstractions#
| Component / Hook | Path | Purpose |
|---|---|---|
useVirtualizedList | virtualized-list.ts | Keyboard navigation, focus management, range tracking |
FlatVirtualizedList | FlatVirtualizedList/ | Generic flat virtualized list (wraps useVirtualizedList) |
GroupedVirtualizedList | GroupedVirtualizedList/ | Sectioned list with sticky headers and pinned overlay |
accessbility.ts | accessbility.ts | ARIA helpers: listbox (flat) and treegrid (grouped) patterns |
VirtualizedRoomListView | VirtualizedRoomListView.tsx | Room list integration: drag-and-drop, overscan, range callbacks |
FlatVirtualizedList and GroupedVirtualizedList were split from the original ListView / VirtualizedList in PR #31860 and PR #32566 respectively.
Overscan / Viewport Extension#
To avoid blank "black spots" during fast scroll, both lists extend their rendered viewport beyond the visible area via Virtuoso's increaseViewportBy prop :
- Room list:
EXTENDED_VIEWPORT_HEIGHT = 25 Γ ROOM_LIST_ITEM_HEIGHT (52px) = 1300pxabove and below β introduced in PR #30867. - Member list (legacy
MemberListView):EXTENDED_VIEWPORT_HEIGHT = 15 Γ MEMBER_LIST_ITEM_HEIGHT (56px) = 840pxβ introduced in PR #31198.
The overscan buffer eliminates most blank-space artifacts at moderate scroll speeds; very fast "sling" scrolling can still reveal gaps .
Range Mapping#
In grouped mode, Virtuoso counts section headers as list entries, so its rangeChanged indices are in entry space, not room space. VirtualizedRoomListView maps between the two via mapEntryRangeToRoomRange() before calling vm.updateVisibleRooms() . Without this conversion, nearly every scroll event incorrectly destroyed and recreated room view models β the bug fixed in PR #34112.
Similarly, useVirtualizedList exposes two optional props for callers to declare their own index-space translation :
mapScrollIndexβ converts an items-array index to Virtuoso's scroll index (used byscrollToIndex)mapRangeIndexβ converts Virtuoso's reported range index back to items-array index (applied inhandleRangeChanged)
Touch Gesture Handling#
The DragDropProvider (via @dnd-kit/react) wraps the grouped room list. The PointerSensor uses device-aware activation constraints :
- Touch (
pointerType === "touch"):Delay({ value: 250, tolerance: 5 })β drag starts only after a 250ms hold; any earlier finger movement cancels the drag, allowing normal scroll. - Mouse / pen:
Distance({ value: 5 })β drag starts after a 5px movement.
This resolved issues #34372 and #34402 where touch-scrolling the room list opened context menus or failed entirely .
Grouped List: Pinned Headers#
GroupedVirtualizedList renders section headers in two layers :
- Real headers inside the virtualized stream (CSS
position: sticky) β accessible and focusable. - Sticky overlay rendered outside the stream (
renderStickyHeaderprop) β stays visible when the real header scrolls out of the virtualized render window.
An updateSticky callback tracks which group is topmost by reading scrollTop and comparing rendered item offsets; getScrollPaddingTop returns 0 for header items (so they land flush at the top) and the header height for room items (to clear the overlay) during keyboard navigation .
Items and headers are interleaved into a single NavigationEntry<Header, Item> discriminated-union array for unified keyboard navigation .
Legacy: TruncatedList + MemberList#
Before the new virtualized stack, the legacy MemberList component (in matrix-react-sdk) used a simpler approach:
TruncatedList(src/components/views/elements/TruncatedList.tsx) renders up totruncateAtchildren and callscreateOverflowElementwith a count of hidden items. It does not virtualize β it simply slices the child array .MemberListstarts withtruncateAtJoined = 30andtruncateAtInvited = 5, expanding by 100 on each "show more" click . It delegates actual member loading and sorting toMemberListStore.
MemberListStore (src/stores/MemberListStore.ts) handles lazy loading:
- If lazy loading is disabled or the room is already loaded (
loadedRoomsset), members are read directly fromroom.currentState. - Otherwise it calls
room.loadMembersIfNeeded()(with storage) or fetches from/membersdirectly (Sliding Sync / incognito mode) . - Once loaded, the room ID is cached in
loadedRoomsto prevent redundant network requests . - Members are sorted by: presence β power level β last active timestamp β display name .