perf(receipts): shared member-change store instead of per-row listeners (PERF-3)

Every ReadReceiptAvatars row and every useMemberAvatar registered its own global
RoomStateEvent.Members listener — ~6 per receipt row — each firing on any
membership / display-name / avatar change in ANY room.

Add a module-level MemberChangeStore (mirroring the PERF-1 presence store) that
registers exactly ONE global Members listener and fans out to subscribers keyed
by roomId|userId. Two hooks: useRoomMemberChange (single) and
useRoomMembersChange (multi, one effect). useMemberAvatar and ReadReceiptAvatars
use them; behavior (re-render triggers) is byte-for-byte equivalent. Unsubscribe
is idempotent via a set-identity guard; the multi-hook key is order-independent.
Unit-tested (key-scoped fan-out, single shared listener, idempotent unsubscribe).

Reviewed by two passes (lifecycle/closure + behavioral equivalence) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 21:10:33 -04:00
co-authored by Claude Opus 4.8
parent 4708a17961
commit 1b8f554584
4 changed files with 182 additions and 48 deletions
+5 -22
View File
@@ -1,8 +1,7 @@
import { useEffect } from 'react';
import { Room, RoomStateEvent, RoomStateEventHandlerMap } from 'matrix-js-sdk';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useMediaAuthentication } from './useMediaAuthentication';
import { useForceUpdate } from './useForceUpdate';
import { useRoomMemberChange } from './useRoomMemberChange';
import { getMemberName } from '../utils/room';
import { mxcUrlToHttp } from '../utils/matrix';
@@ -15,9 +14,8 @@ export type MemberAvatar = {
* Resolve a room member's display name and avatar http url, staying reactive to
* that member's profile (name/avatar/membership) changes.
*
* Mirrors the reactivity pattern in ReadReceiptAvatars (N6): subscribe to
* `RoomStateEvent.Members` filtered to this room + userId and force a re-render,
* since `RoomMemberEvent` has no dedicated Avatar signal.
* Stays reactive to this member's profile (name/avatar/membership) changes via
* the shared member-change store (one global listener for the whole app).
*/
export const useMemberAvatar = (
room: Room,
@@ -28,23 +26,8 @@ export const useMemberAvatar = (
): MemberAvatar => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [, forceUpdate] = useForceUpdate();
useEffect(() => {
const handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = (
event,
_state,
member,
) => {
if (event.getRoomId() === room.roomId && member.userId === userId) {
forceUpdate();
}
};
mx.on(RoomStateEvent.Members, handleMembers);
return () => {
mx.removeListener(RoomStateEvent.Members, handleMembers);
};
}, [mx, room.roomId, userId, forceUpdate]);
useRoomMemberChange(room.roomId, userId);
const name = getMemberName(room, userId);
const avatarMxc = room.getMember(userId)?.getMxcAvatarUrl();