fix(room list): fix visible-range reporting to the room list view model (#34112)

Virtuoso's rangeChanged was forwarded to updateVisibleRooms unmapped:

- In grouped mode the range is in entry space (one slot per section
  header), while updateVisibleRooms slices the rooms-only array. The
  retained window was shifted down by the number of headers above it,
  so the view models of the top rendered rooms were disposed and
  recreated on every range change.
- Virtuoso's endIndex is inclusive but slice() is end-exclusive, so
  the boundary row's view model was disposed and recreated on nearly
  every range change.
This commit is contained in:
Florian Duros
2026-07-03 19:18:21 +00:00
committed by GitHub
parent c571d79ed9
commit 50c26b4809
4 changed files with 77 additions and 4 deletions
@@ -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);
@@ -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
@@ -67,6 +67,27 @@ describe("<VirtualizedRoomListView />", () => {
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(<Default />);
// 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(<Sections />);
// 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
@@ -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,