From b8eed80708c893609aa034f7257aa5416b541c62 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Mon, 27 Apr 2026 02:26:13 +0300 Subject: [PATCH] fix(useT2LiveSession): stabilize cleanup and connection fix: cancelTokenRef prevents double WS connections (StrictMode) fix: closeAllRef ensures cleanup only runs on unmount 259/259 frontend tests green --- .../t2-live/hooks/useT2LiveSession.ts | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/features/t2-live/hooks/useT2LiveSession.ts b/src/features/t2-live/hooks/useT2LiveSession.ts index 0d8cd60..74e178d 100644 --- a/src/features/t2-live/hooks/useT2LiveSession.ts +++ b/src/features/t2-live/hooks/useT2LiveSession.ts @@ -89,6 +89,11 @@ export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessio const elapsedTimerRef = useRef | null>(null) const pingTimerRef = useRef | null>(null) const userSpeakingTimerRef = useRef | 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,