Skip to content

Commit 338d445

Browse files
authored
fix: memory leak during remount or user change (#2639)
* fix: memory leak during remount or user change * fix: add additional unsubscribe on unmount * chore: remove console.log * chore: fix mounting hook and add tests * fix: improve tests and fix bug with remounting * chore: remove dead variable * fix: linter errors
1 parent 68f0ec1 commit 338d445

File tree

4 files changed

+100
-5
lines changed

4 files changed

+100
-5
lines changed

package/src/components/Chat/Chat.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,14 +204,21 @@ const ChatWithContext = <
204204

205205
useEffect(() => {
206206
if (userID && enableOfflineSupport) {
207+
// This acts as a lock for some very rare occurrences of concurrency
208+
// issues we've encountered before with the QuickSqliteClient being
209+
// uninitialized before it's being invoked.
207210
setInitialisedDatabaseConfig({ initialised: false, userID });
208211
QuickSqliteClient.initializeDatabase();
209-
DBSyncManager.init(client as unknown as StreamChat);
210212
setInitialisedDatabaseConfig({ initialised: true, userID });
213+
DBSyncManager.init(client as unknown as StreamChat);
211214
}
212215
// eslint-disable-next-line react-hooks/exhaustive-deps
213216
}, [userID, enableOfflineSupport]);
214217

218+
// In case something went wrong, make sure to also unsubscribe the listener
219+
// on unmount if it exists to prevent a memory leak.
220+
useEffect(() => () => DBSyncManager.connectionChangedListener?.unsubscribe(), []);
221+
215222
const initialisedDatabase =
216223
initialisedDatabaseConfig.initialised && userID === initialisedDatabaseConfig.userID;
217224

package/src/components/Chat/__tests__/Chat.test.js

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import { useChatContext } from '../../../contexts/chatContext/ChatContext';
99
import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext';
1010
import dispatchConnectionChangedEvent from '../../../mock-builders/event/connectionChanged';
1111
import dispatchConnectionRecoveredEvent from '../../../mock-builders/event/connectionRecovered';
12-
import { getTestClient } from '../../../mock-builders/mock';
12+
import { getTestClient, getTestClientWithUser, setUser } from '../../../mock-builders/mock';
13+
import { DBSyncManager } from '../../../utils/DBSyncManager';
1314
import { Streami18n } from '../../../utils/i18n/Streami18n';
1415
import { Chat } from '../Chat';
1516

@@ -24,7 +25,10 @@ const TranslationContextConsumer = ({ fn }) => {
2425
};
2526

2627
describe('Chat', () => {
27-
afterEach(cleanup);
28+
afterEach(() => {
29+
cleanup();
30+
jest.clearAllMocks();
31+
});
2832
const chatClient = getTestClient();
2933

3034
it('renders children without crashing', async () => {
@@ -215,5 +219,79 @@ describe('Chat', () => {
215219
expect(context.tDateTimeParser).toBe(newI18nInstance.tDateTimeParser);
216220
});
217221
});
222+
223+
it('makes sure DBSyncManager listeners are cleaned up after Chat remount', async () => {
224+
const chatClientWithUser = await getTestClientWithUser({ id: 'testID' });
225+
jest.spyOn(DBSyncManager, 'init');
226+
227+
// initial mount and render
228+
const { rerender } = render(
229+
<Chat client={chatClientWithUser} enableOfflineSupport key={1} />,
230+
);
231+
232+
// the unsubscribe fn changes during init(), so we keep a reference to the spy
233+
const unsubscribeSpy = jest.spyOn(DBSyncManager.connectionChangedListener, 'unsubscribe');
234+
const listenersAfterInitialMount = chatClientWithUser.listeners['connection.changed'];
235+
236+
// remount
237+
rerender(<Chat client={chatClientWithUser} enableOfflineSupport key={2} />);
238+
239+
await waitFor(() => {
240+
expect(DBSyncManager.init).toHaveBeenCalledTimes(2);
241+
expect(unsubscribeSpy).toHaveBeenCalledTimes(2);
242+
expect(chatClientWithUser.listeners['connection.changed'].length).toBe(
243+
listenersAfterInitialMount.length,
244+
);
245+
});
246+
});
247+
248+
it('makes sure DBSyncManager listeners are cleaned up if the user changes', async () => {
249+
const chatClientWithUser = await getTestClientWithUser({ id: 'testID1' });
250+
jest.spyOn(DBSyncManager, 'init');
251+
252+
// initial render
253+
const { rerender } = render(<Chat client={chatClientWithUser} enableOfflineSupport />);
254+
255+
// the unsubscribe fn changes during init(), so we keep a reference to the spy
256+
const unsubscribeSpy = jest.spyOn(DBSyncManager.connectionChangedListener, 'unsubscribe');
257+
await act(async () => {
258+
await setUser(chatClientWithUser, { id: 'testID2' });
259+
});
260+
const listenersAfterInitialMount = chatClientWithUser.listeners['connection.changed'];
261+
262+
// rerender with different user ID
263+
rerender(<Chat client={chatClientWithUser} enableOfflineSupport />);
264+
265+
await waitFor(() => {
266+
expect(DBSyncManager.init).toHaveBeenCalledTimes(2);
267+
expect(unsubscribeSpy).toHaveBeenCalledTimes(1);
268+
expect(chatClientWithUser.listeners['connection.changed'].length).toBe(
269+
listenersAfterInitialMount.length,
270+
);
271+
});
272+
});
273+
274+
it('makes sure DBSyncManager state stays intact during normal rerenders', async () => {
275+
const chatClientWithUser = await getTestClientWithUser({ id: 'testID' });
276+
jest.spyOn(DBSyncManager, 'init');
277+
278+
// initial render
279+
const { rerender } = render(<Chat client={chatClientWithUser} enableOfflineSupport />);
280+
281+
// the unsubscribe fn changes during init(), so we keep a reference to the spy
282+
const unsubscribeSpy = jest.spyOn(DBSyncManager.connectionChangedListener, 'unsubscribe');
283+
const listenersAfterInitialMount = chatClientWithUser.listeners['connection.changed'];
284+
285+
// rerender
286+
rerender(<Chat client={chatClientWithUser} enableOfflineSupport />);
287+
288+
await waitFor(() => {
289+
expect(DBSyncManager.init).toHaveBeenCalledTimes(1);
290+
expect(unsubscribeSpy).toHaveBeenCalledTimes(0);
291+
expect(chatClientWithUser.listeners['connection.changed'].length).toBe(
292+
listenersAfterInitialMount.length,
293+
);
294+
});
295+
});
218296
});
219297
});

