From 302e9d671dd25ce8ac5ea51c38e7b27f980d7a89 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Thu, 23 Jul 2026 21:23:44 +0200 Subject: [PATCH 1/2] fix: stop wiping unread counts for spaces and unchanged rooms in incremental recompute --- src/app/utils/room.ts | 7 +- src/app/utils/room.unread.test.ts | 141 ++++++++++++++++++++++++++++++ src/client/slidingSync.test.ts | 68 ++++++++++++++ src/client/slidingSync.ts | 6 +- 4 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 src/app/utils/room.unread.test.ts diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index a9cf703233..24047993df 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -530,10 +530,9 @@ export const getUnreadInfosForRooms = ( deleted.push(roomId); continue; } - if (room.isSpaceRoom()) { - deleted.push(roomId); - continue; - } + // Space unread is derived from children in the atom reducer; skip like + // getUnreadInfos rather than deleting. + if (room.isSpaceRoom()) continue; if (room.getMyMembership() !== 'join') { deleted.push(roomId); continue; diff --git a/src/app/utils/room.unread.test.ts b/src/app/utils/room.unread.test.ts new file mode 100644 index 0000000000..5bcc5826da --- /dev/null +++ b/src/app/utils/room.unread.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EventType, KnownMembership, NotificationCountType } from '$types/matrix-sdk'; +import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; +import { getUnreadInfosForRooms } from './room'; + +const SPACE = '!space:example.com'; +const ROOM_UNREAD = '!unread:example.com'; +const ROOM_EMPTY = '!empty:example.com'; +const ROOM_MUTED = '!muted:example.com'; +const ROOM_LEFT = '!left:example.com'; +const MISSING = '!missing:example.com'; + +const createEvent = (id: string, sender: string, type = 'm.room.message'): MatrixEvent => + ({ + getId: () => id, + getSender: () => sender, + getType: () => type, + isRedacted: () => false, + getRelation: () => undefined, + }) as unknown as MatrixEvent; + +const createClient = ( + rooms: Record, + pushRulesOverride?: unknown[] +): MatrixClient => + ({ + getUserId: () => '@user:example.com', + getRoom: (roomId: string) => rooms[roomId], + getRoomPushRule: () => { + throw new Error('no rule'); + }, + getAccountData: pushRulesOverride + ? () => ({ getContent: () => ({ global: { override: pushRulesOverride } }) }) + : () => undefined, + }) as unknown as MatrixClient; + +const createRoom = ( + roomId: string, + opts: { + isSpace?: boolean; + membership?: string; + total?: number; + highlight?: number; + readUpTo?: string | null; + events?: MatrixEvent[]; + } = {} +): Room => { + const { + isSpace = false, + membership = KnownMembership.Join, + total = 0, + highlight = 0, + readUpTo = '$read', + events = [], + } = opts; + + const client = createClient({ [roomId]: null as unknown as Room }); + + return { + roomId, + isSpaceRoom: () => isSpace, + getMyMembership: () => membership, + getJoinedMemberCount: () => 10, + getUnreadNotificationCount: vi.fn((type: string) => { + if (type === NotificationCountType.Highlight) return highlight; + return total; + }), + getEventReadUpTo: () => readUpTo, + getLiveTimeline: () => ({ getEvents: () => events }), + getAccountData: () => undefined, + client, + } as unknown as Room; +}; + +describe('getUnreadInfosForRooms', () => { + it('skips space rooms instead of deleting them', () => { + const unreadRoom = createRoom(ROOM_UNREAD, { + total: 5, + events: [createEvent('$unread', '@other:example.com')], + }); + const spaceRoom = createRoom(SPACE, { isSpace: true }); + const mx = createClient({ [SPACE]: spaceRoom, [ROOM_UNREAD]: unreadRoom }); + (unreadRoom as unknown as { client: MatrixClient }).client = mx; + (spaceRoom as unknown as { client: MatrixClient }).client = mx; + + const { unread, deleted } = getUnreadInfosForRooms(mx, [SPACE, ROOM_UNREAD]); + + expect(deleted).not.toContain(SPACE); + expect(unread.find((u) => u.roomId === SPACE)).toBeUndefined(); + expect(unread.find((u) => u.roomId === ROOM_UNREAD)).toBeDefined(); + expect(unread.find((u) => u.roomId === ROOM_UNREAD)?.total).toBe(5); + }); + + it('does not delete a space even when it is the only dirty room', () => { + const spaceRoom = createRoom(SPACE, { isSpace: true }); + const mx = createClient({ [SPACE]: spaceRoom }); + (spaceRoom as unknown as { client: MatrixClient }).client = mx; + + const { unread, deleted } = getUnreadInfosForRooms(mx, [SPACE]); + + expect(deleted).toEqual([]); + expect(unread).toEqual([]); + }); + + it('deletes rooms that no longer exist', () => { + const mx = createClient({}); + const { deleted } = getUnreadInfosForRooms(mx, [MISSING]); + expect(deleted).toContain(MISSING); + }); + + it('deletes rooms the user has left', () => { + const leftRoom = createRoom(ROOM_LEFT, { membership: KnownMembership.Leave }); + const mx = createClient({ [ROOM_LEFT]: leftRoom }); + (leftRoom as unknown as { client: MatrixClient }).client = mx; + + const { deleted } = getUnreadInfosForRooms(mx, [ROOM_LEFT]); + expect(deleted).toContain(ROOM_LEFT); + }); + + it('deletes muted rooms', () => { + const mutedRoom = createRoom(ROOM_MUTED); + const mx = createClient( + { [ROOM_MUTED]: mutedRoom }, + [{ rule_id: ROOM_MUTED, actions: ['dont_notify'] }] + ); + (mutedRoom as unknown as { client: MatrixClient }).client = mx; + + const { deleted } = getUnreadInfosForRooms(mx, [ROOM_MUTED]); + expect(deleted).toContain(ROOM_MUTED); + }); + + it('deletes rooms whose unread has dropped to zero', () => { + const emptyRoom = createRoom(ROOM_EMPTY, { total: 0, highlight: 0, events: [] }); + const mx = createClient({ [ROOM_EMPTY]: emptyRoom }); + (emptyRoom as unknown as { client: MatrixClient }).client = mx; + + const { unread, deleted } = getUnreadInfosForRooms(mx, [ROOM_EMPTY]); + expect(unread).toHaveLength(0); + expect(deleted).toContain(ROOM_EMPTY); + }); +}); diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index d63ea4186d..cebfaaf719 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -193,6 +193,74 @@ describe('SlidingSyncManager initial request', () => { ]); }); + it('excludes account_data rooms with no events from the dirty set', async () => { + const manager = makeManager(makeMockMx()); + const settled = vi.fn<(dirtyRoomIds: ReadonlySet) => void>(); + manager.subscribeToResponseSettled(settled); + manager.attach(); + + fireLifecycle(SlidingSyncState.RequestFinished, {}); + fireLifecycle(SlidingSyncState.Complete, { + rooms: {}, + extensions: { + account_data: { + rooms: { + '!unchanged:example.com': [], + '!changed:example.com': [ + { + type: EventType.FullyRead, + content: { event_id: '$event' }, + }, + ], + }, + }, + }, + }); + + await Promise.resolve(); + + expect(settled).toHaveBeenCalledOnce(); + expect([...settled.mock.calls[0]![0]]).toEqual(['!changed:example.com']); + }); + + it('marks only rooms with real data dirty across a full sync response', async () => { + const manager = makeManager(makeMockMx()); + const settled = vi.fn<(dirtyRoomIds: ReadonlySet) => void>(); + manager.subscribeToResponseSettled(settled); + manager.attach(); + + fireLifecycle(SlidingSyncState.RequestFinished, {}); + fireRoomData('!real:example.com', { initial: false }); + fireLifecycle(SlidingSyncState.Complete, { + rooms: { + '!real:example.com': { name: 'Real Room', notification_count: 0, highlight_count: 0 }, + }, + extensions: { + account_data: { + rooms: Object.fromEntries( + Array.from({ length: 50 }, (_, i) => [`!empty${i}:example.com`, []]) + ), + }, + receipts: { + rooms: { + '!real:example.com': { + type: 'm.receipt', + content: {}, + }, + }, + }, + }, + }); + + await Promise.resolve(); + + expect(settled).toHaveBeenCalledOnce(); + const dirty = [...settled.mock.calls[0]![0]]; + // Only the room that actually received data — not the 50 empty echoes. + expect(dirty).toEqual(['!real:example.com']); + expect(dirty).toHaveLength(1); + }); + it('does not fan out member requests for users referenced by startup sync', async () => { const getStateEvent = vi.fn<() => Promise>>().mockResolvedValue({ membership: KnownMembership.Join, diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 72beb2d119..c4b2344a49 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -519,7 +519,11 @@ export class SlidingSyncManager { const extension = resp.extensions?.[extensionName] as | RoomScopedExtensionResponse | undefined; - Object.keys(extension?.rooms ?? {}).forEach((roomId) => this.dirtyRoomIds.add(roomId)); + const rooms = extension?.rooms ?? {}; + Object.entries(rooms).forEach(([roomId, data]) => { + if (extensionName === 'account_data' && Array.isArray(data) && data.length === 0) return; + this.dirtyRoomIds.add(roomId); + }); }); globalThis.queueMicrotask(() => { From acb85e76049e48c7c1618461d1de2f5612a7921b Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Thu, 23 Jul 2026 22:11:58 +0200 Subject: [PATCH 2/2] fix: resolve lint and format failures in unread test --- src/app/utils/room.unread.test.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/app/utils/room.unread.test.ts b/src/app/utils/room.unread.test.ts index 5bcc5826da..2e087bfb9d 100644 --- a/src/app/utils/room.unread.test.ts +++ b/src/app/utils/room.unread.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { EventType, KnownMembership, NotificationCountType } from '$types/matrix-sdk'; +import { KnownMembership, NotificationCountType } from '$types/matrix-sdk'; import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; import { getUnreadInfosForRooms } from './room'; @@ -19,10 +19,7 @@ const createEvent = (id: string, sender: string, type = 'm.room.message'): Matri getRelation: () => undefined, }) as unknown as MatrixEvent; -const createClient = ( - rooms: Record, - pushRulesOverride?: unknown[] -): MatrixClient => +const createClient = (rooms: Record, pushRulesOverride?: unknown[]): MatrixClient => ({ getUserId: () => '@user:example.com', getRoom: (roomId: string) => rooms[roomId], @@ -61,7 +58,7 @@ const createRoom = ( isSpaceRoom: () => isSpace, getMyMembership: () => membership, getJoinedMemberCount: () => 10, - getUnreadNotificationCount: vi.fn((type: string) => { + getUnreadNotificationCount: vi.fn<(type: string) => number>((type: string) => { if (type === NotificationCountType.Highlight) return highlight; return total; }), @@ -119,10 +116,9 @@ describe('getUnreadInfosForRooms', () => { it('deletes muted rooms', () => { const mutedRoom = createRoom(ROOM_MUTED); - const mx = createClient( - { [ROOM_MUTED]: mutedRoom }, - [{ rule_id: ROOM_MUTED, actions: ['dont_notify'] }] - ); + const mx = createClient({ [ROOM_MUTED]: mutedRoom }, [ + { rule_id: ROOM_MUTED, actions: ['dont_notify'] }, + ]); (mutedRoom as unknown as { client: MatrixClient }).client = mx; const { deleted } = getUnreadInfosForRooms(mx, [ROOM_MUTED]);