Compare commits

...

8 Commits

Author SHA1 Message Date
lebaudantoine 77c5329f8a 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
Filter out harmless `ResizeObserver loop limit exceeded` and
`ResizeObserver loop completed with undelivered notifications`
errors via `beforeSend`.

Why this is safe:

* These are W3C spec-mandated browser guards that defer notification
  delivery to the next frame when callbacks alter layout during
  render. They do not cause JS runtime exceptions or break the UX.

Why we actually need to filter them:

* Telemetry platforms like PostHog do not stack/group these well,
  frequently generating distinct error events per browser engine
  and version.
* The unique variants flood reporting dashboards and trigger
  false-positive alerts that clutter real issue triage.
2026-08-13 14:37:55 +02:00
lebaudantoine c8ec1c8a9d 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
* Switch toolbar horizontal alignment from `marginRight` to
  `transform: translateX()`, so it no longer triggers layout reflows
  during ResizeObserver cycles and stops the "ResizeObserver loop"
  error.
* Replace the unstable `shift * 2` margin heuristic with a direct
  1:1 positional delta (`offsetX + shift`).
* Decouple CSS transitions: use the individual CSS `translate`
  property for the slide-up/down animations, leaving `transform`
  free for dynamic horizontal positioning.
2026-08-13 14:37:55 +02:00
lebaudantoine 01e004e272 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
Copy the `formatChatMessageLinks` function locally so we can iterate
on it without patching the upstream dependency.

Use the local copy to trim `\n` characters at the beginning and end
of chat messages, which were leaking into the rendered output.
2026-08-13 14:37:55 +02:00
lebaudantoine cbfb97eb54 🐛(frontend) implement hysteresis band for the control bar layout
Introduce dual thresholds (1100px wide, 1050px narrow) for switching
the control bar between the expanded inline controls and the
collapsed menu.

The 50px deadband absorbs the width changes caused by rendering
5 buttons vs. 1 button, preventing an infinite layout oscillation
and the resulting `ResizeObserver loop` errors.
2026-08-13 14:37:55 +02:00
lebaudantoine ac503b3ae5 🔥(frontend) drop unused vendored ConnectionObserver
The vendored ConnectionObserver collected connection data that never
turned out to be useful for debugging.

Remove it to reduce dead code, and re-add a targeted observer later
if a concrete debugging need shows up.
2026-08-13 10:34:18 +02:00
lebaudantoine 52f119db02 🐛(frontend) harden speaker test against missing sinks and play errors
- Only call `setSinkId` when supported and the device is actually
  enumerated: LiveKit can fall back to a stale id on browsers (e.g.
  WebKit) that expose no such device, making `setSinkId` throw
  `NotFoundError`.
- Await `audio.play()` and reset the playing state on failure, to
  avoid a stuck button and an unhandled rejection.
- Use an absolute `/sounds/uprise.mp3` URL so the asset resolves
  regardless of the current SPA route.
2026-08-13 10:25:16 +02:00
lebaudantoine 387ae17c22 🐛(frontend) handle 401 responses when syncing user preferences
401 responses were not handled by the user preferences sync, which
could leave the app in an inconsistent state when the session had
expired.

Handle the 401 case explicitly and report the error through the
telemetry module so it stays visible without crashing the flow.
2026-08-13 10:25:16 +02:00
lebaudantoine 1eb6f0b9e7 📈(frontend) downgrade unreachable external home URL from error to event
The "unreachable external home URL" check was reporting failures as
errors. In practice, it fired a lot for users behind corporate
networks that cannot reach our public landing page, which is
expected behavior and not something to investigate.

