diff --git a/.changeset/sharp-deer-peel.md b/.changeset/sharp-deer-peel.md new file mode 100644 index 000000000..9a773e2a6 --- /dev/null +++ b/.changeset/sharp-deer-peel.md @@ -0,0 +1,5 @@ +--- +'@livekit/components-react': patch +--- + +Make useSession().start more stable diff --git a/packages/react/src/hooks/useAgent.ts b/packages/react/src/hooks/useAgent.ts index b80201d13..ee8ab99e3 100644 --- a/packages/react/src/hooks/useAgent.ts +++ b/packages/react/src/hooks/useAgent.ts @@ -345,6 +345,119 @@ export const useAgentTimeoutIdStore = (): { type SessionStub = Pick; +/** Internal hook used by useAgent which generates a function that when called, will return a + * promise which resolves when agent.isAvailable is enabled. */ +function useAgentWaitUntilDerivedStates( + emitter: TypedEventEmitter, + state: AgentState, +) { + const stateRef = React.useRef(state); + React.useEffect(() => { + stateRef.current = state; + }, [state]); + + const waitUntilConnected = React.useCallback( + async (signal?: AbortSignal) => { + const { isConnected } = generateDerivedStateValues(stateRef.current); + if (isConnected) { + return; + } + + return new Promise((resolve, reject) => { + const stateChangedHandler = (state: AgentState) => { + const { isConnected } = generateDerivedStateValues(state); + if (!isConnected) { + return; + } + cleanup(); + resolve(); + }; + const abortHandler = () => { + cleanup(); + reject(new Error('useAgent(/* ... */).waitUntilConnected - signal aborted')); + }; + + const cleanup = () => { + emitter.off(AgentEvent.StateChanged, stateChangedHandler); + signal?.removeEventListener('abort', abortHandler); + }; + + emitter.on(AgentEvent.StateChanged, stateChangedHandler); + signal?.addEventListener('abort', abortHandler); + }); + }, + [emitter], + ); + + const waitUntilCouldBeListening = React.useCallback( + async (signal?: AbortSignal) => { + const { canListen } = generateDerivedStateValues(stateRef.current); + if (canListen) { + return; + } + + return new Promise((resolve, reject) => { + const stateChangedHandler = (state: AgentState) => { + const { canListen } = generateDerivedStateValues(state); + if (!canListen) { + return; + } + cleanup(); + resolve(); + }; + const abortHandler = () => { + cleanup(); + reject(new Error('useAgent(/* ... */).waitUntilCouldBeListening - signal aborted')); + }; + + const cleanup = () => { + emitter.off(AgentEvent.StateChanged, stateChangedHandler); + signal?.removeEventListener('abort', abortHandler); + }; + + emitter.on(AgentEvent.StateChanged, stateChangedHandler); + signal?.addEventListener('abort', abortHandler); + }); + }, + [emitter], + ); + + const waitUntilFinished = React.useCallback( + async (signal?: AbortSignal) => { + const { isFinished } = generateDerivedStateValues(stateRef.current); + if (isFinished) { + return; + } + + return new Promise((resolve, reject) => { + const stateChangedHandler = (state: AgentState) => { + const { isFinished } = generateDerivedStateValues(state); + if (!isFinished) { + return; + } + cleanup(); + resolve(); + }; + const abortHandler = () => { + cleanup(); + reject(new Error('useAgent(/* ... */).waitUntilFinished - signal aborted')); + }; + + const cleanup = () => { + emitter.off(AgentEvent.StateChanged, stateChangedHandler); + signal?.removeEventListener('abort', abortHandler); + }; + + emitter.on(AgentEvent.StateChanged, stateChangedHandler); + signal?.addEventListener('abort', abortHandler); + }); + }, + [emitter], + ); + + return { waitUntilConnected, waitUntilCouldBeListening, waitUntilFinished }; +} + /** * useAgent encapculates all agent state, normalizing some quirks around how LiveKit Agents work. * @public @@ -675,104 +788,8 @@ export function useAgent(session?: SessionStub): UseAgentReturn { } }, [agentParticipantAttributes, emitter, agentParticipant, state, videoTrack, audioTrack]); - const waitUntilConnected = React.useCallback( - async (signal?: AbortSignal) => { - const { isConnected } = generateDerivedStateValues(state); - if (isConnected) { - return; - } - - return new Promise((resolve, reject) => { - const stateChangedHandler = (state: AgentState) => { - const { isConnected } = generateDerivedStateValues(state); - if (!isConnected) { - return; - } - cleanup(); - resolve(); - }; - const abortHandler = () => { - cleanup(); - reject(new Error('useAgent.waitUntilConnected - signal aborted')); - }; - - const cleanup = () => { - emitter.off(AgentEvent.StateChanged, stateChangedHandler); - signal?.removeEventListener('abort', abortHandler); - }; - - emitter.on(AgentEvent.StateChanged, stateChangedHandler); - signal?.addEventListener('abort', abortHandler); - }); - }, - [state, emitter], - ); - - const waitUntilCouldBeListening = React.useCallback( - async (signal?: AbortSignal) => { - const { canListen } = generateDerivedStateValues(state); - if (canListen) { - return; - } - - return new Promise((resolve, reject) => { - const stateChangedHandler = (state: AgentState) => { - const { canListen } = generateDerivedStateValues(state); - if (!canListen) { - return; - } - cleanup(); - resolve(); - }; - const abortHandler = () => { - cleanup(); - reject(new Error('useAgent.waitUntilCouldBeListening - signal aborted')); - }; - - const cleanup = () => { - emitter.off(AgentEvent.StateChanged, stateChangedHandler); - signal?.removeEventListener('abort', abortHandler); - }; - - emitter.on(AgentEvent.StateChanged, stateChangedHandler); - signal?.addEventListener('abort', abortHandler); - }); - }, - [state, emitter], - ); - - const waitUntilFinished = React.useCallback( - async (signal?: AbortSignal) => { - const { isFinished } = generateDerivedStateValues(state); - if (isFinished) { - return; - } - - return new Promise((resolve, reject) => { - const stateChangedHandler = (state: AgentState) => { - const { isFinished } = generateDerivedStateValues(state); - if (!isFinished) { - return; - } - cleanup(); - resolve(); - }; - const abortHandler = () => { - cleanup(); - reject(new Error('useAgent.waitUntilFinished - signal aborted')); - }; - - const cleanup = () => { - emitter.off(AgentEvent.StateChanged, stateChangedHandler); - signal?.removeEventListener('abort', abortHandler); - }; - - emitter.on(AgentEvent.StateChanged, stateChangedHandler); - signal?.addEventListener('abort', abortHandler); - }); - }, - [state, emitter], - ); + const { waitUntilConnected, waitUntilCouldBeListening, waitUntilFinished } = + useAgentWaitUntilDerivedStates(emitter, state); const waitUntilCamera = React.useCallback( (signal?: AbortSignal) => { @@ -786,7 +803,7 @@ export function useAgent(session?: SessionStub): UseAgentReturn { }; const abortHandler = () => { cleanup(); - reject(new Error('useAgent.waitUntilCamera - signal aborted')); + reject(new Error('useAgent(/* ... */).waitUntilCamera - signal aborted')); }; const cleanup = () => { @@ -813,7 +830,7 @@ export function useAgent(session?: SessionStub): UseAgentReturn { }; const abortHandler = () => { cleanup(); - reject(new Error('useAgent.waitUntilMicrophone - signal aborted')); + reject(new Error('useAgent(/* ... */).waitUntilMicrophone - signal aborted')); }; const cleanup = () => { diff --git a/packages/react/src/hooks/useSession.ts b/packages/react/src/hooks/useSession.ts index e29f524e5..31065ff1e 100644 --- a/packages/react/src/hooks/useSession.ts +++ b/packages/react/src/hooks/useSession.ts @@ -185,6 +185,55 @@ function areTokenSourceFetchOptionsEqual(a: TokenSourceFetchOptions, b: TokenSou return true; } +/** Internal hook used by useSession to manage creating a function which can be used to wait + * until the session is in a given state before resolving. */ +function useSessionWaitUntilConnectionState( + emitter: TypedEventEmitter, + connectionState: UseSessionReturn['connectionState'], +) { + const connectionStateRef = React.useRef(connectionState); + React.useEffect(() => { + connectionStateRef.current = connectionState; + }, [connectionState]); + + const waitUntilConnectionState = React.useCallback( + async (state: UseSessionReturn['connectionState'], signal?: AbortSignal) => { + if (connectionStateRef.current === state) { + return; + } + + return new Promise((resolve, reject) => { + const onceEventOccurred = (newState: UseSessionReturn['connectionState']) => { + if (newState !== state) { + return; + } + cleanup(); + resolve(); + }; + const abortHandler = () => { + cleanup(); + reject( + new Error( + `useSession(/* ... */).waitUntilConnectionState(${state}, /* signal */) - signal aborted`, + ), + ); + }; + + const cleanup = () => { + emitter.off(SessionEvent.ConnectionStateChanged, onceEventOccurred); + signal?.removeEventListener('abort', abortHandler); + }; + + emitter.on(SessionEvent.ConnectionStateChanged, onceEventOccurred); + signal?.addEventListener('abort', abortHandler); + }); + }, + [emitter], + ); + + return waitUntilConnectionState; +} + /** Internal hook used by useSession to manage creating a function that properly invokes * tokenSource.fetch(...) with any fetch options */ function useSessionTokenSourceFetch( @@ -433,35 +482,9 @@ export function useSession( emitter.emit(SessionEvent.ConnectionStateChanged, conversationState.connectionState); }, [emitter, conversationState.connectionState]); - const waitUntilConnectionState = React.useCallback( - async (state: UseSessionReturn['connectionState'], signal?: AbortSignal) => { - if (conversationState.connectionState === state) { - return; - } - - return new Promise((resolve, reject) => { - const onceEventOccurred = (newState: UseSessionReturn['connectionState']) => { - if (newState !== state) { - return; - } - cleanup(); - resolve(); - }; - const abortHandler = () => { - cleanup(); - reject(new Error(`AgentSession.waitUntilRoomState(${state}, ...) - signal aborted`)); - }; - - const cleanup = () => { - emitter.off(SessionEvent.ConnectionStateChanged, onceEventOccurred); - signal?.removeEventListener('abort', abortHandler); - }; - - emitter.on(SessionEvent.ConnectionStateChanged, onceEventOccurred); - signal?.addEventListener('abort', abortHandler); - }); - }, - [conversationState.connectionState, emitter], + const waitUntilConnectionState = useSessionWaitUntilConnectionState( + emitter, + conversationState.connectionState, ); const waitUntilConnected = React.useCallback(