RoomList: improve performance (#32919)

* perf: add memo to room avatar view

* perf: batch rlsV3 emit

* perf: avoid to re-render the room list if the room list state and sections are same

* perf: listen only message preview of the specific room

* perf: avoid to re-render the room list item if the notification or the content is same

* chore: replace useState and useEffect by useMemo in virtualized list

* fix: listen to room name event in RoomAvatar

* fix: room avatar re-render when room is low priority
This commit is contained in:
Florian Duros
2026-03-25 20:52:45 +01:00
committed by GitHub
parent b90a32bea4
commit 441b292353
10 changed files with 71 additions and 34 deletions
@@ -8,7 +8,7 @@
import { EventType, JoinRule, type MatrixEvent, type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { EventType, JoinRule, type MatrixEvent, type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import { useTypedEventEmitter, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import { useDmMember, usePresence, type Presence } from "../../views/avatars/WithPresenceIndicator"; import { useDmMember, usePresence, type Presence } from "../../views/avatars/WithPresenceIndicator";
import { DefaultTagID } from "../../../stores/room-list-v3/skip-list/tag"; import { DefaultTagID } from "../../../stores/room-list-v3/skip-list/tag";
@@ -41,7 +41,7 @@ export function useRoomAvatarViewModel(room: Room): RoomAvatarViewState {
const roomMember = useDmMember(room); const roomMember = useDmMember(room);
const presence = usePresence(room, roomMember); const presence = usePresence(room, roomMember);
const isPublic = useIsPublic(room); const isPublic = useIsPublic(room);
const isLowPriority = !!room.tags[DefaultTagID.LowPriority]; const isLowPriority = useTypedEventEmitterState(room, RoomEvent.Tags, () => !!room.tags[DefaultTagID.LowPriority]);
let badgeDecoration: AvatarBadgeDecoration | undefined; let badgeDecoration: AvatarBadgeDecoration | undefined;
if (isLowPriority) { if (isLowPriority) {
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/ */
import React, { useCallback, useMemo, type ComponentProps } from "react"; import React, { useCallback, useMemo, type ComponentProps } from "react";
import { type Room, RoomType, KnownMembership, EventType } from "matrix-js-sdk/src/matrix"; import { type Room, RoomType, KnownMembership, EventType, RoomEvent } from "matrix-js-sdk/src/matrix";
import { type RoomAvatarEventContent } from "matrix-js-sdk/src/types"; import { type RoomAvatarEventContent } from "matrix-js-sdk/src/types";
import BaseAvatar from "./BaseAvatar"; import BaseAvatar from "./BaseAvatar";
@@ -21,6 +21,7 @@ import { useSettingValue } from "../../../hooks/useSettings";
import { useRoomState } from "../../../hooks/useRoomState"; import { useRoomState } from "../../../hooks/useRoomState";
import { useRoomIdName } from "../../../hooks/room/useRoomIdName"; import { useRoomIdName } from "../../../hooks/room/useRoomIdName";
import { MediaPreviewValue } from "../../../@types/media_preview"; import { MediaPreviewValue } from "../../../@types/media_preview";
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick" | "size"> { interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick" | "size"> {
// Room may be left unset here, but if it is, // Room may be left unset here, but if it is,
@@ -37,7 +38,8 @@ interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idNam
} }
const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobData, size = "36px", ...otherProps }) => { const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobData, size = "36px", ...otherProps }) => {
const roomName = room?.name ?? oobData?.name ?? "?"; const name = useTypedEventEmitterState(room, RoomEvent.Name, () => room?.name);
const roomName = name ?? oobData?.name ?? "?";
const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, "")); const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, ""));
const roomIdName = useRoomIdName(room, oobData); const roomIdName = useRoomIdName(room, oobData);
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX } from "react"; import React, { memo, type JSX } from "react";
import { type Room } from "matrix-js-sdk/src/matrix"; import { type Room } from "matrix-js-sdk/src/matrix";
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public"; import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
import VideoIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid"; import VideoIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
@@ -33,7 +33,7 @@ interface RoomAvatarViewProps {
* Component to display the avatar of a room. * Component to display the avatar of a room.
* Currently only 32px size is supported. * Currently only 32px size is supported.
*/ */
export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element { export const RoomAvatarView = memo(function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
const vm = useRoomAvatarViewModel(room); const vm = useRoomAvatarViewModel(room);
// No decoration, we just show the avatar // No decoration, we just show the avatar
if (!vm.badgeDecoration) return <RoomAvatar size="32px" room={room} />; if (!vm.badgeDecoration) return <RoomAvatar size="32px" room={room} />;
@@ -54,7 +54,7 @@ export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
{label ? <Tooltip label={label}>{icon}</Tooltip> : icon} {label ? <Tooltip label={label}>{icon}</Tooltip> : icon}
</Flex> </Flex>
); );
} });
/** /**
* Get the decoration for the avatar based on the presence. * Get the decoration for the avatar based on the presence.
@@ -78,6 +78,12 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
private roomSkipList?: RoomSkipList; private roomSkipList?: RoomSkipList;
private readonly msc3946ProcessDynamicPredecessor: boolean; private readonly msc3946ProcessDynamicPredecessor: boolean;
/**
* Whether a batched LISTS_UPDATE_EVENT emission is pending.
* Used by {@link scheduleEmit} to coalesce rapid-fire updates into a single emit per frame.
*/
private pendingEmit = false;
public constructor(dispatcher: MatrixDispatcher) { public constructor(dispatcher: MatrixDispatcher) {
super(dispatcher); super(dispatcher);
this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
@@ -243,7 +249,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
newMembership === EffectiveMembership.Leave newMembership === EffectiveMembership.Leave
) { ) {
this.roomSkipList.removeRoom(payload.room); this.roomSkipList.removeRoom(payload.room);
this.emit(LISTS_UPDATE_EVENT); this.scheduleEmit();
return; return;
} }
@@ -269,7 +275,8 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
case Action.AfterForgetRoom: { case Action.AfterForgetRoom: {
const room = payload.room; const room = payload.room;
this.roomSkipList.removeRoom(room); this.roomSkipList.removeRoom(room);
this.emit(LISTS_UPDATE_EVENT); this.scheduleEmit();
break; break;
} }
} }
@@ -313,7 +320,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
break; break;
} }
} }
if (needsEmit) this.emit(LISTS_UPDATE_EVENT); if (needsEmit) this.scheduleEmit();
} }
/** /**
@@ -348,6 +355,20 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
} }
} }
/**
* Schedule a batched emission of LISTS_UPDATE_EVENT using requestAnimationFrame.
* Multiple calls within the same frame are coalesced into a single emit.
*/
private scheduleEmit(): void {
if (!this.pendingEmit) {
this.pendingEmit = true;
requestAnimationFrame(() => {
this.pendingEmit = false;
this.emit(LISTS_UPDATE_EVENT);
});
}
}
/** /**
* Add a room to the skiplist and emit an update. * Add a room to the skiplist and emit an update.
* @param room The room to add to the skiplist * @param room The room to add to the skiplist
@@ -366,13 +387,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
} else { } else {
this.roomSkipList.reInsertRoom(room); this.roomSkipList.reInsertRoom(room);
} }
this.emit(LISTS_UPDATE_EVENT); this.scheduleEmit();
} }
private onActiveSpaceChanged(): void { private onActiveSpaceChanged(): void {
if (!this.roomSkipList) return; if (!this.roomSkipList) return;
this.roomSkipList.calculateActiveSpaceForNodes(); this.roomSkipList.calculateActiveSpaceForNodes();
this.emit(LISTS_UPDATE_EVENT); this.scheduleEmit();
} }
} }
@@ -19,7 +19,6 @@ import type { RoomNotificationState } from "../../stores/notifications/RoomNotif
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { NotificationStateEvents } from "../../stores/notifications/NotificationState"; import { NotificationStateEvents } from "../../stores/notifications/NotificationState";
import { MessagePreviewStore } from "../../stores/message-preview"; import { MessagePreviewStore } from "../../stores/message-preview";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag"; import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
import DMRoomMap from "../../utils/DMRoomMap"; import DMRoomMap from "../../utils/DMRoomMap";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
@@ -32,6 +31,7 @@ import { UIComponent } from "../../settings/UIFeature";
import { CallStore, CallStoreEvent } from "../../stores/CallStore"; import { CallStore, CallStoreEvent } from "../../stores/CallStore";
import { clearRoomNotification, setMarkedUnreadState } from "../../utils/notifications"; import { clearRoomNotification, setMarkedUnreadState } from "../../utils/notifications";
import { tagRoom } from "../../utils/room/tagRoom"; import { tagRoom } from "../../utils/room/tagRoom";
import { keepIfSame } from "../../utils/keepIfSame";
import dispatcher from "../../dispatcher/dispatcher"; import dispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
@@ -69,8 +69,12 @@ export class RoomListItemViewModel
// Subscribe to notification state changes for this room // Subscribe to notification state changes for this room
this.disposables.trackListener(this.notifState, NotificationStateEvents.Update, this.onNotificationChanged); this.disposables.trackListener(this.notifState, NotificationStateEvents.Update, this.onNotificationChanged);
// Subscribe to message preview changes (will filter to this room) // Subscribe to message preview changes for this specific room
this.disposables.trackListener(MessagePreviewStore.instance, UPDATE_EVENT, this.onMessagePreviewChanged); this.disposables.trackListener(
MessagePreviewStore.instance,
MessagePreviewStore.getPreviewChangedEventName(props.room),
this.onMessagePreviewChanged,
);
// Subscribe to settings changes for message preview toggle // Subscribe to settings changes for message preview toggle
const settingsWatchRef = SettingsStore.watchSetting( const settingsWatchRef = SettingsStore.watchSetting(
@@ -163,8 +167,12 @@ export class RoomListItemViewModel
*/ */
private updateItem(): void { private updateItem(): void {
const newItem = RoomListItemViewModel.generateItemSync(this.props.room, this.props.client, this.notifState); const newItem = RoomListItemViewModel.generateItemSync(this.props.room, this.props.client, this.notifState);
// Preserve message preview - it's managed separately by loadAndSetMessagePreview this.snapshot.merge({
this.snapshot.set({ ...newItem, messagePreview: this.snapshot.current.messagePreview }); ...newItem,
notification: keepIfSame(this.snapshot.current.notification, newItem.notification),
// Preserve message preview - it's managed separately by loadAndSetMessagePreview
messagePreview: this.snapshot.current.messagePreview,
});
} }
private getMessagePreviewTag(): string { private getMessagePreviewTag(): string {
@@ -452,8 +452,8 @@ export class RoomListViewModel
isLoadingRooms, isLoadingRooms,
isRoomListEmpty, isRoomListEmpty,
activeFilterId, activeFilterId,
roomListState, roomListState: keepIfSame(this.snapshot.current.roomListState, roomListState),
sections, sections: keepIfSame(this.snapshot.current.sections, sections),
}); });
} }
@@ -6,7 +6,16 @@
*/ */
import { renderHook, waitFor } from "jest-matrix-react"; import { renderHook, waitFor } from "jest-matrix-react";
import { JoinRule, type MatrixClient, type Room, RoomMember, User } from "matrix-js-sdk/src/matrix"; import {
JoinRule,
type MatrixClient,
MatrixEvent,
type Room,
RoomEvent,
RoomMember,
User,
} from "matrix-js-sdk/src/matrix";
import { act } from "react";
import { import {
AvatarBadgeDecoration, AvatarBadgeDecoration,
@@ -78,6 +87,7 @@ describe("RoomAvatarViewModel", () => {
// 4. With presence, public room, video room and low priority, low priority takes precedence // 4. With presence, public room, video room and low priority, low priority takes precedence
room.tags[DefaultTagID.LowPriority] = {}; room.tags[DefaultTagID.LowPriority] = {};
act(() => room.emit(RoomEvent.Tags, new MatrixEvent(), room));
rerender(room); rerender(room);
expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.LowPriority); expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.LowPriority);
}); });
@@ -43,6 +43,10 @@ describe("RoomListStoreV3", () => {
} }
beforeEach(() => { beforeEach(() => {
jest.spyOn(global, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
cb(0);
return 0;
});
jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space) => space === MetaSpace.Home); jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space) => space === MetaSpace.Home);
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => MetaSpace.Home); jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => MetaSpace.Home);
jest.spyOn(SpaceStore.instance, "storeReadyPromise", "get").mockImplementation(() => Promise.resolve()); jest.spyOn(SpaceStore.instance, "storeReadyPromise", "get").mockImplementation(() => Promise.resolve());
@@ -20,7 +20,6 @@ import { RoomNotificationState } from "../../../src/stores/notifications/RoomNot
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore"; import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState"; import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/message-preview"; import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/message-preview";
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
import SettingsStore from "../../../src/settings/SettingsStore"; import SettingsStore from "../../../src/settings/SettingsStore";
import DMRoomMap from "../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../src/utils/DMRoomMap";
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag"; import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
@@ -200,7 +199,7 @@ describe("RoomListItemViewModel", () => {
text: "Updated message", text: "Updated message",
} as MessagePreview); } as MessagePreview);
MessagePreviewStore.instance.emit(UPDATE_EVENT); MessagePreviewStore.instance.emit(MessagePreviewStore.getPreviewChangedEventName(room));
await flushPromises(); await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBe("Updated message"); expect(viewModel.getSnapshot().messagePreview).toBe("Updated message");
@@ -180,19 +180,12 @@ export function useVirtualizedList<Item, Context>(
/** Range of currently visible items in the viewport */ /** Range of currently visible items in the viewport */
const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined); const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined);
/** Map from item keys to their indices in the items array */ /** Map from item keys to their indices in the items array */
const [keyToIndexMap, setKeyToIndexMap] = useState<Map<string, number>>(new Map()); const keyToIndexMap = useMemo(() => {
/** Whether the list is currently focused */ const map = new Map<string, number>();
const [isFocused, setIsFocused] = useState<boolean>(false); items.forEach((item, index) => map.set(getItemKey(item), index));
return map;
// Update the key-to-index mapping whenever items change
useEffect(() => {
const newKeyToIndexMap = new Map<string, number>();
items.forEach((item, index) => {
const key = getItemKey(item);
newKeyToIndexMap.set(key, index);
});
setKeyToIndexMap(newKeyToIndexMap);
}, [items, getItemKey]); }, [items, getItemKey]);
const [isFocused, setIsFocused] = useState<boolean>(false);
// Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed // Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed
useEffect(() => { useEffect(() => {