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:
@@ -8,7 +8,7 @@
|
||||
import { EventType, JoinRule, type MatrixEvent, type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
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 { DefaultTagID } from "../../../stores/room-list-v3/skip-list/tag";
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useRoomAvatarViewModel(room: Room): RoomAvatarViewState {
|
||||
const roomMember = useDmMember(room);
|
||||
const presence = usePresence(room, roomMember);
|
||||
const isPublic = useIsPublic(room);
|
||||
const isLowPriority = !!room.tags[DefaultTagID.LowPriority];
|
||||
const isLowPriority = useTypedEventEmitterState(room, RoomEvent.Tags, () => !!room.tags[DefaultTagID.LowPriority]);
|
||||
|
||||
let badgeDecoration: AvatarBadgeDecoration | undefined;
|
||||
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 { 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 BaseAvatar from "./BaseAvatar";
|
||||
@@ -21,6 +21,7 @@ import { useSettingValue } from "../../../hooks/useSettings";
|
||||
import { useRoomState } from "../../../hooks/useRoomState";
|
||||
import { useRoomIdName } from "../../../hooks/room/useRoomIdName";
|
||||
import { MediaPreviewValue } from "../../../@types/media_preview";
|
||||
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
|
||||
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick" | "size"> {
|
||||
// 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 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 roomIdName = useRoomIdName(room, oobData);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 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 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";
|
||||
@@ -33,7 +33,7 @@ interface RoomAvatarViewProps {
|
||||
* Component to display the avatar of a room.
|
||||
* 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);
|
||||
// No decoration, we just show the avatar
|
||||
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}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the decoration for the avatar based on the presence.
|
||||
|
||||
@@ -78,6 +78,12 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
private roomSkipList?: RoomSkipList;
|
||||
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) {
|
||||
super(dispatcher);
|
||||
this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
|
||||
@@ -243,7 +249,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
newMembership === EffectiveMembership.Leave
|
||||
) {
|
||||
this.roomSkipList.removeRoom(payload.room);
|
||||
this.emit(LISTS_UPDATE_EVENT);
|
||||
this.scheduleEmit();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -269,7 +275,8 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
case Action.AfterForgetRoom: {
|
||||
const room = payload.room;
|
||||
this.roomSkipList.removeRoom(room);
|
||||
this.emit(LISTS_UPDATE_EVENT);
|
||||
this.scheduleEmit();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -313,7 +320,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
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.
|
||||
* @param room The room to add to the skiplist
|
||||
@@ -366,13 +387,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
} else {
|
||||
this.roomSkipList.reInsertRoom(room);
|
||||
}
|
||||
this.emit(LISTS_UPDATE_EVENT);
|
||||
this.scheduleEmit();
|
||||
}
|
||||
|
||||
private onActiveSpaceChanged(): void {
|
||||
if (!this.roomSkipList) return;
|
||||
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 { NotificationStateEvents } from "../../stores/notifications/NotificationState";
|
||||
import { MessagePreviewStore } from "../../stores/message-preview";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
|
||||
import DMRoomMap from "../../utils/DMRoomMap";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
@@ -32,6 +31,7 @@ import { UIComponent } from "../../settings/UIFeature";
|
||||
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
|
||||
import { clearRoomNotification, setMarkedUnreadState } from "../../utils/notifications";
|
||||
import { tagRoom } from "../../utils/room/tagRoom";
|
||||
import { keepIfSame } from "../../utils/keepIfSame";
|
||||
import dispatcher from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||
@@ -69,8 +69,12 @@ export class RoomListItemViewModel
|
||||
// Subscribe to notification state changes for this room
|
||||
this.disposables.trackListener(this.notifState, NotificationStateEvents.Update, this.onNotificationChanged);
|
||||
|
||||
// Subscribe to message preview changes (will filter to this room)
|
||||
this.disposables.trackListener(MessagePreviewStore.instance, UPDATE_EVENT, this.onMessagePreviewChanged);
|
||||
// Subscribe to message preview changes for this specific room
|
||||
this.disposables.trackListener(
|
||||
MessagePreviewStore.instance,
|
||||
MessagePreviewStore.getPreviewChangedEventName(props.room),
|
||||
this.onMessagePreviewChanged,
|
||||
);
|
||||
|
||||
// Subscribe to settings changes for message preview toggle
|
||||
const settingsWatchRef = SettingsStore.watchSetting(
|
||||
@@ -163,8 +167,12 @@ export class RoomListItemViewModel
|
||||
*/
|
||||
private updateItem(): void {
|
||||
const newItem = RoomListItemViewModel.generateItemSync(this.props.room, this.props.client, this.notifState);
|
||||
// Preserve message preview - it's managed separately by loadAndSetMessagePreview
|
||||
this.snapshot.set({ ...newItem, messagePreview: this.snapshot.current.messagePreview });
|
||||
this.snapshot.merge({
|
||||
...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 {
|
||||
|
||||
@@ -452,8 +452,8 @@ export class RoomListViewModel
|
||||
isLoadingRooms,
|
||||
isRoomListEmpty,
|
||||
activeFilterId,
|
||||
roomListState,
|
||||
sections,
|
||||
roomListState: keepIfSame(this.snapshot.current.roomListState, roomListState),
|
||||
sections: keepIfSame(this.snapshot.current.sections, sections),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -6,7 +6,16 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
AvatarBadgeDecoration,
|
||||
@@ -78,6 +87,7 @@ describe("RoomAvatarViewModel", () => {
|
||||
|
||||
// 4. With presence, public room, video room and low priority, low priority takes precedence
|
||||
room.tags[DefaultTagID.LowPriority] = {};
|
||||
act(() => room.emit(RoomEvent.Tags, new MatrixEvent(), room));
|
||||
rerender(room);
|
||||
expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.LowPriority);
|
||||
});
|
||||
|
||||
@@ -43,6 +43,10 @@ describe("RoomListStoreV3", () => {
|
||||
}
|
||||
|
||||
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, "activeSpace", "get").mockImplementation(() => MetaSpace.Home);
|
||||
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 { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
|
||||
import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/message-preview";
|
||||
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
||||
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
|
||||
@@ -200,7 +199,7 @@ describe("RoomListItemViewModel", () => {
|
||||
text: "Updated message",
|
||||
} as MessagePreview);
|
||||
|
||||
MessagePreviewStore.instance.emit(UPDATE_EVENT);
|
||||
MessagePreviewStore.instance.emit(MessagePreviewStore.getPreviewChangedEventName(room));
|
||||
|
||||
await flushPromises();
|
||||
expect(viewModel.getSnapshot().messagePreview).toBe("Updated message");
|
||||
|
||||
@@ -180,19 +180,12 @@ export function useVirtualizedList<Item, Context>(
|
||||
/** Range of currently visible items in the viewport */
|
||||
const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined);
|
||||
/** Map from item keys to their indices in the items array */
|
||||
const [keyToIndexMap, setKeyToIndexMap] = useState<Map<string, number>>(new Map());
|
||||
/** Whether the list is currently focused */
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
|
||||
// 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);
|
||||
const keyToIndexMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
items.forEach((item, index) => map.set(getItemKey(item), index));
|
||||
return map;
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user