From 328a37de6f5643909bfcd87165b05370221aa024 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 12 Nov 2025 10:45:53 +0200 Subject: [PATCH 1/5] fix: make session.waitUntilConnected more stable It had a dependency on `room.connectionState` so that it could bail if the state was the expected state when being initially called, but this had the side effect of causing it to change reference often. So, cache this in a ref instead. --- packages/react/src/hooks/useSession.ts | 76 ++++++++++++++++---------- 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/packages/react/src/hooks/useSession.ts b/packages/react/src/hooks/useSession.ts index e29f524e5..f1d030ca8 100644 --- a/packages/react/src/hooks/useSession.ts +++ b/packages/react/src/hooks/useSession.ts @@ -185,6 +185,51 @@ 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(`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); + }); + }, + [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,36 +478,7 @@ 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( async (signal?: AbortSignal) => { From 4055f2c644ae8b107fb0c254815cc07b40fc3958 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 12 Nov 2025 10:44:22 +0200 Subject: [PATCH 2/5] fix: make agent.waitUntilAvailable more stable It had a dependency on `state` so that it could bail if the state was the expected state when being initially called, but this had the side effect of causing it to change reference often. So, cache this in a ref instead. --- packages/react/src/hooks/useAgent.ts | 209 +++++++++++++------------ packages/react/src/hooks/useSession.ts | 5 +- 2 files changed, 115 insertions(+), 99 deletions(-) diff --git a/packages/react/src/hooks/useAgent.ts b/packages/react/src/hooks/useAgent.ts index b80201d13..a2c0f358f 100644 --- a/packages/react/src/hooks/useAgent.ts +++ b/packages/react/src/hooks/useAgent.ts @@ -345,6 +345,116 @@ 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 +785,7 @@ 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) => { diff --git a/packages/react/src/hooks/useSession.ts b/packages/react/src/hooks/useSession.ts index f1d030ca8..b86235760 100644 --- a/packages/react/src/hooks/useSession.ts +++ b/packages/react/src/hooks/useSession.ts @@ -478,7 +478,10 @@ export function useSession( emitter.emit(SessionEvent.ConnectionStateChanged, conversationState.connectionState); }, [emitter, conversationState.connectionState]); - const waitUntilConnectionState = useSessionWaitUntilConnectionState(emitter, conversationState.connectionState); + const waitUntilConnectionState = useSessionWaitUntilConnectionState( + emitter, + conversationState.connectionState, + ); const waitUntilConnected = React.useCallback( async (signal?: AbortSignal) => { From b91177111934a1adcbfa7e04598bba7531f8b406 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 12 Nov 2025 11:14:29 +0200 Subject: [PATCH 3/5] fix: adjust signal abort error messages across useSession / useAgent to make them consistent --- packages/react/src/hooks/useAgent.ts | 10 +++++----- packages/react/src/hooks/useSession.ts | 6 +++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/react/src/hooks/useAgent.ts b/packages/react/src/hooks/useAgent.ts index a2c0f358f..23ab862ae 100644 --- a/packages/react/src/hooks/useAgent.ts +++ b/packages/react/src/hooks/useAgent.ts @@ -371,7 +371,7 @@ function useAgentWaitUntilDerivedStates(emitter: TypedEventEmitter { cleanup(); - reject(new Error('useAgent.waitUntilConnected - signal aborted')); + reject(new Error('useAgent(/* ... */).waitUntilConnected - signal aborted')); }; const cleanup = () => { @@ -404,7 +404,7 @@ function useAgentWaitUntilDerivedStates(emitter: TypedEventEmitter { cleanup(); - reject(new Error('useAgent.waitUntilCouldBeListening - signal aborted')); + reject(new Error('useAgent(/* ... */).waitUntilCouldBeListening - signal aborted')); }; const cleanup = () => { @@ -437,7 +437,7 @@ function useAgentWaitUntilDerivedStates(emitter: TypedEventEmitter { cleanup(); - reject(new Error('useAgent.waitUntilFinished - signal aborted')); + reject(new Error('useAgent(/* ... */).waitUntilFinished - signal aborted')); }; const cleanup = () => { @@ -799,7 +799,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 = () => { @@ -826,7 +826,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 b86235760..31065ff1e 100644 --- a/packages/react/src/hooks/useSession.ts +++ b/packages/react/src/hooks/useSession.ts @@ -212,7 +212,11 @@ function useSessionWaitUntilConnectionState( }; const abortHandler = () => { cleanup(); - reject(new Error(`AgentSession.waitUntilRoomState(${state}, ...) - signal aborted`)); + reject( + new Error( + `useSession(/* ... */).waitUntilConnectionState(${state}, /* signal */) - signal aborted`, + ), + ); }; const cleanup = () => { From 4923869fbf332ca55f452831a2f8e0d22725d7e4 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 12 Nov 2025 11:20:15 +0200 Subject: [PATCH 4/5] fix: add changeset --- .changeset/sharp-deer-peel.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sharp-deer-peel.md 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 From 1aab4603b758aac4eda407e794fbbd6914a077c9 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 13 Nov 2025 09:00:46 +0200 Subject: [PATCH 5/5] fix: run npm run format --- packages/react/src/hooks/useAgent.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/react/src/hooks/useAgent.ts b/packages/react/src/hooks/useAgent.ts index 23ab862ae..ee8ab99e3 100644 --- a/packages/react/src/hooks/useAgent.ts +++ b/packages/react/src/hooks/useAgent.ts @@ -347,7 +347,10 @@ type SessionStub = Pick, state: AgentState) { +function useAgentWaitUntilDerivedStates( + emitter: TypedEventEmitter, + state: AgentState, +) { const stateRef = React.useRef(state); React.useEffect(() => { stateRef.current = state; @@ -785,7 +788,8 @@ export function useAgent(session?: SessionStub): UseAgentReturn { } }, [agentParticipantAttributes, emitter, agentParticipant, state, videoTrack, audioTrack]); - const { waitUntilConnected, waitUntilCouldBeListening, waitUntilFinished } = useAgentWaitUntilDerivedStates(emitter, state); + const { waitUntilConnected, waitUntilCouldBeListening, waitUntilFinished } = + useAgentWaitUntilDerivedStates(emitter, state); const waitUntilCamera = React.useCallback( (signal?: AbortSignal) => {