From 42b587a9c87128f5fab10e7282798a0c1a4cc94f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 21:15:55 -0400 Subject: [PATCH 1/7] perf: reduce excessive re-renders and stale state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove RoomEvent.UnreadNotifications from ThreadReplyChip: fired on every room message, causing N×chip re-renders and O(n_events) scans per message when N threads are visible. ThreadEvent.NewReply/Update and RoomEvent.Redaction already cover the relevant updates. - Memoize VList style in RoomTimeline: was creating a new object every render (including the frequent setTimeline spreads from edits/replies) - Replace empty style={} with undefined in RoomView: avoids a new object reference on every render propagating into Page - Reset useUserPresence state on userId change: useState initializer only runs once; stale presence data shown when navigating between user profiles until a UserEvent.Presence fires for the new user --- .changeset/fix-perf-rerender-reduction.md | 5 +++++ src/app/features/room/RoomTimeline.tsx | 20 ++++++++++++-------- src/app/features/room/RoomView.tsx | 2 +- src/app/hooks/useUserPresence.ts | 2 ++ 4 files changed, 20 insertions(+), 9 deletions(-) create mode 100644 .changeset/fix-perf-rerender-reduction.md diff --git a/.changeset/fix-perf-rerender-reduction.md b/.changeset/fix-perf-rerender-reduction.md new file mode 100644 index 000000000..f04f89e03 --- /dev/null +++ b/.changeset/fix-perf-rerender-reduction.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Reduce unnecessary re-renders: memoize VList style in RoomTimeline, remove per-message UnreadNotifications listener from ThreadReplyChip, and reset presence state correctly when navigating between user profiles. diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index fb4b33515..5fee5651c 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -205,6 +205,17 @@ export function RoomTimeline({ const [shift, setShift] = useState(false); const [topSpacerHeight, setTopSpacerHeight] = useState(0); + const vListStyle = useMemo( + () => ({ + flex: 1, + minHeight: 0, + display: 'flex', + flexDirection: 'column' as const, + paddingTop: topSpacerHeight > 0 ? topSpacerHeight : config.space.S600, + paddingBottom: config.space.S600, + }), + [topSpacerHeight] + ); const topSpacerHeightRef = useRef(0); const mountScrollWindowRef = useRef(Date.now() + 3000); @@ -839,14 +850,7 @@ export function RoomTimeline({ data={processedEvents} shift={shift} className={css.messageList} - style={{ - flex: 1, - minHeight: 0, - display: 'flex', - flexDirection: 'column', - paddingTop: topSpacerHeight > 0 ? topSpacerHeight : config.space.S600, - paddingBottom: config.space.S600, - }} + style={vListStyle} onScroll={handleVListScroll} > {(eventData, index) => { diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 421561616..3c812959d 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -146,7 +146,7 @@ export function RoomView({ eventId }: { eventId?: string }) { style={ room.isCallRoom() && screenSize === ScreenSize.Desktop ? { maxWidth: toRem(399), minWidth: toRem(399) } - : {} + : undefined } > diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index f1b858422..a54d05a7b 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -29,6 +29,8 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); useEffect(() => { + setPresence(user ? getUserPresence(user) : undefined); + const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => { if (u.userId === user?.userId) { setPresence(getUserPresence(user)); From f0818e2a843abf5393345255ac450932a231a699 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 11:47:09 -0400 Subject: [PATCH 2/7] fix(ui): suppress button hover transform inside VList items to prevent smearing --- src/app/styles/overrides/General.css.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/styles/overrides/General.css.ts b/src/app/styles/overrides/General.css.ts index 34ef9ada8..4edb6ea98 100644 --- a/src/app/styles/overrides/General.css.ts +++ b/src/app/styles/overrides/General.css.ts @@ -40,7 +40,9 @@ globalStyle( button[class*="_1684mq51"]:has(img):hover, [data-index] [class*="_1r9nvaso"]:hover, [data-index] [class*="_1r9nvaso"] *:hover, - [data-index] button:has(p):hover + [data-index] button:has(p):hover, + [data-index] button:hover, + [data-index] [role="button"]:hover `, { transform: 'none !important', From d4d40165a7c0cc3aaf5be8e839ddb47504c98784 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 13:24:37 -0400 Subject: [PATCH 3/7] feat: add presence badges to sidebar DM list and own account avatar - Fix useUserPresence hook: when User object doesn't exist yet (before first presence event from sliding sync), fall back to subscribing at the MatrixClient level via ClientEvent.Event. ExtensionPresence emits this after creating the User object so mx.getUser(userId) is non-null by the time the handler runs. Broaden deps from [user] to [mx, userId, user] so the effect re-subscribes at User level after the re-render. - AccountSwitcherTab: wrap own user avatar in AvatarPresence to show your own online/away/busy status as a dot in the sidebar. - DirectDMsList: add presence dots to DM sidebar icons. Single DMs show the partner's actual presence (green/yellow/grey). Group DMs show a green dot when any member is online. --- src/app/hooks/useUserPresence.ts | 25 +++++++++++++++--- .../client/sidebar/AccountSwitcherTab.tsx | 23 +++++++++++----- .../pages/client/sidebar/DirectDMsList.tsx | 26 ++++++++++++++++++- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index a54d05a7b..ba9ecaa1d 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk'; +import { ClientEvent, MatrixEvent, User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk'; import { useMatrixClient } from './useMatrixClient'; export enum Presence { @@ -32,19 +32,36 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { setPresence(user ? getUserPresence(user) : undefined); const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => { - if (u.userId === user?.userId) { - setPresence(getUserPresence(user)); + if (u.userId === userId) { + setPresence(getUserPresence(u)); } }; user?.on(UserEvent.Presence, updatePresence); user?.on(UserEvent.CurrentlyActive, updatePresence); user?.on(UserEvent.LastPresenceTs, updatePresence); + + // If the User object doesn't exist yet, subscribe at client level as a fallback. + // ExtensionPresence emits ClientEvent.Event after creating and updating the User object, + // so by the time this fires mx.getUser(userId) is guaranteed to be non-null. + let removeClientListener: (() => void) | undefined; + if (!user) { + const onClientEvent = (event: MatrixEvent) => { + if (event.getSender() !== userId || event.getType() !== 'm.presence') return; + const u = mx.getUser(userId); + if (!u) return; + setPresence(getUserPresence(u)); + }; + mx.on(ClientEvent.Event, onClientEvent); + removeClientListener = () => mx.removeListener(ClientEvent.Event, onClientEvent); + } + return () => { user?.removeListener(UserEvent.Presence, updatePresence); user?.removeListener(UserEvent.CurrentlyActive, updatePresence); user?.removeListener(UserEvent.LastPresenceTs, updatePresence); + removeClientListener?.(); }; - }, [user]); + }, [mx, userId, user]); return presence; }; diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 089c7b84b..c57dd04e4 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -40,10 +40,12 @@ import { getHomePath, getLoginPath, withSearchParam } from '$pages/pathUtils'; import { logoutClient, initClient, stopClient } from '$client/initMatrix'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useUserProfile } from '$hooks/useUserProfile'; +import { useUserPresence } from '$hooks/useUserPresence'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useSessionProfiles } from '$hooks/useSessionProfiles'; import { Settings } from '$features/settings'; import { Modal500 } from '$components/Modal500'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import { useClientConfig } from '$hooks/useClientConfig'; @@ -173,6 +175,7 @@ export function AccountSwitcherTab() { const myUserId = mx.getUserId() ?? ''; const activeProfile = useUserProfile(myUserId); + const myPresence = useUserPresence(myUserId); const activeAvatarUrl = activeProfile.avatarUrl ? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined; @@ -277,12 +280,20 @@ export function AccountSwitcherTab() { onClick={handleToggle} outlined={sessions.length > 1} > - {nameInitials(label)}} - /> + + ) : undefined + } + > + {nameInitials(label)}} + /> + )} diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 16e829ce5..6673dc488 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -15,6 +15,8 @@ import { } from '$components/sidebar'; import { RoomAvatar } from '$components/room-avatar'; import { UserAvatar } from '$components/user-avatar'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; +import { useUserPresence, Presence } from '$hooks/useUserPresence'; import { getDirectRoomAvatarUrl } from '$utils/room'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { nameInitials } from '$utils/common'; @@ -48,6 +50,28 @@ function DMItem({ room, selected }: DMItemProps) { // Members are sorted by who last sent messages (most recent first) const groupMembers = useGroupDMMembers(mx, room, MAX_GROUP_MEMBERS); + // Presence hooks — always called unconditionally (React rules of hooks). + // For single DMs: guessDMUserId() is synchronous; group slots use '' → undefined. + // For group DMs: singleDMUserId is '' → undefined; member slots use groupMembers. + const singleDMUserId = isGroupDM ? '' : room.guessDMUserId(); + const singleDMPresence = useUserPresence(singleDMUserId); + const member0Presence = useUserPresence(isGroupDM ? (groupMembers[0]?.userId ?? '') : ''); + const member1Presence = useUserPresence(isGroupDM ? (groupMembers[1]?.userId ?? '') : ''); + const member2Presence = useUserPresence(isGroupDM ? (groupMembers[2]?.userId ?? '') : ''); + + const groupDMOnline = + isGroupDM && + [member0Presence, member1Presence, member2Presence].some( + (p) => p && p.lastActiveTs !== 0 && p.presence === Presence.Online + ); + + const presenceBadge = + !isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0 ? ( + + ) : isGroupDM && groupDMOnline ? ( + + ) : undefined; + // Get unread info for badge const unread = roomToUnread.get(room.roomId); @@ -133,7 +157,7 @@ function DMItem({ room, selected }: DMItemProps) { {(triggerRef) => ( - {renderAvatar()} + {renderAvatar()} )} From d7c3d1a264330869f85e51ad2996d1f09fce5236 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 14:05:42 -0400 Subject: [PATCH 4/7] fix(presence): move AvatarPresence outside SidebarAvatar to prevent overflow clipping --- .../client/sidebar/AccountSwitcherTab.tsx | 26 +++++++++---------- .../pages/client/sidebar/DirectDMsList.tsx | 8 +++--- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index c57dd04e4..4b5d65233 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -274,18 +274,18 @@ export function AccountSwitcherTab() { {(triggerRef) => ( - 1} + + ) : undefined + } > - - ) : undefined - } + 1} > {nameInitials(label)}} /> - - + + )} {(totalBackgroundUnread > 0 || anyBackgroundHighlight) && ( diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 6673dc488..898943b52 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -156,9 +156,11 @@ function DMItem({ room, selected }: DMItemProps) { {(triggerRef) => ( - - {renderAvatar()} - + + + {renderAvatar()} + + )} {unread && (unread.total > 0 || unread.highlight > 0) && ( From 4affb43a7c0e6cd0848c456f6f046e9b55bd21ea Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 14:46:36 -0400 Subject: [PATCH 5/7] fix(presence): REST fallback fetch when no presence events received via sliding sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synapse MSC4186 sliding sync has no presence extension — User.presence stays at the SDK default ('offline') and getLastActiveTs() stays 0. Without this, presence badges only show after toggling to classic sync and back (which briefly populates the User objects via m.presence events in the classic sync response). Fix: if getLastActiveTs() === 0 (no presence event received yet this session), fall back to GET /_matrix/client/v3/presence/{userId}/status and populate state from the REST response. Live updates via UserEvent.Presence continue to work as before for servers that do deliver presence through sync. --- src/app/hooks/useUserPresence.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index ba9ecaa1d..8b3ed44e2 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -31,6 +31,27 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { useEffect(() => { setPresence(user ? getUserPresence(user) : undefined); + let cancelled = false; + + // Sliding sync (Synapse MSC4186) has no presence extension — m.presence events are never + // delivered via sync. As a result, User.presence stays at the SDK default and + // getLastActiveTs() stays 0. Fall back to a direct REST fetch to bootstrap presence state. + if (!user || user.getLastActiveTs() === 0) { + mx.getPresence(userId) + .then((resp) => { + if (cancelled) return; + setPresence({ + presence: resp.presence as Presence, + status: resp.status_msg, + active: resp.currently_active ?? false, + lastActiveTs: resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined, + }); + }) + .catch(() => { + // Presence not available on this server (404 or not supported) — keep existing state. + }); + } + const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => { if (u.userId === userId) { setPresence(getUserPresence(u)); @@ -56,6 +77,7 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { } return () => { + cancelled = true; user?.removeListener(UserEvent.Presence, updatePresence); user?.removeListener(UserEvent.CurrentlyActive, updatePresence); user?.removeListener(UserEvent.LastPresenceTs, updatePresence); From 9228f91af927fa7138bf71ac9ff7940e9d43fcae Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 14:58:01 -0400 Subject: [PATCH 6/7] fix(presence): explicitly PUT presence status for sliding sync so own avatar shows correct state --- src/app/pages/client/ClientNonUIFeatures.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index dc9bf984f..b2332ea19 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -831,6 +831,12 @@ function PresenceFeature() { mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline); // Sliding sync: enable/disable the presence extension on the next poll. getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); + // Synapse MSC4186 sliding sync has no presence extension, so setSyncPresence has no + // effect. Explicitly PUT /presence/{userId}/status so the server knows the user's + // state — otherwise GET /presence returns stale offline and own presence badge is grey. + mx.setPresence({ presence: sendPresence ? 'online' : 'offline' }).catch(() => { + // Server doesn't support presence — ignore. + }); }, [mx, sendPresence]); return null; From b54a07412f1f4c59f117a1f0859763e7386e1777 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 15:21:21 -0400 Subject: [PATCH 7/7] fix(lint): fix prettier formatting and nested ternary lint errors --- src/app/hooks/useUserPresence.ts | 3 ++- src/app/pages/client/sidebar/DirectDMsList.tsx | 14 +++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 8b3ed44e2..a3b86ef08 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -44,7 +44,8 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { presence: resp.presence as Presence, status: resp.status_msg, active: resp.currently_active ?? false, - lastActiveTs: resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined, + lastActiveTs: + resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined, }); }) .catch(() => { diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 898943b52..34a108a60 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useEffect } from 'react'; +import { useMemo, useRef, useEffect, ReactNode } from 'react'; import * as Sentry from '@sentry/react'; import { useNavigate } from 'react-router-dom'; import { Avatar, Text, Box } from 'folds'; @@ -65,12 +65,12 @@ function DMItem({ room, selected }: DMItemProps) { (p) => p && p.lastActiveTs !== 0 && p.presence === Presence.Online ); - const presenceBadge = - !isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0 ? ( - - ) : isGroupDM && groupDMOnline ? ( - - ) : undefined; + let presenceBadge: ReactNode; + if (!isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0) { + presenceBadge = ; + } else if (isGroupDM && groupDMOnline) { + presenceBadge = ; + } // Get unread info for badge const unread = roomToUnread.get(room.roomId);