From 4708a1796103515cce2c5587aaabb1e65a11f943 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 18 Jul 2026 21:05:39 -0400 Subject: [PATCH] perf: memoize room-list sorts + gate DM-preview listener (PERF-2/4/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PERF-2 (RoomMentionAutocomplete): the #-mention list did `useAtomValue(allRoomsAtom).sort(...)` inline — `.sort()` MUTATED the shared allRoomsAtom array in place (reordering it for ~27 other consumers) and re-ran the O(N log N) getRoom compare every keystroke. Copy then memoize: `useMemo(() => [...allRoomsList].sort(factoryRoomIdByActivity(mx)), ...)`. PERF-4 (SearchFilters): the room-filter A-Z sort ran every render; wrap in useMemo keyed on [searchResult, roomList, mx]. PERF-5 (useRoomLatestRenderedEvent + RoomNavItem): the hook registered a GLOBAL client `Decrypted` listener for every nav item, but its result is only used for DM rows. Add an `enabled` param (default true) that skips all work + listeners when false; RoomNavItem passes `!!direct`. The only other caller keeps the default. Verified behavior-preserving by two review passes (PERF-2 also fixes a real shared-atom mutation bug). Co-Authored-By: Claude Opus 4.8 --- .../editor/autocomplete/RoomMentionAutocomplete.tsx | 11 +++++++++-- src/app/features/message-search/SearchFilters.tsx | 5 ++++- src/app/features/room-nav/RoomNavItem.tsx | 4 +++- src/app/hooks/useRoomLatestRenderedEvent.ts | 13 +++++++++++-- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index 9758834d0..a3243ba61 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -1,4 +1,4 @@ -import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect } from 'react'; +import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react'; import { Editor } from 'slate'; import { Avatar, Icon, Icons, MenuItem, Text } from 'folds'; import { JoinRule, MatrixClient } from 'matrix-js-sdk'; @@ -80,7 +80,14 @@ export function RoomMentionAutocomplete({ const mx = useMatrixClient(); const mDirects = useAtomValue(mDirectAtom); - const allRooms = useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx)); + // Copy before sorting: `.sort()` mutates in place, and `allRoomsAtom`'s array + // is shared app-wide — sorting it here reorders it for every other consumer. + // Also memoize so a keystroke doesn't re-run the O(N log N) getRoom compare. + const allRoomsList = useAtomValue(allRoomsAtom); + const allRooms = useMemo( + () => [...allRoomsList].sort(factoryRoomIdByActivity(mx)), + [allRoomsList, mx], + ); const [result, search, resetSearch] = useAsyncSearch( allRooms, diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx index 8a48399c7..1ea4c25d2 100644 --- a/src/app/features/message-search/SearchFilters.tsx +++ b/src/app/features/message-search/SearchFilters.tsx @@ -152,7 +152,10 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto getRoomNameStr, SEARCH_OPTS, ); - const rooms = Array.from(searchResult?.items ?? roomList).sort(factoryRoomIdByAtoZ(mx)); + const rooms = useMemo( + () => Array.from(searchResult?.items ?? roomList).sort(factoryRoomIdByAtoZ(mx)), + [searchResult, roomList, mx], + ); const virtualizer = useVirtualizer({ count: rooms.length, diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index bab6a5dee..df491d32e 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -698,7 +698,9 @@ function RoomNavItem_({ ); const hasDraft = useAtomValue(hasDraftAtom); - const latestEvent = useRoomLatestRenderedEvent(room); + // Only DM rows render this preview — pass `direct` so non-DM nav items don't + // register the global decryption listener (PERF-5). + const latestEvent = useRoomLatestRenderedEvent(room, !!direct); const dmPreview = (() => { if (!direct || !latestEvent) return null; const type = latestEvent.getType(); diff --git a/src/app/hooks/useRoomLatestRenderedEvent.ts b/src/app/hooks/useRoomLatestRenderedEvent.ts index d46b90a75..a031e54a6 100644 --- a/src/app/hooks/useRoomLatestRenderedEvent.ts +++ b/src/app/hooks/useRoomLatestRenderedEvent.ts @@ -12,13 +12,22 @@ import { useSetting } from '../state/hooks/settings'; import { MessageEvent, StateEvent } from '../../types/matrix/room'; import { isMembershipChanged, reactionOrEditEvent } from '../utils/room'; -export const useRoomLatestRenderedEvent = (room: Room) => { +export const useRoomLatestRenderedEvent = (room: Room, enabled = true) => { const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [latestEvent, setLatestEvent] = useState(); useEffect(() => { + // The latest-event preview is only rendered for DM rooms. Skip the work — + // and especially the GLOBAL client `Decrypted` listener, which every nav + // item would otherwise add and which fans out on every decryption app-wide + // (PERF-5) — when the caller isn't going to use the result. + if (!enabled) { + setLatestEvent(undefined); + return undefined; + } + const getLatestEvent = (): MatrixEvent | undefined => { const liveEvents = room.getLiveTimeline().getEvents(); for (let i = liveEvents.length - 1; i >= 0; i -= 1) { @@ -67,7 +76,7 @@ export const useRoomLatestRenderedEvent = (room: Room) => { room.removeListener(RoomEvent.Timeline, handleTimelineEvent); room.client.removeListener(MatrixEventEvent.Decrypted, handleDecrypted); }; - }, [room, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]); + }, [room, enabled, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]); return latestEvent; };