Skip to content

Commit da389be

Browse files
authored
fix: re-subscribe room streams on DDP reconnect (#7426)
1 parent e08ffb1 commit da389be

2 files changed

Lines changed: 284 additions & 3 deletions

File tree

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import RoomSubscription from './room';
2+
import { loadMissedMessages } from '../loadMissedMessages';
3+
import { clearUserTyping } from '../../../actions/usersTyping';
4+
5+
const mockSubscribeRoom = jest.fn<Promise<unknown[]>, [string]>(() => Promise.resolve([]));
6+
const mockOnStreamData = jest.fn<Promise<{ stop: jest.Mock }>, [string, (...args: unknown[]) => void]>(() =>
7+
Promise.resolve({ stop: jest.fn() })
8+
);
9+
jest.mock('../../services/sdk', () => ({
10+
__esModule: true,
11+
default: {
12+
subscribeRoom: (rid: string) => mockSubscribeRoom(rid),
13+
onStreamData: (event: string, cb: (...args: unknown[]) => void) => mockOnStreamData(event, cb)
14+
}
15+
}));
16+
17+
const mockStoreGetState = jest.fn<{ meteor: { connected: boolean } }, []>(() => ({
18+
meteor: { connected: false }
19+
}));
20+
const mockStoreDispatch = jest.fn<unknown, [unknown]>();
21+
jest.mock('../../store/auxStore', () => ({
22+
store: {
23+
getState: () => mockStoreGetState(),
24+
dispatch: (action: unknown) => mockStoreDispatch(action)
25+
}
26+
}));
27+
28+
jest.mock('../loadMissedMessages', () => ({
29+
loadMissedMessages: jest.fn<Promise<void>, [unknown]>(() => Promise.resolve())
30+
}));
31+
32+
jest.mock('../readMessages', () => ({
33+
readMessages: jest.fn()
34+
}));
35+
36+
jest.mock('../helpers/log', () => ({
37+
__esModule: true,
38+
default: jest.fn()
39+
}));
40+
41+
jest.mock('../helpers', () => ({
42+
debounce: (fn: (...args: unknown[]) => unknown) => fn,
43+
compareServerVersion: jest.fn()
44+
}));
45+
46+
jest.mock('../helpers/protectedFunction', () => ({
47+
__esModule: true,
48+
default: (fn: (...args: unknown[]) => unknown) => fn
49+
}));
50+
51+
jest.mock('../helpers/buildMessage', () => ({
52+
__esModule: true,
53+
default: (msg: unknown) => msg
54+
}));
55+
56+
jest.mock('../helpers/markMessagesRead', () => ({
57+
__esModule: true,
58+
default: jest.fn()
59+
}));
60+
61+
jest.mock('../updateLastOpen', () => ({
62+
updateLastOpen: jest.fn()
63+
}));
64+
65+
jest.mock('../../../actions/usersTyping', () => ({
66+
addUserTyping: jest.fn(),
67+
clearUserTyping: jest.fn().mockReturnValue({ type: 'CLEAR_USER_TYPING' }),
68+
removeUserTyping: jest.fn()
69+
}));
70+
71+
jest.mock('../../../actions/room', () => ({
72+
subscribeRoom: jest.fn().mockReturnValue({ type: 'SUBSCRIBE_ROOM' }),
73+
unsubscribeRoom: jest.fn().mockReturnValue({ type: 'UNSUBSCRIBE_ROOM' })
74+
}));
75+
76+
jest.mock('../../encryption', () => ({
77+
Encryption: {
78+
decryptMessage: jest.fn((msg: unknown) => Promise.resolve(msg)),
79+
decryptPendingSubscriptions: jest.fn(),
80+
decryptPendingMessages: jest.fn(),
81+
getRoomInstance: jest.fn(),
82+
stopRoom: jest.fn()
83+
}
84+
}));
85+
86+
const mockDbBatch = jest.fn().mockResolvedValue(undefined);
87+
const mockDbGet = jest.fn();
88+
jest.mock('../../database', () => {
89+
const mockModel = {
90+
prepareCreate: jest.fn(() => ({})),
91+
prepareUpdate: jest.fn(() => ({})),
92+
prepareDestroyPermanently: jest.fn(() => ({})),
93+
schema: {}
94+
};
95+
return {
96+
__esModule: true,
97+
default: {
98+
active: {
99+
get: (...args: unknown[]) => mockDbGet(...args) ?? mockModel,
100+
write: jest.fn((callback: () => Promise<void>) => callback()),
101+
batch: (...args: unknown[]) => mockDbBatch(...args)
102+
}
103+
}
104+
};
105+
});
106+
107+
jest.mock('../../database/services/Message', () => ({
108+
getMessageById: jest.fn()
109+
}));
110+
111+
jest.mock('../../database/services/Thread', () => ({
112+
getThreadById: jest.fn()
113+
}));
114+
115+
jest.mock('../../database/services/ThreadMessage', () => ({
116+
getThreadMessageById: jest.fn()
117+
}));
118+
119+
describe('RoomSubscription', () => {
120+
const rid = 'test-room-id';
121+
let sub: RoomSubscription;
122+
123+
beforeEach(() => {
124+
jest.clearAllMocks();
125+
mockSubscribeRoom.mockResolvedValue([]);
126+
sub = new RoomSubscription(rid);
127+
});
128+
129+
afterEach(() => {
130+
mockSubscribeRoom.mockReset();
131+
});
132+
133+
describe('subscribe', () => {
134+
it('calls subscribeRoom exactly once on initial entry (no duplicate from connected listener)', async () => {
135+
await sub.subscribe();
136+
137+
expect(mockSubscribeRoom).toHaveBeenCalledTimes(1);
138+
expect(mockSubscribeRoom).toHaveBeenCalledWith(rid);
139+
});
140+
});
141+
142+
describe('handleConnected', () => {
143+
it('calls subscribeRoom, dispatches clearUserTyping, loads missed messages, and reads', async () => {
144+
await sub.handleConnected();
145+
146+
expect(mockSubscribeRoom).toHaveBeenCalledWith(rid);
147+
expect(mockStoreDispatch).toHaveBeenCalledWith(clearUserTyping());
148+
expect(loadMissedMessages).toHaveBeenCalledWith({ rid });
149+
});
150+
151+
it('handles subscribeRoom rejection gracefully', async () => {
152+
mockSubscribeRoom.mockRejectedValueOnce(new Error('boom'));
153+
154+
await expect(sub.handleConnected()).resolves.toBeUndefined();
155+
});
156+
});
157+
158+
describe('handleClose', () => {
159+
it('does not call subscribeRoom or loadMissedMessages, but dispatches clearUserTyping', async () => {
160+
await sub.handleClose();
161+
162+
expect(mockSubscribeRoom).not.toHaveBeenCalled();
163+
expect(loadMissedMessages).not.toHaveBeenCalled();
164+
expect(mockStoreDispatch).toHaveBeenCalledWith(clearUserTyping());
165+
});
166+
});
167+
168+
describe('DDP subscription recovery after forceReopen', () => {
169+
it('handleConnected re-subscribes the room to restore lost DDP subscriptions', async () => {
170+
await sub.subscribe();
171+
mockSubscribeRoom.mockClear();
172+
173+
await sub.handleConnected();
174+
175+
expect(mockSubscribeRoom).toHaveBeenCalledTimes(1);
176+
expect(mockSubscribeRoom).toHaveBeenCalledWith(rid);
177+
});
178+
179+
it('handleClose does NOT re-subscribe (only reconnects restore subscriptions, not disconnects)', async () => {
180+
await sub.subscribe();
181+
mockSubscribeRoom.mockClear();
182+
183+
await sub.handleClose();
184+
185+
expect(mockSubscribeRoom).not.toHaveBeenCalled();
186+
});
187+
188+
it('tears down stale subscriptions on reconnect and tracks fresh ones for later cleanup', async () => {
189+
const staleSub = { unsubscribe: jest.fn(() => Promise.resolve()) };
190+
const freshSub = { unsubscribe: jest.fn(() => Promise.resolve()) };
191+
mockSubscribeRoom.mockResolvedValueOnce([staleSub]).mockResolvedValueOnce([freshSub]);
192+
193+
await sub.subscribe();
194+
await sub.handleConnected();
195+
await sub.unsubscribe();
196+
197+
expect(staleSub.unsubscribe).toHaveBeenCalledTimes(1);
198+
expect(freshSub.unsubscribe).toHaveBeenCalledTimes(1);
199+
});
200+
201+
it('does not accumulate subscriptions across repeated handleConnected calls (simulates sequential reopen)', async () => {
202+
const first = { unsubscribe: jest.fn(() => Promise.resolve()) };
203+
const second = { unsubscribe: jest.fn(() => Promise.resolve()) };
204+
mockSubscribeRoom.mockResolvedValueOnce([first]).mockResolvedValueOnce([second]);
205+
206+
await sub.subscribe();
207+
expect(mockSubscribeRoom).toHaveBeenCalledTimes(1);
208+
209+
// First reopen → tears down [first], creates [second]
210+
await sub.handleConnected();
211+
expect(mockSubscribeRoom).toHaveBeenCalledTimes(2);
212+
expect(first.unsubscribe).toHaveBeenCalledTimes(1);
213+
expect(second.unsubscribe).not.toHaveBeenCalled();
214+
215+
// Second reopen → tears down [second], creates []
216+
await sub.handleConnected();
217+
expect(mockSubscribeRoom).toHaveBeenCalledTimes(3);
218+
expect(second.unsubscribe).toHaveBeenCalledTimes(1);
219+
220+
// Final cleanup → empty batch, no more unsubscribes
221+
await sub.unsubscribe();
222+
expect(first.unsubscribe).toHaveBeenCalledTimes(1);
223+
expect(second.unsubscribe).toHaveBeenCalledTimes(1);
224+
});
225+
226+
it('does not call onStreamData inside handleConnected (listeners persist across reopen)', async () => {
227+
await sub.subscribe();
228+
mockOnStreamData.mockClear();
229+
230+
await sub.handleConnected();
231+
232+
expect(mockOnStreamData).not.toHaveBeenCalled();
233+
});
234+
});
235+
236+
describe('isAlive guard', () => {
237+
it('handleConnected does nothing once the subscription is no longer alive (race with unsubscribe)', async () => {
238+
await sub.subscribe();
239+
await sub.unsubscribe();
240+
jest.clearAllMocks();
241+
242+
await sub.handleConnected();
243+
244+
expect(mockSubscribeRoom).not.toHaveBeenCalled();
245+
expect(loadMissedMessages).not.toHaveBeenCalled();
246+
expect(mockStoreDispatch).not.toHaveBeenCalled();
247+
});
248+
249+
it('handleConnected re-subscribes while the subscription is still alive', async () => {
250+
await sub.subscribe();
251+
mockSubscribeRoom.mockClear();
252+
253+
await sub.handleConnected();
254+
255+
expect(mockSubscribeRoom).toHaveBeenCalledWith(rid);
256+
});
257+
});
258+
});

app/lib/methods/subscriptions/room.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ export default class RoomSubscription {
5151
}
5252
this.promises = sdk.subscribeRoom(this.rid);
5353

54-
this.connectedListener = sdk.onStreamData('connected', this.handleConnection);
55-
this.disconnectedListener = sdk.onStreamData('close', this.handleConnection);
54+
this.connectedListener = sdk.onStreamData('connected', this.handleConnected);
55+
this.disconnectedListener = sdk.onStreamData('close', this.handleClose);
5656
this.notifyRoomListener = sdk.onStreamData('stream-notify-room', this.handleNotifyRoomReceived);
5757
this.messageReceivedListener = sdk.onStreamData('stream-room-messages', this.handleMessageReceived);
5858
if (!this.isAlive) {
@@ -93,16 +93,39 @@ export default class RoomSubscription {
9393
}
9494
};
9595

96-
handleConnection = async () => {
96+
handleConnected = async () => {
97+
if (!this.isAlive) {
98+
return;
99+
}
97100
try {
101+
if (this.promises) {
102+
const oldSubs = await this.promises;
103+
oldSubs.forEach(sub => sub.unsubscribe().catch(() => {}));
104+
}
105+
this.promises = sdk.subscribeRoom(this.rid);
106+
await this.promises;
107+
if (!this.isAlive) {
108+
return;
109+
}
98110
reduxStore.dispatch(clearUserTyping());
99111
await loadMissedMessages({ rid: this.rid });
112+
if (!this.isAlive) {
113+
return;
114+
}
100115
this.read();
101116
} catch (e) {
102117
log(e);
103118
}
104119
};
105120

121+
handleClose = () => {
122+
try {
123+
reduxStore.dispatch(clearUserTyping());
124+
} catch (e) {
125+
log(e);
126+
}
127+
};
128+
106129
handleNotifyRoomReceived = protectedFunction(async (ddpMessage: IDDPMessage) => {
107130
const [_rid, ev] = ddpMessage.fields.eventName.split('/');
108131
if (this.rid !== _rid) {

0 commit comments

Comments
 (0)