From 9d8b7182c3e0d9ca56ad566c135e7b71ebc238b1 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 10 Nov 2025 10:13:41 +0200 Subject: [PATCH 1/8] feat: add logic to get a stable reference to token fetch options --- packages/react/src/hooks/useSession.ts | 71 ++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/react/src/hooks/useSession.ts b/packages/react/src/hooks/useSession.ts index c3c66a7ef..c832fb403 100644 --- a/packages/react/src/hooks/useSession.ts +++ b/packages/react/src/hooks/useSession.ts @@ -154,6 +154,40 @@ type UseSessionCommonOptions = { type UseSessionConfigurableOptions = UseSessionCommonOptions & TokenSourceFetchOptions; type UseSessionFixedOptions = UseSessionCommonOptions; +/** + * Given two TokenSourceFetchOptions values, check to see if they are deep equal. + * + * FIXME: swap this for an import from livekit-client once + * https://github.com/livekit/client-sdk-js/pull/1733 is merged! + * */ +function areTokenSourceFetchOptionsEqual(a: TokenSourceFetchOptions, b: TokenSourceFetchOptions) { + const allKeysSet = new Set([...Object.keys(a), ...Object.keys(b)]) as Set< + keyof TokenSourceFetchOptions + >; + + for (const key of allKeysSet) { + switch (key) { + case 'roomName': + case 'participantName': + case 'participantIdentity': + case 'participantMetadata': + case 'participantAttributes': + case 'agentName': + case 'agentMetadata': + if (a[key] !== b[key]) { + return false; + } + break; + default: + // ref: https://stackoverflow.com/a/58009992 + const exhaustiveCheckedKey: never = key; + throw new Error(`Options key ${exhaustiveCheckedKey} not being checked for equality!`); + } + } + + return true; +} + /** * A Session represents a manages connection to a Room which can contain Agents. * @public @@ -427,15 +461,46 @@ export function useSession( ), ); + const [memoizedTokenFetchOptions, setMemoizedTokenFetchOptions] = + React.useState(() => { + const isConfigurable = tokenSource instanceof TokenSourceConfigurable; + if (isConfigurable) { + return restOptions; + } else { + return null; + } + }); + React.useEffect(() => { + const isConfigurable = tokenSource instanceof TokenSourceConfigurable; + if (!isConfigurable) { + setMemoizedTokenFetchOptions(null); + return; + } + + if ( + memoizedTokenFetchOptions !== null && + areTokenSourceFetchOptionsEqual(memoizedTokenFetchOptions, restOptions) + ) { + return; + } + + const tokenFetchOptions = restOptions as UseSessionConfigurableOptions; + setMemoizedTokenFetchOptions(tokenFetchOptions); + }, [restOptions]); + const tokenSourceFetch = React.useCallback(async () => { const isConfigurable = tokenSource instanceof TokenSourceConfigurable; if (isConfigurable) { - const tokenFetchOptions = restOptions as UseSessionConfigurableOptions; - return tokenSource.fetch(tokenFetchOptions); + if (!memoizedTokenFetchOptions) { + throw new Error( + `AgentSession - memoized token fetch options are not set, but the passed tokenSource was an instance of TokenSourceConfigurable. If you are seeing this please make a new GitHub issue!`, + ); + } + return tokenSource.fetch(memoizedTokenFetchOptions); } else { return tokenSource.fetch(); } - }, [tokenSource, restOptions]); + }, [tokenSource, memoizedTokenFetchOptions]); const start = React.useCallback( async (connectOptions: SessionConnectOptions = {}) => { From 029337127a70f46705956d5e23c1136ccb9ce595 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 10 Nov 2025 10:17:33 +0200 Subject: [PATCH 2/8] refactor: move token source fetch logic into its own internal hook --- packages/react/src/hooks/useSession.ts | 87 ++++++++++++++------------ 1 file changed, 47 insertions(+), 40 deletions(-) diff --git a/packages/react/src/hooks/useSession.ts b/packages/react/src/hooks/useSession.ts index c832fb403..dc79b7d74 100644 --- a/packages/react/src/hooks/useSession.ts +++ b/packages/react/src/hooks/useSession.ts @@ -188,6 +188,52 @@ function areTokenSourceFetchOptionsEqual(a: TokenSourceFetchOptions, b: TokenSou return true; } +/** Internal hook used by useSession to manage creating a function that properly invokes + * tokenSource.fetch(...) with any fetch options */ +function useSessionTokenSourceFetch(tokenSource: TokenSourceConfigurable | TokenSourceFixed, unstableRestOptions: TokenSourceFetchOptions) { + const isConfigurable = tokenSource instanceof TokenSourceConfigurable; + + const [memoizedTokenFetchOptions, setMemoizedTokenFetchOptions] = + React.useState(() => { + if (isConfigurable) { + return unstableRestOptions; + } else { + return null; + } + }); + React.useEffect(() => { + if (!isConfigurable) { + setMemoizedTokenFetchOptions(null); + return; + } + + if ( + memoizedTokenFetchOptions !== null && + areTokenSourceFetchOptionsEqual(memoizedTokenFetchOptions, unstableRestOptions) + ) { + return; + } + + const tokenFetchOptions = unstableRestOptions as UseSessionConfigurableOptions; + setMemoizedTokenFetchOptions(tokenFetchOptions); + }, [isConfigurable, unstableRestOptions]); + + const tokenSourceFetch = React.useCallback(async () => { + if (isConfigurable) { + if (!memoizedTokenFetchOptions) { + throw new Error( + `AgentSession - memoized token fetch options are not set, but the passed tokenSource was an instance of TokenSourceConfigurable. If you are seeing this please make a new GitHub issue!`, + ); + } + return tokenSource.fetch(memoizedTokenFetchOptions); + } else { + return tokenSource.fetch(); + } + }, [isConfigurable, tokenSource, memoizedTokenFetchOptions]); + + return tokenSourceFetch; +} + /** * A Session represents a manages connection to a Room which can contain Agents. * @public @@ -461,46 +507,7 @@ export function useSession( ), ); - const [memoizedTokenFetchOptions, setMemoizedTokenFetchOptions] = - React.useState(() => { - const isConfigurable = tokenSource instanceof TokenSourceConfigurable; - if (isConfigurable) { - return restOptions; - } else { - return null; - } - }); - React.useEffect(() => { - const isConfigurable = tokenSource instanceof TokenSourceConfigurable; - if (!isConfigurable) { - setMemoizedTokenFetchOptions(null); - return; - } - - if ( - memoizedTokenFetchOptions !== null && - areTokenSourceFetchOptionsEqual(memoizedTokenFetchOptions, restOptions) - ) { - return; - } - - const tokenFetchOptions = restOptions as UseSessionConfigurableOptions; - setMemoizedTokenFetchOptions(tokenFetchOptions); - }, [restOptions]); - - const tokenSourceFetch = React.useCallback(async () => { - const isConfigurable = tokenSource instanceof TokenSourceConfigurable; - if (isConfigurable) { - if (!memoizedTokenFetchOptions) { - throw new Error( - `AgentSession - memoized token fetch options are not set, but the passed tokenSource was an instance of TokenSourceConfigurable. If you are seeing this please make a new GitHub issue!`, - ); - } - return tokenSource.fetch(memoizedTokenFetchOptions); - } else { - return tokenSource.fetch(); - } - }, [tokenSource, memoizedTokenFetchOptions]); + const tokenSourceFetch = useSessionTokenSourceFetch(tokenSource, restOptions); const start = React.useCallback( async (connectOptions: SessionConnectOptions = {}) => { From 6a1fc06ee410699dd533640ca7a24d0ab14208cc Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 10 Nov 2025 10:17:52 +0200 Subject: [PATCH 3/8] fix: adjust docs --- packages/react/src/hooks/useSession.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/react/src/hooks/useSession.ts b/packages/react/src/hooks/useSession.ts index dc79b7d74..819f034ea 100644 --- a/packages/react/src/hooks/useSession.ts +++ b/packages/react/src/hooks/useSession.ts @@ -158,7 +158,7 @@ type UseSessionFixedOptions = UseSessionCommonOptions; * Given two TokenSourceFetchOptions values, check to see if they are deep equal. * * FIXME: swap this for an import from livekit-client once - * https://github.com/livekit/client-sdk-js/pull/1733 is merged! + * https://github.com/livekit/client-sdk-js/pull/1733 is merged and published! * */ function areTokenSourceFetchOptionsEqual(a: TokenSourceFetchOptions, b: TokenSourceFetchOptions) { const allKeysSet = new Set([...Object.keys(a), ...Object.keys(b)]) as Set< @@ -189,8 +189,11 @@ function areTokenSourceFetchOptionsEqual(a: TokenSourceFetchOptions, b: TokenSou } /** Internal hook used by useSession to manage creating a function that properly invokes - * tokenSource.fetch(...) with any fetch options */ -function useSessionTokenSourceFetch(tokenSource: TokenSourceConfigurable | TokenSourceFixed, unstableRestOptions: TokenSourceFetchOptions) { + * tokenSource.fetch(...) with any fetch options */ +function useSessionTokenSourceFetch( + tokenSource: TokenSourceConfigurable | TokenSourceFixed, + unstableRestOptions: TokenSourceFetchOptions, +) { const isConfigurable = tokenSource instanceof TokenSourceConfigurable; const [memoizedTokenFetchOptions, setMemoizedTokenFetchOptions] = From cafdf8eb5198c4b728689c0f10debf29b0993ab5 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 10 Nov 2025 10:27:59 +0200 Subject: [PATCH 4/8] fix: make types a little less indirect --- packages/react/src/hooks/useSession.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/react/src/hooks/useSession.ts b/packages/react/src/hooks/useSession.ts index 819f034ea..9a078f9c6 100644 --- a/packages/react/src/hooks/useSession.ts +++ b/packages/react/src/hooks/useSession.ts @@ -192,7 +192,7 @@ function areTokenSourceFetchOptionsEqual(a: TokenSourceFetchOptions, b: TokenSou * tokenSource.fetch(...) with any fetch options */ function useSessionTokenSourceFetch( tokenSource: TokenSourceConfigurable | TokenSourceFixed, - unstableRestOptions: TokenSourceFetchOptions, + unstableRestOptions: Exclude, ) { const isConfigurable = tokenSource instanceof TokenSourceConfigurable; @@ -217,8 +217,7 @@ function useSessionTokenSourceFetch( return; } - const tokenFetchOptions = unstableRestOptions as UseSessionConfigurableOptions; - setMemoizedTokenFetchOptions(tokenFetchOptions); + setMemoizedTokenFetchOptions(unstableRestOptions); }, [isConfigurable, unstableRestOptions]); const tokenSourceFetch = React.useCallback(async () => { From a6f22dae644d71440ae1c4b9eb36d328dd6c2b6a Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 10 Nov 2025 10:31:40 +0200 Subject: [PATCH 5/8] fix: add missing changeset --- .changeset/quick-rings-thank.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quick-rings-thank.md diff --git a/.changeset/quick-rings-thank.md b/.changeset/quick-rings-thank.md new file mode 100644 index 000000000..077fdac26 --- /dev/null +++ b/.changeset/quick-rings-thank.md @@ -0,0 +1,5 @@ +--- +'@livekit/components-react': patch +--- + +Fix useSession return value stability From 9be25309da37f45f8e5e33fa5c2cbb99f04a5c9f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 10 Nov 2025 13:31:02 +0200 Subject: [PATCH 6/8] refactor: adjust token fetch options memoization calculation to use a ref instead of state --- packages/react/src/hooks/useSession.ts | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/react/src/hooks/useSession.ts b/packages/react/src/hooks/useSession.ts index 9a078f9c6..a6c924fd7 100644 --- a/packages/react/src/hooks/useSession.ts +++ b/packages/react/src/hooks/useSession.ts @@ -196,42 +196,38 @@ function useSessionTokenSourceFetch( ) { const isConfigurable = tokenSource instanceof TokenSourceConfigurable; - const [memoizedTokenFetchOptions, setMemoizedTokenFetchOptions] = - React.useState(() => { - if (isConfigurable) { - return unstableRestOptions; - } else { - return null; - } - }); + const memoizedTokenFetchOptionsRef = React.useRef( + isConfigurable ? unstableRestOptions : null, + ); + React.useEffect(() => { if (!isConfigurable) { - setMemoizedTokenFetchOptions(null); + memoizedTokenFetchOptionsRef.current = null; return; } if ( - memoizedTokenFetchOptions !== null && - areTokenSourceFetchOptionsEqual(memoizedTokenFetchOptions, unstableRestOptions) + memoizedTokenFetchOptionsRef.current !== null && + areTokenSourceFetchOptionsEqual(memoizedTokenFetchOptionsRef.current, unstableRestOptions) ) { return; } - setMemoizedTokenFetchOptions(unstableRestOptions); + memoizedTokenFetchOptionsRef.current = unstableRestOptions; }, [isConfigurable, unstableRestOptions]); const tokenSourceFetch = React.useCallback(async () => { if (isConfigurable) { - if (!memoizedTokenFetchOptions) { + if (!memoizedTokenFetchOptionsRef.current) { throw new Error( `AgentSession - memoized token fetch options are not set, but the passed tokenSource was an instance of TokenSourceConfigurable. If you are seeing this please make a new GitHub issue!`, ); } - return tokenSource.fetch(memoizedTokenFetchOptions); + return tokenSource.fetch(memoizedTokenFetchOptionsRef.current); } else { return tokenSource.fetch(); } - }, [isConfigurable, tokenSource, memoizedTokenFetchOptions]); + }, [isConfigurable, tokenSource]); return tokenSourceFetch; } From f48ffa57a513274e02cb1697007532e2698aa734 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 11 Nov 2025 15:42:49 +0200 Subject: [PATCH 7/8] fix: remove two unused memo dependencies These don't really change anything in practice but just out of cleanliness since they aren't used I removed them. --- packages/react/src/hooks/useSession.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react/src/hooks/useSession.ts b/packages/react/src/hooks/useSession.ts index a6c924fd7..f26772751 100644 --- a/packages/react/src/hooks/useSession.ts +++ b/packages/react/src/hooks/useSession.ts @@ -435,9 +435,7 @@ export function useSession( }, [ sessionInternal, room, - emitter, roomConnectionState, - localParticipant, localCamera, localMicrophone, generateDerivedConnectionStateValues, From 424bc0b59c1c68552f10ccaa6e3cce00a6593780 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 12 Nov 2025 09:29:32 +0200 Subject: [PATCH 8/8] fix: remove out of date FIXME comments --- packages/react/src/hooks/useSession.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/react/src/hooks/useSession.ts b/packages/react/src/hooks/useSession.ts index f26772751..80df6531c 100644 --- a/packages/react/src/hooks/useSession.ts +++ b/packages/react/src/hooks/useSession.ts @@ -522,8 +522,6 @@ export function useSession( let tokenDispatchesAgent = false; await Promise.all([ - // FIXME: swap the below line in once the new `livekit-client` changes are published - // room.connect(tokenSource, { tokenSourceOptions }), tokenSourceFetch().then(({ serverUrl, participantToken }) => { const participantTokenPayload = decodeTokenPayload(participantToken); const participantTokenAgentDispatchCount = @@ -559,8 +557,6 @@ export function useSession( const prepareConnection = React.useCallback(async () => { const credentials = await tokenSourceFetch(); - // FIXME: swap the below line in once the new `livekit-client` changes are published - // room.prepareConnection(tokenSource, { tokenSourceOptions }), await room.prepareConnection(credentials.serverUrl, credentials.participantToken); }, [tokenSourceFetch, room]); React.useEffect(