Capture it as a regular telemetry event instead of an error, so it
still gives us visibility on the frequency of the case without
polluting error dashboards.
2026-08-13 10:25:16 +02:00
17 changed files with 138 additions and 183 deletions
+14
View File
@@ -8,6 +8,20 @@ and this project adheres to
## [Unreleased]
### Changed
- 🔥(frontend) drop unused vendored ConnectionObserver
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
### Fixed
- 📈(frontend) downgrade unreachable external home URL from error to event
- 🐛(frontend) handle 401 responses when syncing user preferences
- 🐛(frontend) harden speaker test against missing sinks and play errors
- 🐛(frontend) implement hysteresis band for the control bar layout
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
## [1.26.0] - 2026-08-12
### Added
+23 -16
View File
@@ -3,27 +3,30 @@ import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { reportError } from '@/features/analytics/telemetry'
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
export const SoundTester = () => {
const { t } = useTranslation('settings')
const [isPlaying, setIsPlaying] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null)
const { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' })
const { devices, activeDeviceId } = useMediaDeviceSelect({
kind: 'audiooutput',
})
useEffect(() => {
const updateActiveId = async (deviceId: string) => {
try {
await audioRef?.current?.setSinkId(deviceId)
} catch (error) {
reportError(
'device_switch_failure',
new Error(`Error setting sinkId: ${error}`)
)
if (!canTestAudioOutput() || !activeDeviceId) return
if (!devices.some((device) => device.deviceId === activeDeviceId)) return
audioRef.current?.setSinkId(activeDeviceId).catch((error) => {
if (error instanceof DOMException && error.name === 'NotFoundError') {
return
}
}
updateActiveId(activeDeviceId)
}, [activeDeviceId])
reportError(
'device_switch_failure',
new Error(`Error setting sinkId: ${error}`)
)
})
}, [devices, activeDeviceId])
// prevent pausing the sound
navigator.mediaSession.setActionHandler('pause', function () {})
@@ -32,9 +35,13 @@ export const SoundTester = () => {
<>
<Button
variant="secondaryText"
onPress={() => {
audioRef?.current?.play()
setIsPlaying(true)
onPress={async () => {
try {
await audioRef?.current?.play()
setIsPlaying(true)
} catch {
setIsPlaying(false)
}
}}
size="sm"
isDisabled={isPlaying}
@@ -48,7 +55,7 @@ export const SoundTester = () => {
{/* eslint-disable jsx-a11y/media-has-caption */}
<audio
ref={audioRef}
src="sounds/uprise.mp3"
src="/sounds/uprise.mp3"
onEnded={() => setIsPlaying(false)}
/>
</>
@@ -0,0 +1,24 @@
import type { CaptureResult } from 'posthog-js'
const IGNORED_EXCEPTION_PATTERNS = [
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
]
const shouldIgnoreException = (value: unknown): boolean =>
typeof value === 'string' &&
IGNORED_EXCEPTION_PATTERNS.some((pattern) => pattern.test(value))
export const filterExceptions = (
event: CaptureResult | null
): CaptureResult | null => {
if (event?.event !== '$exception') return event
const exceptionList = event.properties?.['$exception_list']
const values: unknown[] = Array.isArray(exceptionList)
? exceptionList.map((exception) => exception?.value)
: []
values.push(event.properties?.['$exception_message'])
return values.some(shouldIgnoreException) ? null : event
}
@@ -2,6 +2,7 @@ import { useEffect } from 'react'
import { type ApiUser } from '@/features/auth/api/ApiUser'
import { useUser } from '@/features/auth/api/useUser'
import { getPosthog } from '../utils'
import { filterExceptions } from '../exceptionFilters'
export const startAnalyticsSession = (data: ApiUser) => {
getPosthog().then((ph) => {
@@ -47,6 +48,7 @@ export const useAnalytics = ({
capture_unhandled_rejections: true,
capture_console_errors: true,
},
before_send: filterExceptions,
})
})
}, [id, host, flags_api_host, isDisabled])
@@ -6,6 +6,8 @@ import { queryClient } from '@/api/queryClient'
import { updateUserPreferences } from './updateUserPreferences'
import { convertToBackendLanguage } from '@/utils/languages'
import { useUser } from './useUser'
import { ApiError } from '@/api/ApiError.ts'
import { reportError } from '@/features/analytics/telemetry'
/**
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
@@ -42,6 +44,11 @@ export const useSyncUserPreferencesWithBackend = () => {
}
}
syncBrowserPreferencesToBackend()
syncBrowserPreferencesToBackend().catch((error) => {
if (error instanceof ApiError && error.statusCode === 401) return
reportError('generic_failure', error, {
context: '[useSyncUserPreferencesWithBackend] Failed to sync:',
})
})
}, [i18n.language, isLoggedIn, user, mutateAsync])
}
@@ -1,6 +1,6 @@
import { ChatRow } from '@/stores/chat'
import React, { useMemo } from 'react'
import { formatChatMessageLinks } from '@livekit/components-react'
import { formatChatMessageLinks } from '../utils'
import { css } from '@/styled-system/css'
import { Text } from '@/primitives'
+32
View File
@@ -0,0 +1,32 @@
import { tokenize, createDefaultGrammar } from '@livekit/components-core'
import { ReactNode } from 'react'
const defaultGrammar = Object.freeze(createDefaultGrammar())
export function formatChatMessageLinks(message: string): ReactNode {
const trimmedMessage = message.replace(/^[\r\n]+|[\r\n]+$/g, '')
return tokenize(trimmedMessage, defaultGrammar).map((tok, i) => {
if (typeof tok === `string`) {
return tok
} else {
const content = tok.content.toString()
const href =
tok.type === `url`
? /^http(s?):\/\//.test(content)
? content
: `https://${content}`
: `mailto:${content}`
return (
<a
className="lk-chat-link"
key={i}
href={href}
target="_blank"
rel="noreferrer"
>
{content}
</a>
)
}
})
}
@@ -15,7 +15,7 @@ import { css } from '@/styled-system/css'
import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton'
import { LoadingScreen } from '@/components/LoadingScreen'
import { reportError } from '@/features/analytics/telemetry'
import { captureEvent } from '@/features/analytics/telemetry'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
@@ -161,8 +161,10 @@ const Home = () => {
window.location.replace(data.external_home_url)
} catch (error) {
setRedirectFailed(true)
reportError('generic_failure', error, {
context: 'Site is not reachable:',
captureEvent('external-home-unreachable', {
error_name: error instanceof Error ? error.name : 'Unknown',
error_message:
error instanceof Error ? error.message : String(error),
})
}
}
@@ -6,7 +6,7 @@ import type { NotificationType } from '@/features/notifications/NotificationType
// fixme - handle dynamic audio output changes
export const useNotificationSound = () => {
const notificationsSnap = useSnapshot(notificationsStore)
const [play] = useSound('./sounds/notifications.mp3', {
const [play] = useSound('/sounds/notifications.mp3', {
sprite: {
participantJoined: [0, 1150],
handRaised: [1400, 180],
@@ -26,8 +26,8 @@ const StyledContainer = styled('div', {
backgroundColor: 'primaryDark.100',
maxWidth: '100%',
opacity: 0,
transform: 'translateY(3.25rem)',
transition: 'opacity, transform',
translate: '0 3.25rem',
transition: 'opacity, translate',
transitionDuration: '0.5s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
pointerEvents: 'none',
@@ -36,7 +36,7 @@ const StyledContainer = styled('div', {
isVisible: {
true: {
opacity: 1,
transform: 'translateY(0)',
translate: '0 0',
pointerEvents: 'auto',
},
},
@@ -84,7 +84,7 @@ export const ReactionButtonsContainer = ({
shouldBeCenteredWithToggleButton,
setShouldBeCenteredWithToggleButton,
] = useState(false)
const [rightOffset, setRightOffset] = useState(0)
const [offsetX, setOffsetX] = useState(0)
const updateArrows = useCallback(() => {
const el = scrollRef.current
@@ -115,7 +115,7 @@ export const ReactionButtonsContainer = ({
useLayoutEffect(() => {
if (!shouldBeCenteredWithToggleButton || isMobile) {
setRightOffset(0)
setOffsetX(0)
return
}
@@ -133,7 +133,7 @@ export const ReactionButtonsContainer = ({
const containerCenterX = containerRect.left + containerRect.width / 2
const shift = toggleCenterX - containerCenterX
if (Math.abs(shift) < 0.5) return
setRightOffset((prev) => prev - shift * 2)
setOffsetX((prev) => prev + shift)
}
const schedule = () => {
@@ -182,7 +182,7 @@ export const ReactionButtonsContainer = ({
isVisible={isVisible}
style={
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
? { marginRight: `${rightOffset}px` }
? { transform: `translateX(${offsetX}px)` }
: { margin: '0 15px' }
}
>
@@ -30,7 +30,6 @@ import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { PictureInPictureConference } from '@/features/pip/components/PictureInPictureConference'
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
import { useSnapshot } from 'valtio'
@@ -247,23 +246,8 @@ export const Conference = ({
onDisconnected={(e) => {
const metadata = {
room_id: roomId,
pc_publisher: connectionObserverStore.publisher && {
...connectionObserverStore.publisher,
},
pc_subscriber: connectionObserverStore.subscriber && {
...connectionObserverStore.subscriber,
},
pc_publisher_changes_count:
connectionObserverStore.publisherChangesCount,
pc_subscriber_changes_count:
connectionObserverStore.subscriberChangesCount,
}
connectionObserverStore.publisher = null
connectionObserverStore.publisherChangesCount = 0
connectionObserverStore.subscriber = null
connectionObserverStore.subscriberChangesCount = 0
switch (e) {
case DisconnectReason.CLIENT_INITIATED:
navigateTo(
@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
import { styled, VStack } from '@/styled-system/jsx'
import { Button as RACButton } from 'react-aria-components'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import type { CandidateInfo } from '@/stores/connectionObserver'
import { captureEvent } from '@/features/analytics/telemetry'
const Card = styled('div', {
@@ -240,10 +239,6 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
type RatingMetadata = {
room_id?: string
pc_publisher?: CandidateInfo
pc_subscriber?: CandidateInfo
pc_publisher_changes_count?: number
pc_subscriber_changes_count?: number
}
export const Rating = ({
@@ -9,14 +9,9 @@ import { useSnapshot } from 'valtio'
import { DisconnectReason, RoomEvent } from 'livekit-client'
import { userPreferencesStore } from '@/stores/userPreferences'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { isMobileBrowser } from '@livekit/components-core'
import { FeatureFlags } from '@/features/analytics/enums'
import { captureEvent, captureMediaEvent } from '@/features/analytics/telemetry'
const CANDIDATE_POLL_INTERVAL_MS = 5000
import { connectionObserverStore } from '@/stores/connectionObserver'
export const ConnectionObserver = () => {
const room = useRoomContext()
@@ -24,13 +19,6 @@ export const ConnectionObserver = () => {
const { data } = useConfig()
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
const isMobile = isMobileBrowser()
const isAdvancedConnectionObserverEnabled =
!isMobile && isAnalyticsEnabled && featureEnabled
const userPreferencesSnap = useSnapshot(userPreferencesStore)
const idleDisconnectModalTimeoutRef = useRef<ReturnType<
@@ -80,100 +68,6 @@ export const ConnectionObserver = () => {
userPreferencesSnap.is_idle_disconnect_modal_enabled,
])
useEffect(() => {
if (!isAdvancedConnectionObserverEnabled) return
if (!room) return
let interval: ReturnType<typeof setInterval> | null = null
const pollCandidate = async (
label: 'publisher' | 'subscriber',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pc?: any
) => {
if (!pc) return
let stats: RTCStatsReport
try {
stats = await pc.getStats()
} catch {
return
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stats.forEach((report: any) => {
if (
report.type === 'candidate-pair' &&
report.state === 'succeeded' &&
report.nominated
) {
const remoteCandidate = stats.get(report.remoteCandidateId)
if (!remoteCandidate) return
const next = {
type: remoteCandidate.candidateType,
address: remoteCandidate.address,
protocol: remoteCandidate.protocol,
}
const current = connectionObserverStore[label]
const hasChanged =
current?.type !== next.type ||
current?.address !== next.address ||
current?.protocol !== next.protocol
if (hasChanged) {
connectionObserverStore[label] = next
const key = `${label}ChangesCount` as const
connectionObserverStore[key] =
(connectionObserverStore[key] || 0) + 1
}
}
})
}
const poll = async () => {
const publisher = room.engine?.pcManager?.publisher
const subscriber = room.engine?.pcManager?.subscriber
await Promise.all([
pollCandidate('publisher', publisher),
pollCandidate('subscriber', subscriber),
])
}
const startPolling = async () => {
if (interval) return // prevent duplicates
// Initial snapshot
await poll()
interval = setInterval(poll, CANDIDATE_POLL_INTERVAL_MS)
}
const stopPolling = () => {
if (!interval) return
clearInterval(interval)
interval = null
}
room.on(RoomEvent.Connected, startPolling)
room.on(RoomEvent.Reconnected, startPolling)
room.on(RoomEvent.Reconnecting, stopPolling)
room.on(RoomEvent.Disconnected, stopPolling)
return () => {
stopPolling()
room.off(RoomEvent.Connected, startPolling)
room.off(RoomEvent.Reconnected, startPolling)
room.off(RoomEvent.Reconnecting, stopPolling)
room.off(RoomEvent.Disconnected, stopPolling)
}
}, [room, isAdvancedConnectionObserverEnabled])
useEffect(() => {
if (!isAnalyticsEnabled) return
@@ -126,7 +126,7 @@ export const OutputSoundTester = ({
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio
ref={audioRef}
src="sounds/uprise.mp3"
src="/sounds/uprise.mp3"
onEnded={() => setIsPlaying(false)}
/>
</StyledContainer>
@@ -12,7 +12,8 @@ import type { ToggleButtonProps } from '@/primitives/ToggleButton'
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
const CONTROL_BAR_BREAKPOINT = 1100
const CONTROL_BAR_BREAKPOINT_WIDE = 1100
const CONTROL_BAR_BREAKPOINT_NARROW = 1050
const NavigationControls = ({
onPress,
@@ -65,10 +66,9 @@ export const LateralMenu = () => {
</DialogTrigger>
)
}
interface BreakpointObserverProps {
containerRef: RefObject<HTMLDivElement>
onWideChange: (isWide: boolean) => void
onWideChange: (isWide: boolean | null) => void
}
const BreakpointObserver = ({
@@ -76,7 +76,20 @@ const BreakpointObserver = ({
onWideChange,
}: BreakpointObserverProps) => {
const { width } = useSize(containerRef)
const isWide = width > CONTROL_BAR_BREAKPOINT
const [isWide, setIsWide] = useState<boolean | null>(null)
useEffect(() => {
if (!width) {
return
}
if (width > CONTROL_BAR_BREAKPOINT_WIDE) {
setIsWide(true)
} else if (width <= CONTROL_BAR_BREAKPOINT_NARROW) {
setIsWide(false)
} else {
setIsWide((prev) => (prev === null ? false : prev))
}
}, [width])
useEffect(() => {
onWideChange(isWide)
@@ -90,7 +103,7 @@ export const MoreOptions = ({
}: {
parentElement: RefObject<HTMLDivElement>
}) => {
const [isWide, setIsWide] = useState(false)
const [isWide, setIsWide] = useState<boolean | null>(null)
return (
<nav
@@ -107,7 +120,7 @@ export const MoreOptions = ({
containerRef={parentElement}
onWideChange={setIsWide}
/>
{isWide ? <NavigationControls /> : <LateralMenu />}
{isWide !== null && (isWide ? <NavigationControls /> : <LateralMenu />)}
</nav>
)
}
@@ -6,7 +6,6 @@ import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation } from 'wouter'
import { useMemo } from 'react'
import { DisconnectReason } from 'livekit-client'
import type { CandidateInfo } from '@/stores/connectionObserver'
// fixme - duplicated with home, refactor in a proper style
const Heading = styled('h1', {
@@ -48,10 +47,6 @@ const FeedbackRoute = () => {
const state = window.history.state
return {
room_id: state?.room_id as string,
pc_publisher: state?.pc_publisher as CandidateInfo,
pc_publisher_changes_count: state?.pc_publisher_changes_count as number,
pc_subscriber: state?.pc_subscriber as CandidateInfo,
pc_subscriber_changes_count: state?.pc_subscriber_changes_count as number,
}
}, [])
@@ -1,23 +1,9 @@
import { proxy } from 'valtio'
export type CandidateInfo = {
type: string
address: string
protocol: string
}
type State = {
isIdleDisconnectModalOpen: boolean
publisher: CandidateInfo | null
publisherChangesCount: number
subscriber: CandidateInfo | null
subscriberChangesCount: number
}
export const connectionObserverStore = proxy<State>({
isIdleDisconnectModalOpen: false,
publisher: null,
publisherChangesCount: 0,
subscriber: null,
subscriberChangesCount: 0,
})