diff --git a/.changeset/real-words-buy.md b/.changeset/real-words-buy.md new file mode 100644 index 000000000..d256a426b --- /dev/null +++ b/.changeset/real-words-buy.md @@ -0,0 +1,5 @@ +--- +'@livekit/components-react': patch +--- + +add useSequentialRoomConnectDisconnect to fix react useEffect room connection issue diff --git a/packages/react/etc/components-react.api.md b/packages/react/etc/components-react.api.md index acdf30abf..d4e59c88f 100644 --- a/packages/react/etc/components-react.api.md +++ b/packages/react/etc/components-react.api.md @@ -1078,6 +1078,15 @@ export interface UseRoomInfoOptions { room?: Room; } +// @public +export function useSequentialRoomConnectDisconnect(room: R): UseSequentialRoomConnectDisconnectResults; + +// @public (undocumented) +export type UseSequentialRoomConnectDisconnectResults = { + connect: typeof Room.prototype.connect & (R extends undefined ? null : unknown); + disconnect: typeof Room.prototype.disconnect & (R extends undefined ? null : unknown); +}; + // @public export function useSortedParticipants(participants: Array): Participant[]; diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index cdd9ed342..904f5a890 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -56,3 +56,4 @@ export * from './useParticipantAttributes'; export * from './useIsRecording'; export * from './useTextStream'; export * from './useTranscriptions'; +export * from './useSequentialRoomConnectDisconnect'; diff --git a/packages/react/src/hooks/useSequentialRoomConnectDisconnect.ts b/packages/react/src/hooks/useSequentialRoomConnectDisconnect.ts new file mode 100644 index 000000000..de5b19315 --- /dev/null +++ b/packages/react/src/hooks/useSequentialRoomConnectDisconnect.ts @@ -0,0 +1,171 @@ +import { Mutex, type Room } from 'livekit-client'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { log } from '@livekit/components-core'; + +const CONNECT_DISCONNECT_WARNING_THRESHOLD_QUANTITY = 2; +const CONNECT_DISCONNECT_WARNING_THRESHOLD_MS = 400; + +const ROOM_CHANGE_WARNING_THRESHOLD_QUANTITY = 3; +const ROOM_CHANGE_WARNING_THRESHOLD_MS = 1000; + +/** @public */ +export type UseSequentialRoomConnectDisconnectResults = { + connect: typeof Room.prototype.connect & (R extends undefined ? null : unknown); + disconnect: typeof Room.prototype.disconnect & (R extends undefined ? null : unknown); +}; + +/** + * When calling room.disconnect() as part of a React useEffect cleanup function, it is possible for + * a room.connect(...) in the effect body to start running while the room.disconnect() is still + * running. This hook sequentializes these two operations, so they always happen in order and + * never overlap. + * + * @example + * ```ts + * const { connect, disconnect } = useSequentialRoomConnectDisconnect(room); + * + * // Connecting to a room: + * useEffect(() => { + * connect(); + * return () => disconnect(); + * }, [connect, disconnect]); + * ``` + * + * @public + */ +export function useSequentialRoomConnectDisconnect( + room: R, +): UseSequentialRoomConnectDisconnectResults { + const connectDisconnectQueueRef = useRef< + Array< + | { + type: 'connect'; + room: Room; + args: Parameters; + resolve: (value: Awaited>) => void; + reject: (err: Error) => void; + } + | { + type: 'disconnect'; + room: Room; + args: Parameters; + resolve: (value: Awaited>) => void; + reject: (err: Error) => void; + } + > + >([]); + + // Process room connection / disconnection events and execute them in series + // The main queue is a ref, so one invocation of this function can continue to process newly added + // events + const processConnectsAndDisconnectsLock = useMemo(() => new Mutex(), []); + const processConnectsAndDisconnects = useCallback(async () => { + return processConnectsAndDisconnectsLock.lock().then(async (unlock) => { + while (true) { + const message = connectDisconnectQueueRef.current.pop(); + if (!message) { + unlock(); + break; + } + + switch (message.type) { + case 'connect': + await message.room + .connect(...message.args) + .then(message.resolve) + .catch(message.reject); + break; + case 'disconnect': + await message.room + .disconnect(...message.args) + .then(message.resolve) + .catch(message.reject); + break; + } + } + }); + }, []); + + const roomChangedTimesRef = useRef>([]); + const checkRoomThreshold = useCallback((now: Date) => { + let roomChangesInThreshold = 0; + roomChangedTimesRef.current = roomChangedTimesRef.current.filter((i) => { + const isWithinThreshold = now.getTime() - i.getTime() < ROOM_CHANGE_WARNING_THRESHOLD_MS; + if (isWithinThreshold) { + roomChangesInThreshold += 1; + } + return isWithinThreshold; + }); + + if (roomChangesInThreshold > ROOM_CHANGE_WARNING_THRESHOLD_QUANTITY) { + log.warn( + `useSequentialRoomConnectDisconnect: room changed reference rapidly (over ${ROOM_CHANGE_WARNING_THRESHOLD_QUANTITY}x in ${ROOM_CHANGE_WARNING_THRESHOLD_MS}ms). This is not recommended.`, + ); + } + }, []); + + // When the room changes, clear any pending connect / disconnect calls and log when it happened + useEffect(() => { + connectDisconnectQueueRef.current = []; + + const now = new Date(); + roomChangedTimesRef.current.push(now); + checkRoomThreshold(now); + }, [room, checkRoomThreshold]); + + const connectDisconnectEnqueueTimes = useRef>([]); + const checkConnectDisconnectThreshold = useCallback((now: Date) => { + let connectDisconnectsInThreshold = 0; + connectDisconnectEnqueueTimes.current = connectDisconnectEnqueueTimes.current.filter((i) => { + const isWithinThreshold = + now.getTime() - i.getTime() < CONNECT_DISCONNECT_WARNING_THRESHOLD_MS; + if (isWithinThreshold) { + connectDisconnectsInThreshold += 1; + } + return isWithinThreshold; + }); + + if (connectDisconnectsInThreshold > CONNECT_DISCONNECT_WARNING_THRESHOLD_QUANTITY) { + log.warn( + `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.`, + ); + } + }, []); + + const connect = useCallback( + async (...args: Parameters) => { + return new Promise((resolve, reject) => { + if (!room) { + throw new Error('Called connect(), but room was unset'); + } + const now = new Date(); + checkConnectDisconnectThreshold(now); + connectDisconnectQueueRef.current.push({ type: 'connect', room, args, resolve, reject }); + connectDisconnectEnqueueTimes.current.push(now); + processConnectsAndDisconnects(); + }); + }, + [room, checkConnectDisconnectThreshold, processConnectsAndDisconnects], + ); + + const disconnect = useCallback( + async (...args: Parameters) => { + return new Promise((resolve, reject) => { + if (!room) { + throw new Error('Called discconnect(), but room was unset'); + } + const now = new Date(); + checkConnectDisconnectThreshold(now); + connectDisconnectQueueRef.current.push({ type: 'disconnect', room, args, resolve, reject }); + connectDisconnectEnqueueTimes.current.push(now); + processConnectsAndDisconnects(); + }); + }, + [room, checkConnectDisconnectThreshold, processConnectsAndDisconnects], + ); + + return { + connect: room ? connect : null, + disconnect: room ? disconnect : null, + } as UseSequentialRoomConnectDisconnectResults; +}