fix(useT2LiveSession): stabilize cleanup and connection
Some checks failed
CI / quality (push) Has been cancelled

fix: cancelTokenRef prevents double WS connections (StrictMode)
fix: closeAllRef ensures cleanup only runs on unmount
  259/259 frontend tests green
This commit is contained in:
Hermann_Kitio 2026-04-27 02:26:13 +03:00
parent 1d95166611
commit b8eed80708

View file

@ -89,6 +89,11 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const userSpeakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Sprint 6d — token de cancellation pour rendre `startDialogue` idempotent
// sur les appels rapprochés (StrictMode dev double-mount, double-clic, etc.).
// Si une connexion est déjà en cours (token non null), un second appel est
// no-op. Le cleanup d'unmount invalide le token + ferme tout WS en flight.
const cancelTokenRef = useRef<{ cancelled: boolean } | null>(null)
const playback = useAudioPlayback()
const recording = useAudioRecording()
@ -138,6 +143,12 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio
const closeAll = useCallback(() => {
sessionEndedRef.current = true
cleanupTimers()
// Invalide tout `startDialogue` async en flight : il s'arrêtera après
// l'await de getAccessToken sans ouvrir / en fermant immédiatement le WS.
if (cancelTokenRef.current) {
cancelTokenRef.current.cancelled = true
cancelTokenRef.current = null
}
capture.stop()
if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) {
try {
@ -274,14 +285,20 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio
)
const startDialogue = useCallback(async () => {
if (wsRef.current) return
// Idempotent : ws déjà ouvert OU connexion en flight → no-op.
if (wsRef.current || cancelTokenRef.current) return
const localToken = { cancelled: false }
cancelTokenRef.current = localToken
setErrorMessage(null)
setWarning(false)
sessionEndedRef.current = false
dispatch({ type: 'START_DIALOGUE' })
const token = await getAccessToken()
if (localToken.cancelled) return
if (!token) {
cancelTokenRef.current = null
setErrorMessage('Authentification requise.')
dispatch({ type: 'ERROR', code: 4001 })
navigate('/login')
@ -293,11 +310,22 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio
ws = new WebSocket(buildWsUrl(token, sujetId))
ws.binaryType = 'arraybuffer'
} catch (err) {
cancelTokenRef.current = null
const message = err instanceof Error ? err.message : 'Connexion impossible'
setErrorMessage(message)
dispatch({ type: 'ERROR' })
return
}
// Si le composant a été démonté pendant le `new WebSocket(...)` (très rare
// mais possible en StrictMode dev), on ferme immédiatement.
if (localToken.cancelled) {
try {
ws.close()
} catch {
/* ignore */
}
return
}
wsRef.current = ws
ws.onopen = () => {
@ -356,12 +384,25 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio
dispatch({ type: 'END_REQUESTED' })
}, [capture, cleanupTimers, dispatch])
// Cleanup au démontage.
// Cleanup au démontage UNIQUEMENT.
//
// ⚠ Bug subtil corrigé : `closeAll` dépend de `capture` (objet retourné
// par useAudioCapture, recréé à chaque render). Mettre `closeAll` en
// dépendance de cet effet ferait s'exécuter le cleanup à chaque render —
// donc fermer le WS dès la première transition d'état (ws.onopen →
// dispatch WS_OPENED → setState → re-render → cleanup → close).
//
// Pattern : ref qui tient toujours la dernière version de closeAll, effet
// de cleanup avec deps vides qui n'exécute la fonction qu'au démontage.
const closeAllRef = useRef(closeAll)
useEffect(() => {
closeAllRef.current = closeAll
})
useEffect(() => {
return () => {
closeAll()
closeAllRef.current()
}
}, [closeAll])
}, [])
return {
state,