Repository navigation
fix: keep useRoom from disconnecting when sortParticipants changes identity #472
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
cpruijsen
wants to merge
1
commit into
livekit:main
Choose a base branch
from
cpruijsen:fix/issue-470-93807c63
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+139
−4
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 passed as an inline function |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| import { beforeEach, describe, expect, it, jest } from '@jest/globals'; | ||
| import { RoomEvent, type Participant, type Room } from 'livekit-client'; | ||
| import { useRoom, type RoomOptions } from '../useRoom'; | ||
|
|
||
| // useRoom calls the real React hooks, which only work inside a component | ||
| // render. Mock them so the hook can be invoked directly: useEffect records its | ||
| // callback and deps, and the driver below replays React's Object.is dependency | ||
| // comparison to decide when the effect re-runs and its cleanup fires. | ||
| const mockEffectCalls: { fn: () => unknown; deps?: unknown[] }[] = []; | ||
| const mockRefCells: { current: unknown }[] = []; | ||
| let mockRefCursor = 0; | ||
|
|
||
| jest.mock('react', () => { | ||
| const actual = jest.requireActual<typeof import('react')>('react'); | ||
| return { | ||
| ...actual, | ||
| useState: (initial: unknown) => [initial, jest.fn()], | ||
| useRef: (initial: unknown) => { | ||
| const cell = mockRefCells[mockRefCursor] ?? { current: initial }; | ||
| mockRefCells[mockRefCursor++] = cell; | ||
| return cell; | ||
| }, | ||
| useEffect: (fn: () => unknown, deps?: unknown[]) => { | ||
| mockEffectCalls.push({ fn, deps }); | ||
| }, | ||
| }; | ||
| }); | ||
|
|
||
| let mountedDeps: unknown[] | undefined; | ||
| let mountedCleanup: (() => void) | undefined; | ||
|
|
||
| const renderUseRoom = (room: Room, options?: RoomOptions) => { | ||
| mockEffectCalls.length = 0; | ||
| mockRefCursor = 0; | ||
| // eslint-disable-next-line react-hooks/rules-of-hooks -- the hooks are mocked; the hook is called directly so the test controls each render. | ||
| useRoom(room, options); | ||
| for (const call of mockEffectCalls) { | ||
| const prevDeps = mountedDeps; | ||
| const depsChanged = | ||
| prevDeps === undefined || | ||
| call.deps === undefined || | ||
| call.deps.length !== prevDeps.length || | ||
| call.deps.some((dep, i) => !Object.is(dep, prevDeps[i])); | ||
| if (depsChanged) { | ||
| mountedCleanup?.(); | ||
| const cleanup = call.fn(); | ||
| mountedCleanup = | ||
| typeof cleanup === 'function' ? (cleanup as () => void) : undefined; | ||
| } | ||
| mountedDeps = call.deps; | ||
| } | ||
| }; | ||
|
|
||
| const unmount = () => { | ||
| mountedCleanup?.(); | ||
| }; | ||
|
|
||
| const fakeRoom = () => { | ||
| const room = { | ||
| remoteParticipants: new Map<string, Participant>(), | ||
| localParticipant: {} as Participant, | ||
| disconnect: jest.fn(), | ||
| once: jest.fn(), | ||
| on: jest.fn(), | ||
| off: jest.fn(), | ||
| }; | ||
| room.on.mockReturnValue(room); | ||
| room.off.mockReturnValue(room); | ||
| return room; | ||
| }; | ||
|
|
||
| beforeEach(() => { | ||
| mountedDeps = undefined; | ||
| mountedCleanup = undefined; | ||
| mockEffectCalls.length = 0; | ||
| mockRefCells.length = 0; | ||
| mockRefCursor = 0; | ||
| }); | ||
|
|
||
| describe('useRoom', () => { | ||
| it('does not disconnect the room when re-rendered with a new inline sortParticipants', () => { | ||
| const room = fakeRoom(); | ||
| renderUseRoom(room as unknown as Room, { | ||
| sortParticipants: () => {}, | ||
| }); | ||
| renderUseRoom(room as unknown as Room, { | ||
| sortParticipants: () => {}, | ||
| }); | ||
| renderUseRoom(room as unknown as Room, { | ||
| sortParticipants: () => {}, | ||
| }); | ||
|
|
||
| expect(room.on).toHaveBeenCalled(); | ||
| expect(room.disconnect).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('disconnects the room when the component unmounts', () => { | ||
| const room = fakeRoom(); | ||
| renderUseRoom(room as unknown as Room); | ||
| unmount(); | ||
|
|
||
| expect(room.disconnect).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('uses the latest sortParticipants after a re-render', () => { | ||
| const room = fakeRoom(); | ||
| const first = jest.fn((_participants: Participant[]) => {}); | ||
| const second = jest.fn((_participants: Participant[]) => {}); | ||
| renderUseRoom(room as unknown as Room, { sortParticipants: first }); | ||
| renderUseRoom(room as unknown as Room, { sortParticipants: second }); | ||
|
|
||
| // The initial effect run already invoked `first`; reset the spies so only | ||
| // the call below is counted. | ||
| first.mockClear(); | ||
| second.mockClear(); | ||
|
|
||
| const onParticipantsChanged = room.on.mock.calls.find( | ||
| ([event]) => event === RoomEvent.Reconnected | ||
| )?.[1] as () => void; | ||
| onParticipantsChanged(); | ||
|
|
||
| expect(second).toHaveBeenCalledTimes(1); | ||
| expect(first).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 sorter changes
When
sortParticipantschanges without a room event,useRoomleaves the displayed participants in their old order.onParticipantsChangedonly sorts again on room events, so the new order can remain invisible indefinitely.Learn more
The hook stores the ordered participant list in React state. Updating the sorter ref changes which callback the event listener uses later, but does not rebuild that list. Previously a new callback identity restarted the effect and immediately sorted the current room participants. Avoid restarting that effect because its cleanup disconnects the room; instead trigger a participant-only state update after a committed sorter change, without treating every new inline callback as a reason to synchronously rebuild state in a loop.
Example: A room has Alice and Bob, with Alice first. A settings change supplies a sorter that puts Bob first, but no participant or track event follows. The hook continues returning Alice first instead of Bob first.
Recommended fix: Separate the participant refresh from the room subscription lifecycle. Refresh when the configured sorting behavior changes while preventing inline callbacks that change identity every render from causing a render loop; validate both ordering changes and the no-disconnect case in tests.
Was this helpful? React with 👍 or 👎 to provide feedback.