From 5dde09e968ee85f96691683fc2e0736936e9b203 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 1 Aug 2026 10:51:50 +0200 Subject: [PATCH] fix(members): refill the member roster when sliding sync only lazy-loaded it --- .changeset/fix-lazy-loaded-member-roster.md | 5 + src/app/hooks/useRoomMembers.ts | 7 +- src/client/roomMemberHydration.test.ts | 101 +++++++++++++++++++- src/client/roomMemberHydration.ts | 46 ++++++++- 4 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-lazy-loaded-member-roster.md diff --git a/.changeset/fix-lazy-loaded-member-roster.md b/.changeset/fix-lazy-loaded-member-roster.md new file mode 100644 index 0000000000..c577a11956 --- /dev/null +++ b/.changeset/fix-lazy-loaded-member-roster.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Refill the room member list from the server when sliding sync only delivered lazy-loaded members, so mention autocomplete and the members panel are complete. diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts index 31e04867f5..4469680d61 100644 --- a/src/app/hooks/useRoomMembers.ts +++ b/src/app/hooks/useRoomMembers.ts @@ -1,6 +1,7 @@ import type { MatrixClient, MatrixEvent, RoomMember } from '$types/matrix-sdk'; import { EventType, RoomMemberEvent, RoomStateEvent } from '$types/matrix-sdk'; import { useEffect, useState } from 'react'; +import { hydrateAllRoomMembers } from '$client/roomMemberHydration'; export const useRoomMembers = (mx: MatrixClient, roomId: string, enabled = true): RoomMember[] => { const [members, setMembers] = useState([]); @@ -23,11 +24,13 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string, enabled = true) if (room) { setMembers(room.getMembers()); - room.loadMembersIfNeeded().then(() => { + const stopLoading = () => { loadingMembers = false; if (disposed) return; updateMemberList(); - }); + void hydrateAllRoomMembers(mx, roomId).then(() => updateMemberList()); + }; + room.loadMembersIfNeeded().then(stopLoading, stopLoading); } const handleStateEvent = (event: MatrixEvent) => { diff --git a/src/client/roomMemberHydration.test.ts b/src/client/roomMemberHydration.test.ts index 7a6c4ebd4e..d447e5bae4 100644 --- a/src/client/roomMemberHydration.test.ts +++ b/src/client/roomMemberHydration.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { MatrixClient, Room, RoomMember } from '$types/matrix-sdk'; import { EventType } from '$types/matrix-sdk'; -import { hydrateRoomMember, hydrateRoomMembers } from './roomMemberHydration'; +import { + hydrateAllRoomMembers, + hydrateRoomMember, + hydrateRoomMembers, +} from './roomMemberHydration'; const ROOM_ID = '!room:server'; const USER_ID = '@ghost:server'; @@ -151,6 +155,101 @@ describe('hydrateRoomMember (force)', () => { }); }); +type BulkFakeSetup = { + mx: MatrixClient; + members: ReturnType; + setStateEvents: ReturnType; +}; + +const makeBulkFakes = ( + joinedMembers: number, + joinedCount: number, + knownMemberIds: string[] = [] +): BulkFakeSetup => { + const setStateEvents = vi.fn<() => void>(); + const room = { + roomId: ROOM_ID, + getJoinedMembers: () => Array.from({ length: joinedMembers }, () => ({}) as RoomMember), + getJoinedMemberCount: () => joinedCount, + getMember: (userId: string) => (knownMemberIds.includes(userId) ? ({} as RoomMember) : null), + currentState: { setStateEvents }, + } as unknown as Room; + const members = vi.fn<() => Promise>(() => + Promise.resolve({ + chunk: [ + { + type: EventType.RoomMember, + state_key: USER_ID, + room_id: ROOM_ID, + sender: USER_ID, + content: { membership: 'join' }, + }, + ], + }) + ); + const mx = { + getRoom: () => room, + members, + } as unknown as MatrixClient; + return { mx, members, setStateEvents }; +}; + +describe('hydrateAllRoomMembers', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('fetches the full member list when the roster is short of the joined count', async () => { + const { mx, members, setStateEvents } = makeBulkFakes(2, 20); + + await hydrateAllRoomMembers(mx, ROOM_ID); + + expect(members).toHaveBeenCalledWith(ROOM_ID, undefined, 'leave'); + const [events] = setStateEvents.mock.calls[0] as [Array<{ getType: () => string }>]; + expect(events[0]?.getType()).toBe(EventType.RoomMember); + }); + + it('skips members the room already knows', async () => { + const { mx, setStateEvents } = makeBulkFakes(2, 20, [USER_ID]); + + await hydrateAllRoomMembers(mx, ROOM_ID); + + expect(setStateEvents).not.toHaveBeenCalled(); + }); + + it('does nothing when the roster already matches the joined count', async () => { + const { mx, members } = makeBulkFakes(20, 20); + + await hydrateAllRoomMembers(mx, ROOM_ID); + + expect(members).not.toHaveBeenCalled(); + }); + + it('does not refetch within the TTL and retries after it', async () => { + const { mx, members } = makeBulkFakes(2, 20); + + await hydrateAllRoomMembers(mx, ROOM_ID); + await hydrateAllRoomMembers(mx, ROOM_ID); + expect(members).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(5 * 60_000 + 1); + await hydrateAllRoomMembers(mx, ROOM_ID); + expect(members).toHaveBeenCalledTimes(2); + }); + + it('swallows a failed fetch', async () => { + const { mx, members, setStateEvents } = makeBulkFakes(2, 20); + members.mockRejectedValueOnce(new Error('403')); + + await expect(hydrateAllRoomMembers(mx, ROOM_ID)).resolves.toBeUndefined(); + expect(setStateEvents).not.toHaveBeenCalled(); + }); +}); + describe('hydrateRoomMembers', () => { it('dedups user ids and filters non-user ids', async () => { const { mx, getStateEvent } = makeFakes(); diff --git a/src/client/roomMemberHydration.ts b/src/client/roomMemberHydration.ts index cc43745669..ef5493d27a 100644 --- a/src/client/roomMemberHydration.ts +++ b/src/client/roomMemberHydration.ts @@ -1,5 +1,5 @@ import type { MatrixClient } from '$types/matrix-sdk'; -import { EventType, MatrixEvent } from '$types/matrix-sdk'; +import { EventType, KnownMembership, MatrixEvent } from '$types/matrix-sdk'; const inFlight = new WeakMap>>(); @@ -103,6 +103,50 @@ export const hydrateRoomMember = ( return request; }; +// The SDK only fetches /members when the sync store holds no out-of-band member +// set for the room. Sliding sync sends $LAZY members, so a room whose stored set +// predates most joins keeps a short roster forever. Refill it from the server. +const BULK_TTL_MS = 5 * 60_000; +const bulkInFlight = new WeakMap>>(); +const bulkAttemptedAt = new WeakMap>(); + +export const hydrateAllRoomMembers = (mx: MatrixClient, roomId: string): Promise => { + const room = mx.getRoom(roomId); + if (!room) return Promise.resolve(); + if (room.getJoinedMembers().length >= room.getJoinedMemberCount()) return Promise.resolve(); + + const attemptedTs = bulkAttemptedAt.get(mx)?.get(roomId); + if (attemptedTs !== undefined && Date.now() - attemptedTs < BULK_TTL_MS) return Promise.resolve(); + + const pending = bulkInFlight.get(mx) ?? new Map>(); + bulkInFlight.set(mx, pending); + const existing = pending.get(roomId); + if (existing) return existing; + + const attempts = bulkAttemptedAt.get(mx) ?? new Map(); + bulkAttemptedAt.set(mx, attempts); + attempts.set(roomId, Date.now()); + + const request = mx + .members(roomId, undefined, KnownMembership.Leave) + .then(({ chunk }) => { + const currentRoom = mx.getRoom(roomId); + if (!currentRoom || !chunk) return; + // The response is current state, which may be ahead of our sync position, + // so only fill in members we are missing rather than overwriting known ones. + const missing = chunk.filter( + (event) => event.state_key && !currentRoom.getMember(event.state_key) + ); + if (missing.length === 0) return; + currentRoom.currentState.setStateEvents(missing.map((event) => new MatrixEvent(event))); + }) + .catch(() => undefined) + .finally(() => pending.delete(roomId)); + + pending.set(roomId, request); + return request; +}; + export const hydrateRoomMembers = ( mx: MatrixClient, roomId: string,