Skip to content

Commit 3cfba32

Browse files
authored
Room list: avoid excessive re-renders on room list store update or filter change (#32663)
* perf(room list): clear room list item vm only when changing space Clearing all the item vms at every room list change is causing massive re-render of all the room list items. References to the vms are already removed when out of view (see RoomListViewMode.updateVisibleRooms) and handled by GC. * perf(room list): avoid to re-render filters at every room list update RoomListView re-renders on update but the filters (children) don't need to. Add a memo to avoid excessive-rerenders. * chore: add `keepIfSame` to do diff on vm objects * perf(room list): avoid to create new filter ids and keys when the room list is updated The filterKeys are passed in the virtuoso context so it should reduce internal computing The filter ids array has always the same value, there is no point to create a new instance. * test(room list): remove no longer relevant test * test(room list): add new test to ensure that room list item vms are preserved
1 parent 15530ef commit 3cfba32

File tree

5 files changed

+71
-31
lines changed

5 files changed

+71
-31
lines changed

apps/web/src/utils/keepIfSame.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Copyright 2026 Element Creations Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { isEqual } from "lodash";
9+
10+
/**
11+
* Returns the current value if it is deeply equal to the next value, otherwise returns the next value.
12+
* This is useful to prevent unnecessary re-renders in React components when the value has not changed.
13+
* @param current The current value
14+
* @param next The next value
15+
* @returns The current value if it is deeply equal to the next value, otherwise the next value
16+
*/
17+
export function keepIfSame<T>(current: T, next: T): T {
18+
if (isEqual(current, next)) return current;
19+
return next;
20+
}

apps/web/src/viewmodels/room-list/RoomListViewViewModel.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotif
2525
import { RoomListItemViewModel } from "./RoomListItemViewModel";
2626
import { SdkContextClass } from "../../contexts/SDKContext";
2727
import { hasCreateRoomRights } from "./utils";
28+
import { keepIfSame } from "../../utils/keepIfSame";
2829

2930
interface RoomListViewViewModelProps {
3031
client: MatrixClient;
@@ -133,9 +134,6 @@ export class RoomListViewViewModel
133134
// Update roomsMap immediately before clearing VMs
134135
this.updateRoomsMap(this.roomsResult);
135136

136-
// Clear view models since room list changed
137-
this.clearViewModels();
138-
139137
this.updateRoomListData();
140138
};
141139

@@ -291,11 +289,13 @@ export class RoomListViewViewModel
291289

292290
const newSpaceId = this.roomsResult.spaceId;
293291

294-
// Clear view models since room list structure changed
295-
this.clearViewModels();
296-
297292
// Detect space change
298293
if (oldSpaceId !== newSpaceId) {
294+
// Clear view models when the space changes
295+
// We only want to do this on space changes, not on regular list updates, to preserve view models when possible
296+
// The view models are disposed when scrolling out of view (handled by updateVisibleRooms)
297+
this.clearViewModels();
298+
299299
// Space changed - get the last selected room for the new space to prevent flicker
300300
const lastSelectedRoom = SpaceStore.instance.getLastSelectedRoomIdForSpace(newSpaceId);
301301

@@ -408,13 +408,16 @@ export class RoomListViewViewModel
408408
// Build the complete state atomically to ensure consistency
409409
// roomIds and roomListState must always be in sync
410410
const roomIds = this.roomIds;
411+
412+
// Update filter keys - only update if they have actually changed to prevent unnecessary re-renders of the room list
413+
const previousFilterKeys = this.snapshot.current.roomListState.filterKeys;
414+
const newFilterKeys = this.roomsResult.filterKeys?.map((k) => String(k));
411415
const roomListState: RoomListViewState = {
412416
activeRoomIndex,
413417
spaceId: this.roomsResult.spaceId,
414-
filterKeys: this.roomsResult.filterKeys?.map((k) => String(k)),
418+
filterKeys: keepIfSame(previousFilterKeys, newFilterKeys),
415419
};
416420

417-
const filterIds = [...filterKeyToIdMap.values()];
418421
const activeFilterId = this.activeFilter !== undefined ? filterKeyToIdMap.get(this.activeFilter) : undefined;
419422
const isRoomListEmpty = roomIds.length === 0;
420423
const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms;
@@ -423,7 +426,6 @@ export class RoomListViewViewModel
423426
this.snapshot.merge({
424427
isLoadingRooms,
425428
isRoomListEmpty,
426-
filterIds,
427429
activeFilterId,
428430
roomListState,
429431
roomIds,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Copyright 2026 Element Creations Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { keepIfSame } from "../../../src/utils/keepIfSame";
9+
10+
describe("keepIfSame", () => {
11+
it("returns the next value if the current and next values are not deeply equal", () => {
12+
const current = { a: 1 };
13+
const next = { a: 2 };
14+
expect(keepIfSame(current, next)).toBe(next);
15+
});
16+
17+
it("returns the current value if the current and next values are deeply equal", () => {
18+
const current = { a: 1 };
19+
const next = { a: 1 };
20+
expect(keepIfSame(current, next)).toBe(current);
21+
});
22+
});

apps/web/test/viewmodels/room-list/RoomListViewViewModel-test.tsx

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,20 @@ describe("RoomListViewViewModel", () => {
124124

125125
expect(viewModel.getSnapshot().isLoadingRooms).toBe(false);
126126
});
127+
128+
// This test ensures that the room list item vms are preserved when the room list is changing
129+
it("should keep existing view model when ListsUpdate event fires", () => {
130+
viewModel = new RoomListViewViewModel({ client: matrixClient });
131+
132+
// Create view model for room1
133+
const room1VM = viewModel.getRoomItemViewModel("!room1:server");
134+
expect(room1VM).toBeDefined();
135+
136+
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
137+
138+
// View model should be still valid
139+
expect(room1VM.isDisposed).toBe(false);
140+
});
127141
});
128142

129143
describe("Space switching", () => {
@@ -295,24 +309,6 @@ describe("RoomListViewViewModel", () => {
295309
expect(viewModel.getSnapshot().activeFilterId).toBeUndefined();
296310
expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]);
297311
});
298-
299-
it("should clear view models when filter changes", () => {
300-
viewModel = new RoomListViewViewModel({ client: matrixClient });
301-
302-
// Get view models
303-
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
304-
const disposeSpy = jest.spyOn(vm1, "dispose");
305-
306-
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
307-
spaceId: "home",
308-
rooms: [room2],
309-
filterKeys: [FilterKey.UnreadFilter],
310-
});
311-
312-
viewModel.onToggleFilter("unread");
313-
314-
expect(disposeSpy).toHaveBeenCalled();
315-
});
316312
});
317313

