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; }