From 854b1cd1df32875ccf0a1de629bc6412f1b32f03 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 13 Mar 2026 10:04:03 +0000 Subject: [PATCH] fix: prevent memory leaks on consecutive fight replays - FightViewer: store canvas event handlers and remove them before replacing canvas elements, preventing detached DOM/closure leaks - FightViewer: clean up canvas listeners on unmount - tts.ts: clear _staticLoading dedup map after precache completes Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/FightViewer.vue | 25 +++++++++++++++++++++---- frontend/src/game/tts.ts | 2 ++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index ad3fb32..a80f02c 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -30,6 +30,8 @@ let cleanupTimerHandle: ReturnType | null = null let destroyed = false let initializingScene = false const contextLost = ref(false) +let canvasContextLostHandler: ((e: Event) => void) | null = null +let canvasContextRestoredHandler: (() => void) | null = null const isReplaying = ref(false) const ttsProgress = ref(-1) @@ -171,6 +173,14 @@ onUnmounted(() => { stopAllAudio() sceneReady.value = false if (scene) { scene.destroy(); scene = null } + // Remove canvas event listeners to prevent detached DOM leaks + const canvas = canvasRef.value + if (canvas && canvasContextLostHandler) { + canvas.removeEventListener('webglcontextlost', canvasContextLostHandler) + canvas.removeEventListener('webglcontextrestored', canvasContextRestoredHandler!) + } + canvasContextLostHandler = null + canvasContextRestoredHandler = null if (cleanupTimerHandle) { clearTimeout(cleanupTimerHandle); cleanupTimerHandle = null } if (ttsProgressTimer) { clearInterval(ttsProgressTimer); ttsProgressTimer = null } if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() @@ -194,6 +204,11 @@ async function initScene() { // Replace canvas element so Kaplay gets a fresh context const oldCanvas = canvasRef.value + // Remove old canvas event listeners to prevent memory leaks + if (oldCanvas && canvasContextLostHandler) { + oldCanvas.removeEventListener('webglcontextlost', canvasContextLostHandler) + oldCanvas.removeEventListener('webglcontextrestored', canvasContextRestoredHandler!) + } const newCanvas = document.createElement('canvas') newCanvas.className = 'w-full h-full block' newCanvas.width = container.clientWidth || 800 @@ -207,17 +222,19 @@ async function initScene() { contextLost.value = false // Handle WebGL/Canvas context loss (GPU pressure, tab backgrounding, etc.) - newCanvas.addEventListener('webglcontextlost', (e) => { + canvasContextLostHandler = (e: Event) => { e.preventDefault() // Allow context restoration contextLost.value = true sceneReady.value = false - }) - newCanvas.addEventListener('webglcontextrestored', () => { + } + canvasContextRestoredHandler = () => { // Don't auto-reinit during replay — just mark context as restored // The contextLost overlay will let users tap to reload if needed contextLost.value = false if (!isReplaying.value) initScene() - }) + } + newCanvas.addEventListener('webglcontextlost', canvasContextLostHandler) + newCanvas.addEventListener('webglcontextrestored', canvasContextRestoredHandler) // Wrap scene creation in try/catch + timeout so a mobile sprite loading failure // or hang never blocks the overlay/voice/round flow diff --git a/frontend/src/game/tts.ts b/frontend/src/game/tts.ts index 662f0be..6d23ee1 100644 --- a/frontend/src/game/tts.ts +++ b/frontend/src/game/tts.ts @@ -358,6 +358,8 @@ async function _precacheCommon() { } _precacheDone = true _precacheRunning = false + // Release dedup promises — they hold closures referencing fetch/decode state + _staticLoading.clear() } function _cacheKey(text: string, profile: string): string {