Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quick-rings-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/components-react': patch
---

Fix useSession return value stability
92 changes: 83 additions & 9 deletions packages/react/src/hooks/useSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,88 @@ 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 and published!
* */
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;
}
Comment on lines +157 to +189
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chore: This has been copied from here: livekit/client-sdk-js#1733. I did this so I could test the change but IMO this copied code shouldn't be merged.

So, before this should be merged, get the above linked pull request deployed into a new version on npm, then delete this code and update it to be an import.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, I forgot to do this before merging 🤦 . I will do this as part of the follow up change.


/** 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: Exclude<UseSessionConfigurableOptions, keyof UseSessionCommonOptions>,
) {
const isConfigurable = tokenSource instanceof TokenSourceConfigurable;

const [memoizedTokenFetchOptions, setMemoizedTokenFetchOptions] =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (if-minor): there's a nice util library that would allow us to use your compare function directly to compare the deps.

Internally it uses a ref for comparison, which I personally feel would be slightly preferable to the useEffect pattern in case you don't want to use that library directly. Main reason is that the useEffect will always only run on the subsequent tick and makes the flow a bit harder to reason about with multiple dependencies

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good - migrated to a ref instead!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, that was a misunderstanding, I meant that the ref usage would make things cleaner because it means we can get rid of the useEffect altogether.

If we need to keep the useEffect, then your previous solution works just as well.
FWIW, I think using this approach would allow us to get rid of the useEffect (which was my primary motivation to suggest this change)

React.useState<TokenSourceFetchOptions | null>(() => {
if (isConfigurable) {
return unstableRestOptions;
} else {
return null;
}
});
React.useEffect(() => {
if (!isConfigurable) {
setMemoizedTokenFetchOptions(null);
return;
}

if (
memoizedTokenFetchOptions !== null &&
areTokenSourceFetchOptionsEqual(memoizedTokenFetchOptions, unstableRestOptions)
) {
return;
}

setMemoizedTokenFetchOptions(unstableRestOptions);
}, [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!`,
);
}
Copy link
Contributor Author

@1egoman 1egoman Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: There might be a better way to word this error message. This should be an invariant that I want to make clear should never happen in actual use but isn't easily representable in another way.

(clarifying a bit further - I don't think this can happen in actual use, but I'm not 100% sure reasoning through it in my head. So if somebody can make this happen down the line I would be curious to get the reproduction instructions!)

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
Expand Down Expand Up @@ -427,15 +509,7 @@ export function useSession(
),
);

const tokenSourceFetch = React.useCallback(async () => {
const isConfigurable = tokenSource instanceof TokenSourceConfigurable;
if (isConfigurable) {
const tokenFetchOptions = restOptions as UseSessionConfigurableOptions;
return tokenSource.fetch(tokenFetchOptions);
} else {
return tokenSource.fetch();
}
}, [tokenSource, restOptions]);
const tokenSourceFetch = useSessionTokenSourceFetch(tokenSource, restOptions);

const start = React.useCallback(
async (connectOptions: SessionConnectOptions = {}) => {
Expand Down