diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts
index 540d03c160..01f8cd92f5 100644
--- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts
+++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts
@@ -332,10 +332,13 @@ export class RoomListViewModel
* Update which rooms are currently visible.
* Called by the view when scroll position changes.
* Disposes of view models for rooms no longer visible.
+ *
+ * Indices are in room-index space (section header entries excluded):
+ * startIndex is inclusive, endIndex is exclusive.
*/
public updateVisibleRooms(startIndex: number, endIndex: number): void {
const allRoomIds = this.roomIds;
- const newVisibleIds = allRoomIds.slice(startIndex, Math.min(endIndex, allRoomIds.length));
+ const newVisibleIds = allRoomIds.slice(startIndex, endIndex);
const newVisibleSet = new Set(newVisibleIds);
diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx
index 6de5ab2f29..e515197ed3 100644
--- a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx
+++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx
@@ -73,7 +73,10 @@ export interface RoomListViewActions {
* Allow undefined to be returned if we don't have a view model for the room. In this case the room will not be rendered.
*/
getRoomItemViewModel: (roomId: string) => RoomListItemViewModel | undefined;
- /** Called when the visible range changes (virtualization API) */
+ /**
+ * Called when the rendered range changes (virtualization API). Indices are in room-index
+ * space (section header entries excluded): startIndex is inclusive, endIndex exclusive.
+ */
updateVisibleRooms: (startIndex: number, endIndex: number) => void;
/**
* Called when the last genuinely-visible item index changes (excluding the rendered
diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx
index d1a2fc6f53..7ca3dd8e86 100644
--- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx
+++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx
@@ -67,6 +67,27 @@ describe("", () => {
expect(Default.args.updateVisibleRooms).toHaveBeenCalled();
});
+ describe("updateVisibleRooms range reporting", () => {
+ beforeEach(() => {
+ (Default.args.updateVisibleRooms as any).mockClear?.();
+ (Sections.args.updateVisibleRooms as any).mockClear?.();
+ });
+
+ it("reports an exclusive end bound in flat mode", () => {
+ renderWithMockContext();
+ // 10 rooms, all rendered by the mock viewport: Virtuoso reports the inclusive
+ // range [0, 9], which must reach the view model as the exclusive window [0, 10).
+ expect(Default.args.updateVisibleRooms).toHaveBeenLastCalledWith(0, 10);
+ });
+
+ it("maps entry-space indices to room indices in grouped mode", () => {
+ renderWithMockContext();
+ // 13 entries (3 section headers + 10 rooms) are all rendered: Virtuoso reports the
+ // inclusive entry range [0, 12], which must map back to the room window [0, 10).
+ expect(Sections.args.updateVisibleRooms).toHaveBeenLastCalledWith(0, 10);
+ });
+ });
+
describe("drag and drop", () => {
beforeEach(() => {
// Storybook fn() spies are shared across tests; vi.clearAllMocks() may not
diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx
index 5641b652e0..f54323d1a5 100644
--- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx
+++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx
@@ -241,17 +241,63 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
[sections],
);
+ // In a grouped list, Virtuoso's range counts section headers as entries, so entry indices
+ // don't match room indices. This maps an inclusive entry range to a [start, end) room-index
+ // range. A header entry maps to the section boundary: "from this section's first room" as a
+ // start, "up to the previous section's last room" as an end.
+ const mapEntryRangeToRoomRange = useCallback(
+ (startEntry: number, endEntry: number): [start: number, endExclusive: number] => {
+ let start: number | undefined;
+ let end: number | undefined;
+ let headerEntry = 0; // entry index of the current section's header
+ let roomsBefore = 0; // number of rooms in the sections above the current one
+ for (const section of sections) {
+ // Last entry of this section: the header entry followed by one entry per room
+ const lastEntry = headerEntry + section.roomIds.length;
+
+ // The range starts in this section: on the header (-1 clamped to the first room)
+ // or on one of its rooms
+ if (start === undefined && startEntry <= lastEntry) {
+ start = roomsBefore + Math.max(0, startEntry - headerEntry - 1);
+ }
+
+ // The range ends in this section: on the header (0 rooms of this section
+ // included) or on one of its rooms (exclusive bound, hence no -1)
+ if (end === undefined && endEntry <= lastEntry) {
+ end = roomsBefore + Math.max(0, endEntry - headerEntry);
+ }
+
+ // Both bounds found, no need to look at the remaining sections
+ if (start !== undefined && end !== undefined) break;
+
+ // Move to the next section
+ headerEntry = lastEntry + 1;
+ roomsBefore += section.roomIds.length;
+ }
+ // The range can transiently point past the sections when the list shrinks before
+ // Virtuoso reports the new range; fall back to the widest valid window.
+ return [start ?? 0, end ?? roomsBefore];
+ },
+ [sections],
+ );
+
/**
* Callback when the visible range changes
* Notifies the view model which rooms are visible
*/
const rangeChanged = useCallback(
(range: { startIndex: number; endIndex: number }) => {
- vm.updateVisibleRooms(range.startIndex, range.endIndex);
+ // Virtuoso's endIndex is inclusive; updateVisibleRooms takes an exclusive end.
+ if (isFlatList) {
+ vm.updateVisibleRooms(range.startIndex, range.endIndex + 1);
+ } else {
+ const [start, end] = mapEntryRangeToRoomRange(range.startIndex, range.endIndex);
+ vm.updateVisibleRooms(start, end);
+ }
// The rendered set changed; (un)observe items so the fold stays accurate.
scheduleSyncObservedItems();
},
- [vm, scheduleSyncObservedItems],
+ [vm, scheduleSyncObservedItems, isFlatList, mapEntryRangeToRoomRange],
);
// Builds the accessibility plugin (live-region announcements) for keyboard/pointer drags,