318314
describe("Room item view models", () => {

packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Please see LICENSE files in the repository root for full details.
66
*/
77

8-
import React, { type JSX, useId, useState } from "react";
8+
import React, { type JSX, memo, useId, useState } from "react";
99
import { ChatFilter, IconButton } from "@vector-im/compound-web";
1010
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
1111

@@ -53,11 +53,11 @@ export interface RoomListPrimaryFiltersProps {
5353
* The primary filters component for the room list.
5454
* Displays a collapsible list of filters with expand/collapse functionality.
5555
*/
56-
export const RoomListPrimaryFilters: React.FC<RoomListPrimaryFiltersProps> = ({
56+
export const RoomListPrimaryFilters = memo(function RoomListPrimaryFilters({
5757
filterIds,
5858
activeFilterId,
5959
onToggleFilter,
60-
}): JSX.Element | null => {
60+
}: RoomListPrimaryFiltersProps): JSX.Element | null {
6161
const id = useId();
6262
const [isExpanded, setIsExpanded] = useState(false);
6363

@@ -113,4 +113,4 @@ export const RoomListPrimaryFilters: React.FC<RoomListPrimaryFiltersProps> = ({
113113
</Flex>
114114
</Flex>
115115
);
116-
};
116+
});

0 commit comments

Comments
 (0)