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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-13 10:04:03 +00:00
co-authored by Claude Opus 4.6
parent aa263ec8ae
commit 854b1cd1df
2 changed files with 23 additions and 4 deletions
+21 -4
View File
@@ -30,6 +30,8 @@ let cleanupTimerHandle: ReturnType<typeof setTimeout> | null = null
let destroyed = false let destroyed = false
let initializingScene = false let initializingScene = false
const contextLost = ref(false) const contextLost = ref(false)
let canvasContextLostHandler: ((e: Event) => void) | null = null
let canvasContextRestoredHandler: (() => void) | null = null
const isReplaying = ref(false) const isReplaying = ref(false)
const ttsProgress = ref(-1) const ttsProgress = ref(-1)
@@ -171,6 +173,14 @@ onUnmounted(() => {
stopAllAudio() stopAllAudio()
sceneReady.value = false sceneReady.value = false
if (scene) { scene.destroy(); scene = null } 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 (cleanupTimerHandle) { clearTimeout(cleanupTimerHandle); cleanupTimerHandle = null }
if (ttsProgressTimer) { clearInterval(ttsProgressTimer); ttsProgressTimer = null } if (ttsProgressTimer) { clearInterval(ttsProgressTimer); ttsProgressTimer = null }
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
@@ -194,6 +204,11 @@ async function initScene() {
// Replace canvas element so Kaplay gets a fresh context // Replace canvas element so Kaplay gets a fresh context
const oldCanvas = canvasRef.value 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') const newCanvas = document.createElement('canvas')
newCanvas.className = 'w-full h-full block' newCanvas.className = 'w-full h-full block'
newCanvas.width = container.clientWidth || 800 newCanvas.width = container.clientWidth || 800
@@ -207,17 +222,19 @@ async function initScene() {
contextLost.value = false contextLost.value = false
// Handle WebGL/Canvas context loss (GPU pressure, tab backgrounding, etc.) // Handle WebGL/Canvas context loss (GPU pressure, tab backgrounding, etc.)
newCanvas.addEventListener('webglcontextlost', (e) => { canvasContextLostHandler = (e: Event) => {
e.preventDefault() // Allow context restoration e.preventDefault() // Allow context restoration
contextLost.value = true contextLost.value = true
sceneReady.value = false sceneReady.value = false
}) }
newCanvas.addEventListener('webglcontextrestored', () => { canvasContextRestoredHandler = () => {
// Don't auto-reinit during replay — just mark context as restored // Don't auto-reinit during replay — just mark context as restored
// The contextLost overlay will let users tap to reload if needed // The contextLost overlay will let users tap to reload if needed
contextLost.value = false contextLost.value = false
if (!isReplaying.value) initScene() 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 // Wrap scene creation in try/catch + timeout so a mobile sprite loading failure
// or hang never blocks the overlay/voice/round flow // or hang never blocks the overlay/voice/round flow
+2
View File
@@ -358,6 +358,8 @@ async function _precacheCommon() {
} }
_precacheDone = true _precacheDone = true
_precacheRunning = false _precacheRunning = false
// Release dedup promises — they hold closures referencing fetch/decode state
_staticLoading.clear()
} }
function _cacheKey(text: string, profile: string): string { function _cacheKey(text: string, profile: string): string {