Compare commits

..

2 Commits

Author SHA1 Message Date
lebaudantoine bd51c142f6 🐛(frontend) return real 404s for missing hashed assets
Nginx was rewriting missing `/assets/*` files to `index.html`, so
requests for stale chunks got a 200 with the SPA shell served as a
JS module. That is what turned a plain 404 into the confusing:

  "TypeError: error loading dynamically imported module"

whenever a client loaded before a deployment requested an old chunk.

Stop rewriting missing `/assets/*` to `index.html`: those requests
now return a real 404 with `Cache-Control: no-store`. SPA route
fallback for non-asset paths is unchanged.
2026-08-12 19:40:47 +02:00
lebaudantoine 2af6157265 (frontend) recover from stale lazy-loaded chunks after a deploy
Route components are code-split with content-hashed filenames. A
user who loaded the app before a deployment (typically someone
sitting in a call) still holds an `index.html` referencing old chunk
names. When they navigate after the deploy — e.g. to `/feedback` on
leaving a room — the old chunk is gone and the dynamic import fails
with "TypeError: error loading dynamically imported module".

Reload the page on that failure to fetch a fresh `index.html` with
the current hashes, which transparently fixes the stale-deploy case.

Guard against infinite reload loops with a `RELOAD_COOLDOWN_MS`: if
the import fails again right after a reload, the cause is not a
stale deploy (ad blocker, proxy, outage) and reloading further would
loop forever. In that case, let the error propagate so it reaches
monitoring.

