️(frontend) reduce unnecessary re-renders in the participant panel

Cut down the number of unnecessary re-renders happening in the
participant panel by narrowing subscriptions and isolating state to
the components that actually depend on it.
This commit is contained in:
lebaudantoine
2026-07-17 21:19:25 +02:00
committed by aleb_the_flash
parent fca7c3b8b8
commit 7343560a9b
7 changed files with 230 additions and 220 deletions
@@ -93,9 +93,7 @@ type ParticipantListItemProps = {
participant: Participant
}
export const ParticipantListItem = ({
participant,
}: ParticipantListItemProps) => {
export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
const { t } = useTranslation('rooms')
const name = participant.name || participant.identity
return (
@@ -1,110 +0,0 @@
import { ReactNode, useState } from 'react'
import { css } from '@/styled-system/css'
import { ToggleButton } from 'react-aria-components'
import { HStack, styled, VStack } from '@/styled-system/jsx'
import { RiArrowUpSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
const ToggleHeader = styled(ToggleButton, {
base: {
minHeight: '40px', //fixme hardcoded value
paddingRight: '.5rem',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
width: '100%',
alignItems: 'center',
transition: 'background 200ms',
borderTopRadius: '7px',
'&[data-hovered]': {
backgroundColor: 'greyscale.50',
},
},
})
const Container = styled('div', {
base: {
border: '1px solid',
borderColor: 'greyscale.250',
borderRadius: '8px',
margin: '0 .625rem',
},
})
const ListContainer = styled(VStack, {
base: {
borderTop: '1px solid',
borderTopColor: 'greyscale.250',
alignItems: 'start',
overflowY: 'scroll',
overflowX: 'hidden',
minHeight: 0,
flexGrow: 1,
display: 'flex',
paddingY: '0.5rem',
paddingX: '1rem',
gap: 0,
},
})
export type ParticipantsCollapsableListProps<T> = {
heading: string
participants: Array<T>
renderParticipant: (participant: T) => JSX.Element
action?: ReactNode
}
export function ParticipantsCollapsableList<T>({
heading,
participants,
renderParticipant,
action,
}: ParticipantsCollapsableListProps<T>) {
const { t } = useTranslation('rooms')
const [isOpen, setIsOpen] = useState(true)
const label = t(`participants.collapsable.${isOpen ? 'close' : 'open'}`, {
name: heading,
})
return (
<Container>
<ToggleHeader
isSelected={isOpen}
aria-label={label}
onPress={() => setIsOpen(!isOpen)}
style={{
borderRadius: !isOpen ? '7px' : undefined,
}}
>
<HStack
justify="space-between"
className={css({
margin: '0 1.25rem',
width: '100%',
})}
>
<div
className={css({
fontSize: '1rem',
})}
>
{heading}
</div>
<div>{participants?.length || 0}</div>
</HStack>
<RiArrowUpSLine
size={32}
style={{
transform: isOpen ? 'rotate(-180deg)' : undefined,
transition: 'transform 200ms',
}}
/>
</ToggleHeader>
{isOpen && (
<ListContainer>
{action}
{participants.map((participant) => renderParticipant(participant))}
</ListContainer>
)}
</Container>
)
}
@@ -0,0 +1,93 @@
import { ReactNode, useId, useState } from 'react'
import { RiArrowUpSLine } from '@remixicon/react'
import { styled, HStack, VStack } from '@/styled-system/jsx'
const Container = styled('div', {
base: {
border: '1px solid',
borderColor: 'greyscale.250',
borderRadius: '8px',
margin: '0 .625rem 0.9375rem',
},
})
const Header = styled('button', {
base: {
minHeight: '2.5rem',
paddingX: '1.25rem',
paddingY: '0.5rem',
gap: '0.5rem',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
fontSize: '1rem',
transition: 'background 200ms',
borderTopRadius: '7px', // container radius (8) minus its 1px border
_hover: { backgroundColor: 'greyscale.50' },
},
variants: {
isOpen: { false: { borderRadius: '7px' } },
},
})
const Chevron = styled(RiArrowUpSLine, {
base: { transition: 'transform 200ms', flexShrink: 0 },
variants: {
isOpen: { false: { transform: 'rotate(180deg)' } },
},
})
const List = styled(VStack, {
base: {
borderTop: '1px solid',
borderTopColor: 'greyscale.250',
alignItems: 'start',
minHeight: 0,
flexGrow: 1,
paddingY: '0.5rem',
paddingX: '1rem',
gap: 0,
},
})
export type ParticipantsCollapsibleSectionProps = {
heading: string
count: number
action?: ReactNode
children: ReactNode
}
export const ParticipantsCollapsibleSection = ({
heading,
count,
action,
children,
}: ParticipantsCollapsibleSectionProps) => {
const [isOpen, setIsOpen] = useState(true)
const listId = useId()
return (
<Container>
<Header
type="button"
isOpen={isOpen}
aria-expanded={isOpen}
aria-controls={listId}
onClick={() => setIsOpen((open) => !open)}
>
<HStack justify="space-between" width="100%">
<span>{heading}</span>
<span>{count}</span>
</HStack>
<Chevron size={32} isOpen={isOpen} aria-hidden />
</Header>
{isOpen && (
<List id={listId} role="list">
{action}
{children}
</List>
)}
</Container>
)
}
@@ -1,54 +1,87 @@
import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import {
useRemoteParticipants,
useRoomContext,
} from '@livekit/components-react'
import { RoomEvent } from 'livekit-client'
import { Div, H } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { ParticipantListItem } from './ParticipantListItem'
import { ParticipantsCollapsableList } from './ParticipantsCollapsableList'
import { HandRaisedListItem } from './HandRaisedListItem'
import { ParticipantRow } from './ParticipantRow'
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
import { LowerAllHandsButton } from './LowerAllHandsButton'
import { WaitingParticipantListItem } from './WaitingParticipantListItem'
import { useWaitingParticipants } from '@/features/participants/hooks/useWaitingParticipants'
import type { Participant } from 'livekit-client'
import { WaitingParticipant } from '@/features/participants/api/listWaitingParticipants'
import { MuteEveryoneButton } from './MuteEveryoneButton'
import { WaitingParticipantsSection } from './WaitingParticipantsSection'
import { RaisedHandRow } from './RaisedHandRow'
const JoinedParticipantsSections = () => {
const room = useRoomContext()
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
// `updateOnlyOn` filters on the room event *type*, not on which participant
// the event is about. `ParticipantNameChanged` and `ParticipantAttributesChanged`
// are room-wide and fire for the local participant too, so this hook re-emits
// (and we re-render) on local changes, despite its name.
//
// That is deliberate here: we prepend `room.localParticipant` to the list below,
// and its hand-raise state comes from `attributes.handRaisedAt`. Without the
// local events waking this component up, raising your own hand would not
// update the UI. Do not "optimise" these away.
const remoteParticipants = useRemoteParticipants({
updateOnlyOn: [
RoomEvent.ParticipantNameChanged,
RoomEvent.ParticipantAttributesChanged,
],
})
const allParticipants = [
room.localParticipant,
...[...remoteParticipants].sort((a, b) =>
(a.name || a.identity).localeCompare(b.name || b.identity)
),
]
const participantsWithRaisedHands = allParticipants
.filter((p) => !!p.attributes.handRaisedAt)
.sort((a, b) => {
const raisedAtA = Date.parse(a.attributes.handRaisedAt!)
const raisedAtB = Date.parse(b.attributes.handRaisedAt!)
return (
(Number.isNaN(raisedAtA) ? Infinity : raisedAtA) -
(Number.isNaN(raisedAtB) ? Infinity : raisedAtB)
)
})
return (
<>
{participantsWithRaisedHands.length > 0 && (
<ParticipantsCollapsibleSection
heading={t('raisedHands')}
count={participantsWithRaisedHands.length}
action={
<LowerAllHandsButton participants={participantsWithRaisedHands} />
}
>
{participantsWithRaisedHands.map((p) => (
<RaisedHandRow key={p.identity} participant={p} />
))}
</ParticipantsCollapsibleSection>
)}
<ParticipantsCollapsibleSection
heading={t('contributors')}
count={allParticipants.length}
action={<MuteEveryoneButton participants={remoteParticipants} />}
>
{allParticipants.map((p) => (
<ParticipantRow key={p.identity} participant={p} />
))}
</ParticipantsCollapsibleSection>
</>
)
}
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
// because the 'useLocalParticipant' hook does not update the participant's information when their
// metadata/name changes. The LiveKit team has marked this as a TODO item in the code.
const participants = useParticipants()
const sortedRemoteParticipants = participants
.slice(1)
.sort((participantA, participantB) => {
const nameA = participantA.name || participantA.identity
const nameB = participantB.name || participantB.identity
return nameA.localeCompare(nameB)
})
const sortedParticipants = [
participants[0], // first participant returned by the hook, is always the local one
...sortedRemoteParticipants,
]
const raisedHandParticipants = participants
.filter((participant) => !!participant.attributes.handRaisedAt)
.sort((a, b) => {
const dateA = new Date(a.attributes.handRaisedAt)
const dateB = new Date(b.attributes.handRaisedAt)
const timeA = isNaN(dateA.getTime()) ? 0 : dateA.getTime()
const timeB = isNaN(dateB.getTime()) ? 0 : dateB.getTime()
return timeA - timeB
})
const { waitingParticipants, handleParticipantEntry } =
useWaitingParticipants()
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
return (
<Div overflowY="scroll">
<H
@@ -63,50 +96,8 @@ export const ParticipantsList = () => {
>
{t('subheading').toUpperCase()}
</H>
{waitingParticipants?.length > 0 && (
<Div marginBottom=".9375rem">
<ParticipantsCollapsableList<WaitingParticipant>
heading={t('waiting.title')}
participants={waitingParticipants}
renderParticipant={(participant) => (
<WaitingParticipantListItem
key={participant.id}
participant={participant}
onAction={handleParticipantEntry}
/>
)}
action={<></>}
/>
</Div>
)}
{raisedHandParticipants.length > 0 && (
<Div marginBottom=".9375rem">
<ParticipantsCollapsableList<Participant>
heading={t('raisedHands')}
participants={raisedHandParticipants}
renderParticipant={(participant) => (
<HandRaisedListItem
key={participant.identity}
participant={participant}
/>
)}
action={
<LowerAllHandsButton participants={raisedHandParticipants} />
}
/>
</Div>
)}
<ParticipantsCollapsableList<Participant>
heading={t('contributors')}
participants={sortedParticipants}
renderParticipant={(participant) => (
<ParticipantListItem
key={participant.identity}
participant={participant}
/>
)}
action={<MuteEveryoneButton participants={sortedRemoteParticipants} />}
/>
<WaitingParticipantsSection />
<JoinedParticipantsSections />
</Div>
)
}
@@ -6,25 +6,45 @@ import { useTranslation } from 'react-i18next'
import { Avatar } from '@/components/Avatar'
import { useLowerHandParticipant } from '../api/lowerHandParticipant'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import type { Participant } from 'livekit-client'
import { isLocal } from '@/utils/livekit'
import { RiHand } from '@remixicon/react'
import { Button } from '@/primitives'
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
const ActionButton = ({
participant,
name,
}: {
participant: Participant
name: string
}) => {
const { t } = useTranslation('rooms')
const { lowerHandParticipant } = useLowerHandParticipant()
return (
<Button
square
variant="greyscale"
size="sm"
onPress={() => lowerHandParticipant(participant)}
aria-label={t('participants.lowerParticipantHand', { name })}
tooltip={t('participants.lowerParticipantHand', { name })}
data-attr="participants-lower-hand"
>
<RiHand />
</Button>
)
}
type HandRaisedListItemProps = {
participant: Participant
}
export const HandRaisedListItem = ({
participant,
}: HandRaisedListItemProps) => {
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
const { t } = useTranslation('rooms')
const name = participant.name || participant.identity
const { lowerHandParticipant } = useLowerHandParticipant()
const isAdminOrOwner = useIsAdminOrOwner()
return (
<HStack
role="listitem"
@@ -69,19 +89,9 @@ export const HandRaisedListItem = ({
)}
</Text>
</HStack>
{isAdminOrOwner && (
<Button
square
variant="greyscale"
size="sm"
onPress={() => lowerHandParticipant(participant)}
aria-label={t('participants.lowerParticipantHand', { name })}
tooltip={t('participants.lowerParticipantHand', { name })}
data-attr="participants-lower-hand"
>
<RiHand />
</Button>
)}
<AdminOrOwnerOnly>
<ActionButton participant={participant} name={name} />
</AdminOrOwnerOnly>
</HStack>
)
}
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
import { WaitingParticipant } from '../api/listWaitingParticipants'
import { RiCloseLine } from '@remixicon/react'
export const WaitingParticipantListItem = ({
export const WaitingParticipantRow = ({
participant,
onAction,
}: {
@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
import { WaitingParticipantRow } from './WaitingParticipantRow'
import { useWaitingParticipants } from '../hooks/useWaitingParticipants'
export const WaitingParticipantsSection = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants.waiting' })
const { waitingParticipants, handleParticipantEntry } =
useWaitingParticipants()
if (waitingParticipants?.length == 0) return
return (
<ParticipantsCollapsibleSection
heading={t('title')}
count={waitingParticipants.length}
>
{waitingParticipants.map((p) => (
<WaitingParticipantRow
key={p.id}
participant={p}
onAction={handleParticipantEntry}
/>
))}
</ParticipantsCollapsibleSection>
)
}