diff --git a/.changeset/fix-use-room-sort-disconnect.md b/.changeset/fix-use-room-sort-disconnect.md new file mode 100644 index 0000000..317235b --- /dev/null +++ b/.changeset/fix-use-room-sort-disconnect.md @@ -0,0 +1,5 @@ +--- +'@livekit/react-native': patch +--- + +Fix `useRoom` disconnecting the room on every render when `sortParticipants` is an inline function diff --git a/package.json b/package.json index 33904f6..a45c9d6 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" }, @@ -120,6 +122,7 @@ "packageManager": "yarn@4.11.0", "jest": { "preset": "react-native", + "testEnvironment": "node", "modulePathIgnorePatterns": [ "/ci", "/example", diff --git a/src/__tests__/useRoom.test.tsx b/src/__tests__/useRoom.test.tsx new file mode 100644 index 0000000..141a33a --- /dev/null +++ b/src/__tests__/useRoom.test.tsx @@ -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; + act(() => { + renderer = create(); + }); + act(() => { + renderer.update(); + }); + act(() => { + renderer.update(); + }); + + 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; + act(() => { + renderer = create(); + }); + 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; + act(() => { + renderer = create(); + }); + act(() => { + renderer.update(); + }); + + const onParticipantsChanged = (room.on as jest.Mock).mock.calls.find( + ([event]) => event === 'reconnected' + )![1]; + act(() => { + onParticipantsChanged(); + }); + + expect(second).toHaveBeenCalled(); + }); +}); diff --git a/src/useRoom.ts b/src/useRoom.ts index 0a2b74b..6454167 100644 --- a/src/useRoom.ts +++ b/src/useRoom.ts @@ -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; @@ -29,14 +29,18 @@ export function useRoom(room: Room, options?: RoomOptions): RoomState { const [participants, setParticipants] = useState([]); const [audioTracks, setAudioTracks] = useState([]); - 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) => { @@ -92,7 +96,7 @@ export function useRoom(room: Room, options?: RoomOptions): RoomState { return () => { room.disconnect(); }; - }, [room, sortFunc]); + }, [room]); return { error, diff --git a/tsconfig.build.json b/tsconfig.build.json index 9adfc50..7a1a017 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig", - "exclude": ["example", "ci"] + "exclude": ["example", "ci", "**/__tests__"] } diff --git a/yarn.lock b/yarn.lock index c91af92..3b42e5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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" @@ -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" @@ -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" @@ -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" @@ -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