Skip to content

Commit f7117e7

Browse files
committed
fix(unread): flush the sync store when the app is hidden or closed
1 parent 5c94cef commit f7117e7

6 files changed

Lines changed: 477 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@
157157
"@vitest/ui": "^4.1.10",
158158
"buffer": "^6.0.3",
159159
"cloudflared": "^0.7.1",
160+
"fake-indexeddb": "^6.2.5",
160161
"jsdom": "^29.1.1",
161162
"knip": "6.32.1",
162163
"oxfmt": "^0.57.0",

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/client/initMatrix.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
} from './slidingSync';
4242
import { PresenceSyncManager } from './presenceSync';
4343
import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache';
44+
import { disposeSyncStorePersistence, installSyncStorePersistence } from './syncStorePersistence';
4445
import { clearCachedUserProfiles } from './userProfileCache';
4546
import {
4647
clearLocalNotificationCache,
@@ -570,6 +571,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig):
570571
}),
571572
{ transport: useSliding ? 'sliding' : 'classic' }
572573
);
574+
if (!useSliding) installSyncStorePersistence(mx);
573575
if (manager && (await manager.waitForSidebarCacheHydration())) {
574576
config?.onCachedRoomsLoaded?.();
575577
}
@@ -592,6 +594,7 @@ export const stopClient = (mx: MatrixClient): void => {
592594
log.log('stopClient', mx.getUserId());
593595
debugLog.info('sync', 'Stopping client', { userId: mx.getUserId() });
594596
slidingSyncRequestCleanupByClient.get(mx)?.();
597+
disposeSyncStorePersistence(mx);
595598
disposeSlidingSync(mx);
596599
disposePresenceSync(mx);
597600
mx.stopClient();
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import 'fake-indexeddb/auto';
2+
import { EventEmitter } from 'events';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
import type { MatrixClient } from '$types/matrix-sdk';
5+
import { IndexedDBStore, RoomEvent } from '$types/matrix-sdk';
6+
import { disposeSyncStorePersistence, installSyncStorePersistence } from './syncStorePersistence';
7+
8+
const USER_ID = '@me:example.org';
9+
const ROOM_ID = '!room:example.org';
10+
11+
type SyncOptions = { nextBatch: string; notificationCount: number; readReceiptFor?: string };
12+
13+
const syncResponse = ({ nextBatch, notificationCount, readReceiptFor }: SyncOptions) => ({
14+
next_batch: nextBatch,
15+
account_data: { events: [] },
16+
presence: { events: [] },
17+
rooms: {
18+
join: {
19+
[ROOM_ID]: {
20+
timeline: {
21+
events: [
22+
{
23+
event_id: '$msg',
24+
type: 'm.room.message',
25+
sender: '@them:example.org',
26+
content: { msgtype: 'm.text', body: 'hi' },
27+
origin_server_ts: 1000,
28+
},
29+
],
30+
prev_batch: 'p1',
31+
limited: false,
32+
},
33+
state: { events: [] },
34+
account_data: { events: [] },
35+
ephemeral: {
36+
events: readReceiptFor
37+
? [
38+
{
39+
type: 'm.receipt',
40+
content: { [readReceiptFor]: { 'm.read': { [USER_ID]: { ts: 2000 } } } },
41+
},
42+
]
43+
: [],
44+
},
45+
unread_notifications: { notification_count: notificationCount, highlight_count: 0 },
46+
},
47+
},
48+
invite: {},
49+
leave: {},
50+
},
51+
});
52+
53+
const openStore = (dbName: string) =>
54+
new IndexedDBStore({ indexedDB: globalThis.indexedDB, localStorage, dbName });
55+
56+
const readBackSnapshot = async (dbName: string) => {
57+
const store = openStore(dbName);
58+
await store.startup();
59+
const saved = await store.getSavedSync();
60+
const room = saved?.roomsData.join[ROOM_ID];
61+
await store.destroy();
62+
return {
63+
notificationCount: room?.unread_notifications?.notification_count,
64+
receipts: room?.ephemeral?.events ?? [],
65+
};
66+
};
67+
68+
const fakeClient = (store: IndexedDBStore) =>
69+
Object.assign(new EventEmitter(), {
70+
store,
71+
getUserId: () => USER_ID,
72+
}) as unknown as MatrixClient;
73+
74+
let dbName = '';
75+
let liveStore: IndexedDBStore | undefined;
76+
let liveClient: MatrixClient | undefined;
77+
78+
beforeEach(async () => {
79+
dbName = `sync-store-${Math.random().toString(16).slice(2)}`;
80+
liveStore = openStore(dbName);
81+
await liveStore.startup();
82+
liveClient = fakeClient(liveStore);
83+
installSyncStorePersistence(liveClient);
84+
85+
// The state a running client already had on disk before the room was read.
86+
await liveStore.setSyncData(syncResponse({ nextBatch: 's1', notificationCount: 3 }) as never);
87+
await liveStore.save(true);
88+
89+
// Reading the room: the server echoes the receipt and a cleared count, but
90+
// the sync loop will not write it for another five minutes.
91+
await liveStore.setSyncData(
92+
syncResponse({ nextBatch: 's2', notificationCount: 0, readReceiptFor: '$msg' }) as never
93+
);
94+
});
95+
96+
afterEach(async () => {
97+
if (liveClient) disposeSyncStorePersistence(liveClient);
98+
await liveStore?.destroy();
99+
liveStore = undefined;
100+
liveClient = undefined;
101+
});
102+
103+
describe('sync store persistence across a restart', () => {
104+
it('restores the pre-read snapshot when nothing flushes the store', async () => {
105+
const snapshot = await readBackSnapshot(dbName);
106+
107+
expect(snapshot.notificationCount).toBe(3);
108+
expect(snapshot.receipts).toEqual([]);
109+
});
110+
111+
it('restores the read state after the app is hidden', async () => {
112+
Object.defineProperty(document, 'visibilityState', {
113+
configurable: true,
114+
get: () => 'hidden',
115+
});
116+
document.dispatchEvent(new Event('visibilitychange'));
117+
await vi.waitFor(async () => {
118+
expect((await readBackSnapshot(dbName)).notificationCount).toBe(0);
119+
});
120+
121+
const snapshot = await readBackSnapshot(dbName);
122+
expect(snapshot.receipts).toHaveLength(1);
123+
});
124+
125+
it('restores the read state once our own receipt settles, without any hide', async () => {
126+
vi.useFakeTimers();
127+
try {
128+
(liveClient as unknown as EventEmitter).emit(RoomEvent.Receipt, {
129+
getContent: () => ({ $msg: { 'm.read': { [USER_ID]: { ts: 2000 } } } }),
130+
});
131+
await vi.advanceTimersByTimeAsync(60000);
132+
} finally {
133+
vi.useRealTimers();
134+
}
135+
136+
await vi.waitFor(async () => {
137+
expect((await readBackSnapshot(dbName)).notificationCount).toBe(0);
138+
});
139+
});
140+
});

0 commit comments

Comments
 (0)