Skip to content

Commit d343373

Browse files
authored
Add room connect disconnect hack (#1199)
1 parent 1406465 commit d343373

File tree

4 files changed

+186
-0
lines changed

4 files changed

+186
-0
lines changed

.changeset/real-words-buy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@livekit/components-react': patch
3+
---
4+
5+
add useSequentialRoomConnectDisconnect to fix react useEffect room connection issue

packages/react/etc/components-react.api.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,15 @@ export interface UseRoomInfoOptions {
10781078
room?: Room;
10791079
}
10801080

1081+
// @public
1082+
export function useSequentialRoomConnectDisconnect<R extends Room | undefined>(room: R): UseSequentialRoomConnectDisconnectResults<R>;
1083+
1084+
// @public (undocumented)
1085+
export type UseSequentialRoomConnectDisconnectResults<R extends Room | undefined> = {
1086+
connect: typeof Room.prototype.connect & (R extends undefined ? null : unknown);
1087+
disconnect: typeof Room.prototype.disconnect & (R extends undefined ? null : unknown);
1088+
};
1089+
10811090
// @public
10821091
export function useSortedParticipants(participants: Array<Participant>): Participant[];
10831092

packages/react/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,4 @@ export * from './useParticipantAttributes';
5656
export * from './useIsRecording';
5757
export * from './useTextStream';
5858
export * from './useTranscriptions';
59+
export * from './useSequentialRoomConnectDisconnect';
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { Mutex, type Room } from 'livekit-client';
2+
import { useCallback, useEffect, useMemo, useRef } from 'react';
3+
import { log } from '@livekit/components-core';
4+
5+
const CONNECT_DISCONNECT_WARNING_THRESHOLD_QUANTITY = 2;
6+
const CONNECT_DISCONNECT_WARNING_THRESHOLD_MS = 400;
7+
8+
const ROOM_CHANGE_WARNING_THRESHOLD_QUANTITY = 3;
9+
const ROOM_CHANGE_WARNING_THRESHOLD_MS = 1000;
10+
11+
/** @public */
12+
export type UseSequentialRoomConnectDisconnectResults<R extends Room | undefined> = {
13+
connect: typeof Room.prototype.connect & (R extends undefined ? null : unknown);
14+
disconnect: typeof Room.prototype.disconnect & (R extends undefined ? null : unknown);
15+
};
16+
17+
/**
18+
* When calling room.disconnect() as part of a React useEffect cleanup function, it is possible for
19+
* a room.connect(...) in the effect body to start running while the room.disconnect() is still
20+
* running. This hook sequentializes these two operations, so they always happen in order and
21+
* never overlap.
22+
*
23+
* @example
24+
* ```ts
25+
* const { connect, disconnect } = useSequentialRoomConnectDisconnect(room);
26+
*
27+
* // Connecting to a room:
28+
* useEffect(() => {
29+
* connect();
30+
* return () => disconnect();
31+
* }, [connect, disconnect]);
32+
* ```
33+
*
34+
* @public
35+
*/
36+
export function useSequentialRoomConnectDisconnect<R extends Room | undefined>(
37+
room: R,
38+
): UseSequentialRoomConnectDisconnectResults<R> {
39+
const connectDisconnectQueueRef = useRef<
40+
Array<
41+
| {
42+
type: 'connect';
43+
room: Room;
44+
args: Parameters<typeof Room.prototype.connect>;
45+
resolve: (value: Awaited<ReturnType<typeof Room.prototype.connect>>) => void;
46+
reject: (err: Error) => void;
47+
}
48+
| {
49+
type: 'disconnect';
50+
room: Room;
51+
args: Parameters<typeof Room.prototype.disconnect>;
52+
resolve: (value: Awaited<ReturnType<typeof Room.prototype.disconnect>>) => void;
53+
reject: (err: Error) => void;
54+
}
55+
>
56+
>([]);
57+
58+
// Process room connection / disconnection events and execute them in series
59+
// The main queue is a ref, so one invocation of this function can continue to process newly added
60+
// events
61+
const processConnectsAndDisconnectsLock = useMemo(() => new Mutex(), []);
62+
const processConnectsAndDisconnects = useCallback(async () => {
63+
return processConnectsAndDisconnectsLock.lock().then(async (unlock) => {
64+
while (true) {
65+
const message = connectDisconnectQueueRef.current.pop();
66+
if (!message) {
67+
unlock();
68+
break;
69+
}
70+
71+
switch (message.type) {
72+
case 'connect':
73+
await message.room
74+
.connect(...message.args)
75+
.then(message.resolve)
76+
.catch(message.reject);
77+
break;
78+
case 'disconnect':
79+
await message.room
80+
.disconnect(...message.args)
81+
.then(message.resolve)
82+
.catch(message.reject);
83+
break;
84+
}
85+
}
86+
});
87+
}, []);
88+
89+
const roomChangedTimesRef = useRef<Array<Date>>([]);
90+
const checkRoomThreshold = useCallback((now: Date) => {
91+
let roomChangesInThreshold = 0;
92+
roomChangedTimesRef.current = roomChangedTimesRef.current.filter((i) => {
93+
const isWithinThreshold = now.getTime() - i.getTime() < ROOM_CHANGE_WARNING_THRESHOLD_MS;
94+
if (isWithinThreshold) {
95+
roomChangesInThreshold += 1;
96+
}
97+
return isWithinThreshold;
98+
});
99+
100+
if (roomChangesInThreshold > ROOM_CHANGE_WARNING_THRESHOLD_QUANTITY) {
101+
log.warn(
102+
`useSequentialRoomConnectDisconnect: room changed reference rapidly (over ${ROOM_CHANGE_WARNING_THRESHOLD_QUANTITY}x in ${ROOM_CHANGE_WARNING_THRESHOLD_MS}ms). This is not recommended.`,
103+
);
104+
}
105+
}, []);
106+
107+
// When the room changes, clear any pending connect / disconnect calls and log when it happened
108+
useEffect(() => {
109+
connectDisconnectQueueRef.current = [];
110+
111+
const now = new Date();
112+
roomChangedTimesRef.current.push(now);
113+
checkRoomThreshold(now);
114+
}, [room, checkRoomThreshold]);
115+
116+
const connectDisconnectEnqueueTimes = useRef<Array<Date>>([]);
117+
const checkConnectDisconnectThreshold = useCallback((now: Date) => {
118+
let connectDisconnectsInThreshold = 0;
119+
connectDisconnectEnqueueTimes.current = connectDisconnectEnqueueTimes.current.filter((i) => {
120+
const isWithinThreshold =
121+
now.getTime() - i.getTime() < CONNECT_DISCONNECT_WARNING_THRESHOLD_MS;
122+
if (isWithinThreshold) {
123+
connectDisconnectsInThreshold += 1;
124+
}
125+
return isWithinThreshold;
126+
});
127+
128+
if (connectDisconnectsInThreshold > CONNECT_DISCONNECT_WARNING_THRESHOLD_QUANTITY) {
129+
log.warn(
130+
`useSequentialRoomConnectDisconnect: room connect / disconnect occurring in rapid sequence (over ${CONNECT_DISCONNECT_WARNING_THRESHOLD_QUANTITY}x in ${CONNECT_DISCONNECT_WARNING_THRESHOLD_MS}ms). This is not recommended and may be the sign of a bug like a useEffect dependency changing every render.`,
131+
);
132+
}
133+
}, []);
134+
135+
const connect = useCallback(
136+
async (...args: Parameters<typeof Room.prototype.connect>) => {
137+
return new Promise((resolve, reject) => {
138+
if (!room) {
139+
throw new Error('Called connect(), but room was unset');
140+
}
141+
const now = new Date();
142+
checkConnectDisconnectThreshold(now);
143+
connectDisconnectQueueRef.current.push({ type: 'connect', room, args, resolve, reject });
144+
connectDisconnectEnqueueTimes.current.push(now);
145+
processConnectsAndDisconnects();
146+
});
147+
},
148+
[room, checkConnectDisconnectThreshold, processConnectsAndDisconnects],
149+
);
150+
151+
const disconnect = useCallback(
152+
async (...args: Parameters<typeof Room.prototype.disconnect>) => {
153+
return new Promise((resolve, reject) => {
154+
if (!room) {
155+
throw new Error('Called discconnect(), but room was unset');
156+
}
157+
const now = new Date();
158+
checkConnectDisconnectThreshold(now);
159+
connectDisconnectQueueRef.current.push({ type: 'disconnect', room, args, resolve, reject });
160+
connectDisconnectEnqueueTimes.current.push(now);
161+
processConnectsAndDisconnects();
162+
});
163+
},
164+
[room, checkConnectDisconnectThreshold, processConnectsAndDisconnects],
165+
);
166+
167+
return {
168+
connect: room ? connect : null,
169+
disconnect: room ? disconnect : null,
170+
} as UseSequentialRoomConnectDisconnectResults<R>;
171+
}

0 commit comments

Comments
 (0)