package/src/mock-builders/mock.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { StreamChat } from 'stream-chat';
66
const apiKey = 'API_KEY';
77
const token = 'dummy_token';
88

9-
const setUser = (client, user) =>
9+
export const setUser = (client, user) =>
1010
new Promise((resolve) => {
1111
client.connectionId = 'dumm_connection_id';
1212
client.user = user;

package/src/utils/DBSyncManager.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export class DBSyncManager {
3838
static syncStatus = false;
3939
static listeners: Array<(status: boolean) => void> = [];
4040
static client: StreamChat | null = null;
41+
static connectionChangedListener: { unsubscribe: () => void } | null = null;
4142

4243
/**
4344
* Returns weather channel states in local DB are synced with backend or not.
@@ -62,7 +63,16 @@ export class DBSyncManager {
6263
this.listeners.forEach((l) => l(true));
6364
}
6465

65-
this.client.on('connection.changed', async (event) => {
66+
// If a listener has already been registered, unsubscribe from it so
67+
// that it can be reinstated. This can happen if we reconnect with a
68+
// different user or the component invoking the init() function gets
69+
// unmounted and then remounted again. This part of the code makes
70+
// sure the stale listener doesn't produce a memory leak.
71+
if (this.connectionChangedListener) {
72+
this.connectionChangedListener.unsubscribe();
73+
}
74+
75+
this.connectionChangedListener = this.client.on('connection.changed', async (event) => {
6676
if (event.online) {
6777
await this.syncAndExecutePendingTasks();
6878
this.syncStatus = true;

0 commit comments

Comments
 (0)