Inspired by https://vite.dev/guide/build#load-error-handling
2026-08-12 19:37:24 +02:00
9 changed files with 57 additions and 41 deletions
+1 -3
View File
@@ -10,9 +10,7 @@ and this project adheres to
### Fixed ### Fixed
- 📈(frontend) downgrade unreachable external home URL from error to event - (frontend) recover from stale lazy-loaded chunks after a deploy
- 🐛(frontend) handle 401 responses when syncing user preferences
- 🐛(frontend) harden speaker test against missing sinks and play errors
## [1.26.0] - 2026-08-12 ## [1.26.0] - 2026-08-12
+7
View File
@@ -74,6 +74,13 @@ server {
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d; expires 30d;
add_header Cache-Control "public, max-age=2592000"; add_header Cache-Control "public, max-age=2592000";
try_files $uri =404;
error_page 404 = @asset_missing;
}
location @asset_missing {
add_header Cache-Control "no-store" always;
return 404;
} }
# Serve static files # Serve static files
+7
View File
@@ -14,6 +14,13 @@ server {
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d; expires 30d;
add_header Cache-Control "public, max-age=2592000"; add_header Cache-Control "public, max-age=2592000";
try_files $uri =404;
error_page 404 = @asset_missing;
}
location @asset_missing {
add_header Cache-Control "no-store" always;
return 404;
} }
# Serve static files # Serve static files
+12 -19
View File
@@ -3,30 +3,27 @@ import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react' import { useMediaDeviceSelect } from '@livekit/components-react'
import { reportError } from '@/features/analytics/telemetry' import { reportError } from '@/features/analytics/telemetry'
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
export const SoundTester = () => { export const SoundTester = () => {
const { t } = useTranslation('settings') const { t } = useTranslation('settings')
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null) const audioRef = useRef<HTMLAudioElement>(null)
const { devices, activeDeviceId } = useMediaDeviceSelect({ const { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' })
kind: 'audiooutput',
})
useEffect(() => { useEffect(() => {
if (!canTestAudioOutput() || !activeDeviceId) return const updateActiveId = async (deviceId: string) => {
if (!devices.some((device) => device.deviceId === activeDeviceId)) return try {
audioRef.current?.setSinkId(activeDeviceId).catch((error) => { await audioRef?.current?.setSinkId(deviceId)
if (error instanceof DOMException && error.name === 'NotFoundError') { } catch (error) {
return
}
reportError( reportError(
'device_switch_failure', 'device_switch_failure',
new Error(`Error setting sinkId: ${error}`) new Error(`Error setting sinkId: ${error}`)
) )
}) }
}, [devices, activeDeviceId]) }
updateActiveId(activeDeviceId)
}, [activeDeviceId])
// prevent pausing the sound // prevent pausing the sound
navigator.mediaSession.setActionHandler('pause', function () {}) navigator.mediaSession.setActionHandler('pause', function () {})
@@ -35,13 +32,9 @@ export const SoundTester = () => {
<> <>
<Button <Button
variant="secondaryText" variant="secondaryText"
onPress={async () => { onPress={() => {
try { audioRef?.current?.play()
await audioRef?.current?.play()
setIsPlaying(true) setIsPlaying(true)
} catch {
setIsPlaying(false)
}
}} }}
size="sm" size="sm"
isDisabled={isPlaying} isDisabled={isPlaying}
@@ -55,7 +48,7 @@ export const SoundTester = () => {
{/* eslint-disable jsx-a11y/media-has-caption */} {/* eslint-disable jsx-a11y/media-has-caption */}
<audio <audio
ref={audioRef} ref={audioRef}
src="/sounds/uprise.mp3" src="sounds/uprise.mp3"
onEnded={() => setIsPlaying(false)} onEnded={() => setIsPlaying(false)}
/> />
</> </>
@@ -6,8 +6,6 @@ import { queryClient } from '@/api/queryClient'
import { updateUserPreferences } from './updateUserPreferences' import { updateUserPreferences } from './updateUserPreferences'
import { convertToBackendLanguage } from '@/utils/languages' import { convertToBackendLanguage } from '@/utils/languages'
import { useUser } from './useUser' 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. * Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
@@ -44,11 +42,6 @@ export const useSyncUserPreferencesWithBackend = () => {
} }
} }
syncBrowserPreferencesToBackend().catch((error) => { syncBrowserPreferencesToBackend()
if (error instanceof ApiError && error.statusCode === 401) return
reportError('generic_failure', error, {
context: '[useSyncUserPreferencesWithBackend] Failed to sync:',
})
})
}, [i18n.language, isLoggedIn, user, mutateAsync]) }, [i18n.language, isLoggedIn, user, mutateAsync])
} }
@@ -15,7 +15,7 @@ import { css } from '@/styled-system/css'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton' import { LoginButton } from '@/components/LoginButton'
import { LoadingScreen } from '@/components/LoadingScreen' import { LoadingScreen } from '@/components/LoadingScreen'
import { captureEvent } from '@/features/analytics/telemetry' import { reportError } from '@/features/analytics/telemetry'
const Columns = ({ children }: { children?: ReactNode }) => { const Columns = ({ children }: { children?: ReactNode }) => {
return ( return (
@@ -161,10 +161,8 @@ const Home = () => {
window.location.replace(data.external_home_url) window.location.replace(data.external_home_url)
} catch (error) { } catch (error) {
setRedirectFailed(true) setRedirectFailed(true)
captureEvent('external-home-unreachable', { reportError('generic_failure', error, {
error_name: error instanceof Error ? error.name : 'Unknown', context: 'Site is not reachable:',
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 // fixme - handle dynamic audio output changes
export const useNotificationSound = () => { export const useNotificationSound = () => {
const notificationsSnap = useSnapshot(notificationsStore) const notificationsSnap = useSnapshot(notificationsStore)
const [play] = useSound('/sounds/notifications.mp3', { const [play] = useSound('./sounds/notifications.mp3', {
sprite: { sprite: {
participantJoined: [0, 1150], participantJoined: [0, 1150],
handRaised: [1400, 180], handRaised: [1400, 180],
@@ -126,7 +126,7 @@ export const OutputSoundTester = ({
{/* eslint-disable-next-line jsx-a11y/media-has-caption */} {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio <audio
ref={audioRef} ref={audioRef}
src="/sounds/uprise.mp3" src="sounds/uprise.mp3"
onEnded={() => setIsPlaying(false)} onEnded={() => setIsPlaying(false)}
/> />
</StyledContainer> </StyledContainer>
+20
View File
@@ -2,6 +2,26 @@ import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
const CHUNK_RELOAD_KEY = 'vite-preload-error-reload-at'
const RELOAD_COOLDOWN_MS = 30_000
window.addEventListener('vite:preloadError', (event) => {
try {
const lastReloadAt = Number(sessionStorage.getItem(CHUNK_RELOAD_KEY)) || 0
if (Date.now() - lastReloadAt <= RELOAD_COOLDOWN_MS) {
// Recent reload didn't help: not a stale deploy, surface the error.
return
}
sessionStorage.setItem(CHUNK_RELOAD_KEY, String(Date.now()))
} catch {
// Without sessionStorage we cannot guard against a reload loop:
// don't auto-reload, let the error propagate.
return
}
event.preventDefault()
window.location.reload()
})
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />