Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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/fix-use-room-sort-disconnect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/react-native': patch
---

Fix `useRoom` disconnecting the room on every render when `sortParticipants` is an inline function
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"@react-native/eslint-config": "0.83.0",
"@types/fastestsmallesttextencoderdecoder": "^1.0.0",
"@types/react": "^19.2.0",
"@types/react-test-renderer": "^19.1.0",
"eslint": "^9.35.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
Expand All @@ -89,6 +90,7 @@
"react": "19.2.0",
"react-native": "0.83.0",
"react-native-builder-bob": "^0.40.17",
"react-test-renderer": "19.2.0",
"typedoc": "^0.28.14",
"typescript": "^5.9.2"
},
Expand Down Expand Up @@ -120,6 +122,7 @@
"packageManager": "yarn@4.11.0",
"jest": {
"preset": "react-native",
"testEnvironment": "node",
"modulePathIgnorePatterns": [
"<rootDir>/ci",
"<rootDir>/example",
Expand Down
92 changes: 92 additions & 0 deletions src/__tests__/useRoom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { Participant, Room } from 'livekit-client';
import { act, create } from 'react-test-renderer';

import { useRoom } from '../useRoom';

function fakeRoom() {
const room: any = {
remoteParticipants: new Map(),
localParticipant: {},
disconnect: jest.fn(),
once: jest.fn(),
on: jest.fn(),
off: jest.fn(),
};
room.on.mockReturnValue(room);
room.off.mockReturnValue(room);
return room as Room & { disconnect: jest.Mock };
}

describe('useRoom', () => {
it('does not disconnect the room when it re-renders with a new sortParticipants', () => {
const room = fakeRoom();
let renders = 0;
const Probe = () => {
// Without this guard, the unfixed hook re-renders forever.
if (++renders > 20) {
throw new Error('useRoom re-rendered in a loop');
}
// An inline function has a new identity on every render.
useRoom(room, { sortParticipants: (_: Participant[]) => {} });
return null;
};

let renderer: ReturnType<typeof create>;
act(() => {
renderer = create(<Probe />);
});
act(() => {
renderer.update(<Probe />);
});
act(() => {
renderer.update(<Probe />);
});

expect(room.disconnect).not.toHaveBeenCalled();
});

it('disconnects the room when the component unmounts', () => {
const room = fakeRoom();
const Probe = () => {
useRoom(room);
return null;
};

let renderer: ReturnType<typeof create>;
act(() => {
renderer = create(<Probe />);
});
act(() => {
renderer.unmount();
});

expect(room.disconnect).toHaveBeenCalledTimes(1);
});

it('uses the latest sortParticipants after a re-render', () => {
const room = fakeRoom();
const first = jest.fn();
const second = jest.fn();
const Probe = ({ sort }: { sort: (p: Participant[]) => void }) => {
useRoom(room, { sortParticipants: sort });
return null;
};

let renderer: ReturnType<typeof create>;
act(() => {
renderer = create(<Probe sort={first} />);
});
act(() => {
renderer.update(<Probe sort={second} />);
});

const onParticipantsChanged = (room.on as jest.Mock).mock.calls.find(
([event]) => event === 'reconnected'
)![1];
act(() => {
onParticipantsChanged();
});

expect(second).toHaveBeenCalled();
});
});
12 changes: 8 additions & 4 deletions src/useRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
RoomEvent,
Track,
} from 'livekit-client';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';

export interface RoomState {
room?: Room;
Expand All @@ -29,14 +29,18 @@ export function useRoom(room: Room, options?: RoomOptions): RoomState {
const [participants, setParticipants] = useState<Participant[]>([]);
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);

const sortFunc = options?.sortParticipants ?? sortParticipants;
// Keep the latest sort function in a ref so that passing a new function on
// every render does not re-run the effect below, whose cleanup disconnects
// the room.
const sortFuncRef = useRef(options?.sortParticipants ?? sortParticipants);
sortFuncRef.current = options?.sortParticipants ?? sortParticipants;

useEffect(() => {
const onParticipantsChanged = () => {
const remotes = Array.from(room.remoteParticipants.values());
const newParticipants: Participant[] = [room.localParticipant];
newParticipants.push(...remotes);
sortFunc(newParticipants, room.localParticipant);
sortFuncRef.current(newParticipants, room.localParticipant);
setParticipants(newParticipants);
};
const onSubscribedTrackChanged = (track?: RemoteTrack) => {
Expand Down Expand Up @@ -92,7 +96,7 @@ export function useRoom(room: Room, options?: RoomOptions): RoomState {
return () => {
room.disconnect();
};
}, [room, sortFunc]);
}, [room]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Participant order stays stale after sort changes

When sortParticipants changes without a room event, useRoom leaves the existing participant order untouched. sortFuncRef affects only future event callbacks, so the new ordering never appears until another room event.

Learn more

The hook stores the sorted participant list in React state. Changing the sorting callback previously reran the effect, which called onSubscribedTrackChanged() and rebuilt that list. The new effect dependency excludes the callback, so changing it alone does not update the visible order. A room with no subsequent events retains its previous ordering indefinitely.

Example: A call view initially sorts Alice before Bob. The user selects a reverse-order control that replaces sortParticipants, but the view still shows Alice first until someone joins, leaves, or generates another subscribed event.

Recommended fix: Separate participant-list recalculation from room subscription and disconnect cleanup. Recalculate the list when the sorting callback changes without tearing down the room, while preserving the stable event subscription for inline callbacks.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


return {
error,
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

{
"extends": "./tsconfig",
"exclude": ["example", "ci"]
"exclude": ["example", "ci", "**/__tests__"]
}
41 changes: 40 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3640,6 +3640,7 @@ __metadata:
"@react-native/eslint-config": "npm:0.83.0"
"@types/fastestsmallesttextencoderdecoder": "npm:^1.0.0"
"@types/react": "npm:^19.2.0"
"@types/react-test-renderer": "npm:^19.1.0"
array.prototype.at: "npm:^1.1.1"
base64-js: "npm:1.5.1"
eslint: "npm:^9.35.0"
Expand All @@ -3656,6 +3657,7 @@ __metadata:
react-native: "npm:0.83.0"
react-native-builder-bob: "npm:^0.40.17"
react-native-url-polyfill: "npm:^3.0.0"
react-test-renderer: "npm:19.2.0"
typed-emitter: "npm:^2.1.0"
typedoc: "npm:^0.28.14"
typescript: "npm:^5.9.2"
Expand Down Expand Up @@ -4660,6 +4662,24 @@ __metadata:
languageName: node
linkType: hard

"@types/react-test-renderer@npm:^19.1.0":
version: 19.3.0
resolution: "@types/react-test-renderer@npm:19.3.0"
dependencies:
"@types/react": "npm:*"
checksum: 10/fbc49458dbb661d7565d60c07bca5284bac15bf88fc7fef9bb5ff0f58c4dbe87faf430443bac94b9a7a3dd2fa1e520435fe4d245928545756220b0fb76e0b080
languageName: node
linkType: hard

"@types/react@npm:*":
version: 19.3.0
resolution: "@types/react@npm:19.3.0"
dependencies:
csstype: "npm:^3.2.2"
checksum: 10/2f4589222fd9c512e1ef78e65f334ade8777400add8ec0803fcc3a044761679b7984ba8410a890af24255383c3251a568bb692b60acfa22397ba0a64b2e032bf
languageName: node
linkType: hard

"@types/react@npm:^19.2.0":
version: 19.2.16
resolution: "@types/react@npm:19.2.16"
Expand Down Expand Up @@ -11297,6 +11317,13 @@ __metadata:
languageName: node
linkType: hard

"react-is@npm:^19.2.0":
version: 19.3.0
resolution: "react-is@npm:19.3.0"
checksum: 10/69cfafcab48caac03da35fb17e640a2a2de8299a3cd699da720e671a22231fc7179c77c82e379826728857f9cc45cb56ff1e7f5d607acdd48fe151417a2a388f
languageName: node
linkType: hard

"react-native-builder-bob@npm:^0.40.17":
version: 0.40.17
resolution: "react-native-builder-bob@npm:0.40.17"
Expand Down Expand Up @@ -11531,6 +11558,18 @@ __metadata:
languageName: node
linkType: hard

"react-test-renderer@npm:19.2.0":
version: 19.2.0
resolution: "react-test-renderer@npm:19.2.0"
dependencies:
react-is: "npm:^19.2.0"
scheduler: "npm:^0.27.0"
peerDependencies:
react: ^19.2.0
checksum: 10/1a072bf5c383ee9cec1eed5872114a25d8029e8fabe17a9154cbb7b5d6e5570711efc3e80e336d170bb1f60e29d395087147891fa198743d17c29ff86782cde3
languageName: node
linkType: hard

"react@npm:19.1.1":
version: 19.1.1
resolution: "react@npm:19.1.1"
Expand Down Expand Up @@ -11902,7 +11941,7 @@ __metadata:
languageName: node
linkType: hard

"scheduler@npm:0.27.0":
"scheduler@npm:0.27.0, scheduler@npm:^0.27.0":
version: 0.27.0
resolution: "scheduler@npm:0.27.0"
checksum: 10/eab3c3a8373195173e59c147224fc30dabe6dd453f248f5e610e8458512a5a2ee3a06465dc400ebfe6d35c9f5b7f3bb6b2e41c88c86fd177c25a73e7286a1e06
Expand Down
Loading