♻️(frontend) refactor usePersistentUserChoice to fix state sync issues

I may have introduced a misusage of the usePersistentUserChoice hook.
I ended using it while expecting it to be a global state, it wasn't.

Fix broken global state that caused user choice desync. Use LiveKit default
persistence functions similar to notification store approach. Carefully
handles existing localStorage data to prevent regressions.

Note: Audio output persistence will be added in future commits.
This commit is contained in:
lebaudantoine
2025-06-24 19:11:44 +02:00
committed by aleb_the_flash
parent 1cd8fd2fc6
commit 4a18e188e4
7 changed files with 76 additions and 119 deletions
@@ -15,8 +15,8 @@ import { InviteDialog } from './InviteDialog'
import { VideoConference } from '../livekit/prefabs/VideoConference'
import posthog from 'posthog-js'
import { css } from '@/styled-system/css'
import { LocalUserChoices } from '../routes/Room'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices'
export const Conference = ({
roomId,
@@ -9,7 +9,6 @@ import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleD
import { Field } from '@/primitives/Field'
import { Button, Dialog, Text, Form } from '@/primitives'
import { HStack, VStack } from '@/styled-system/jsx'
import { LocalUserChoices } from '../routes/Room'
import { Heading } from 'react-aria-components'
import { RiImageCircleAiFill } from '@remixicon/react'
import {
@@ -28,6 +27,7 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint'
import { LocalUserChoices } from '@/stores/userChoices'
const onError = (e: Error) => console.error('ERROR', e)
@@ -116,40 +116,26 @@ export const Join = ({
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const {
userChoices: initialUserChoices,
userChoices: {
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
processorSerialized,
username,
},
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
saveUsername,
saveProcessorSerialized,
} = usePersistentUserChoices({})
} = usePersistentUserChoices()
const [audioEnabled, setAudioEnabled] = useState(true)
const [videoEnabled, setVideoEnabled] = useState(true)
const [audioDeviceId, setAudioDeviceId] = useState<string>(
initialUserChoices.audioDeviceId
)
const [videoDeviceId, setVideoDeviceId] = useState<string>(
initialUserChoices.videoDeviceId
)
const [username, setUsername] = useState<string>(initialUserChoices.username)
const [processor, setProcessor] = useState(
BackgroundProcessorFactory.deserializeProcessor(
initialUserChoices.processorSerialized
)
BackgroundProcessorFactory.deserializeProcessor(processorSerialized)
)
useEffect(() => {
saveAudioInputDeviceId(audioDeviceId)
}, [audioDeviceId, saveAudioInputDeviceId])
useEffect(() => {
saveVideoInputDeviceId(videoDeviceId)
}, [videoDeviceId, saveVideoInputDeviceId])
useEffect(() => {
saveUsername(username)
}, [username, saveUsername])
useEffect(() => {
saveProcessorSerialized(processor?.serialize())
}, [
@@ -161,8 +147,8 @@ export const Join = ({
const tracks = usePreviewTracks(
{
audio: { deviceId: initialUserChoices.audioDeviceId },
video: { deviceId: initialUserChoices.videoDeviceId },
audio: { deviceId: audioDeviceId },
video: { deviceId: videoDeviceId },
},
onError
)
@@ -351,9 +337,9 @@ export const Join = ({
</H>
<Field
type="text"
onChange={setUsername}
onChange={saveUsername}
label={t('usernameLabel')}
defaultValue={initialUserChoices?.username}
defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
@@ -474,11 +460,11 @@ export const Join = ({
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
initialDeviceId={initialUserChoices.audioDeviceId}
onChange={(enabled) => setAudioEnabled(enabled)}
initialDeviceId={audioDeviceId}
onChange={(enabled) => saveAudioInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
setAudioDeviceId(deviceId ?? '')
saveAudioInputDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
@@ -486,11 +472,11 @@ export const Join = ({
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
initialDeviceId={initialUserChoices.videoDeviceId}
onChange={(enabled) => setVideoEnabled(enabled)}
initialDeviceId={videoDeviceId}
onChange={(enabled) => saveVideoInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
setVideoDeviceId(deviceId ?? '')
saveVideoInputDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
@@ -99,7 +99,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const trackProps = useTrackToggle(props)
const { userChoices } = usePersistentUserChoices({})
const { userChoices } = usePersistentUserChoices()
const toggle = () => {
if (props.source === Track.Source.Camera) {
@@ -1,71 +1,31 @@
import { UsePersistentUserChoicesOptions } from '@livekit/components-react'
import React from 'react'
import { LocalUserChoices } from '../../routes/Room'
import { saveUserChoices, loadUserChoices } from '@livekit/components-core'
import { ProcessorSerialized } from '../components/blur'
import { useSnapshot } from 'valtio'
import { userChoicesStore } from '@/stores/userChoices'
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
/**
* From @livekit/component-react
*
* A hook that provides access to user choices stored in local storage, such as
* selected media devices and their current state (on or off), as well as the user name.
* @alpha
*/
export function usePersistentUserChoices(
options: UsePersistentUserChoicesOptions = {}
) {
const [userChoices, setSettings] = React.useState<LocalUserChoices>(
loadUserChoices(options.defaults, options.preventLoad ?? false)
)
const saveAudioInputEnabled = React.useCallback((isEnabled: boolean) => {
setSettings((prev: LocalUserChoices) => ({
...prev,
audioEnabled: isEnabled,
}))
}, [])
const saveVideoInputEnabled = React.useCallback((isEnabled: boolean) => {
setSettings((prev: LocalUserChoices) => ({
...prev,
videoEnabled: isEnabled,
}))
}, [])
const saveAudioInputDeviceId = React.useCallback((deviceId: string) => {
setSettings((prev: LocalUserChoices) => ({
...prev,
audioDeviceId: deviceId,
}))
}, [])
const saveVideoInputDeviceId = React.useCallback((deviceId: string) => {
setSettings((prev: LocalUserChoices) => ({
...prev,
videoDeviceId: deviceId,
}))
}, [])
const saveUsername = React.useCallback((username: string) => {
setSettings((prev: LocalUserChoices) => ({ ...prev, username: username }))
}, [])
const saveProcessorSerialized = React.useCallback(
(processorSerialized?: ProcessorSerialized) => {
setSettings((prev: LocalUserChoices) => ({
...prev,
processorSerialized,
}))
},
[]
)
React.useEffect(() => {
saveUserChoices(userChoices, options.preventSave ?? false)
}, [userChoices, options.preventSave])
export function usePersistentUserChoices() {
const userChoicesSnap = useSnapshot(userChoicesStore)
return {
userChoices,
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
saveUsername,
saveProcessorSerialized,
userChoices: userChoicesSnap,
saveAudioInputEnabled: (isEnabled: boolean) => {
userChoicesStore.audioEnabled = isEnabled
},
saveVideoInputEnabled: (isEnabled: boolean) => {
userChoicesStore.videoEnabled = isEnabled
},
saveAudioInputDeviceId: (deviceId: string) => {
userChoicesStore.audioDeviceId = deviceId
},
saveVideoInputDeviceId: (deviceId: string) => {
userChoicesStore.videoDeviceId = deviceId
},
saveUsername: (username: string) => {
userChoicesStore.username = username
},
saveProcessorSerialized: (
processorSerialized: ProcessorSerialized | undefined
) => {
userChoicesStore.processorSerialized = processorSerialized
},
}
}
@@ -47,16 +47,13 @@ export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
* ```
* @public
*/
export function ControlBar({
saveUserChoices = true,
onDeviceError,
}: ControlBarProps) {
export function ControlBar({ onDeviceError }: ControlBarProps) {
const {
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
} = usePersistentUserChoices({ preventSave: !saveUserChoices })
} = usePersistentUserChoices()
const microphoneOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
@@ -1,23 +1,16 @@
import { useEffect, useState } from 'react'
import {
usePersistentUserChoices,
type LocalUserChoices as LocalUserChoicesLK,
} from '@livekit/components-react'
import { usePersistentUserChoices } from '@livekit/components-react'
import { useLocation, useParams } from 'wouter'
import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
import { ProcessorSerialized } from '../livekit/components/blur'
import {
isRoomValid,
normalizeRoomId,
} from '@/features/rooms/utils/isRoomValid'
export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized
}
import { LocalUserChoices } from '@/stores/userChoices'
export const Room = () => {
const { isLoggedIn } = useUser()
+21
View File
@@ -0,0 +1,21 @@
import { proxy, subscribe } from 'valtio'
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
import {
loadUserChoices,
saveUserChoices,
LocalUserChoices as LocalUserChoicesLK,
} from '@livekit/components-core'
export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized
}
function getUserChoicesState(): LocalUserChoices {
return loadUserChoices()
}
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
subscribe(userChoicesStore, () => {
saveUserChoices(userChoicesStore, false)
})