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:
co-authored by
Claude Opus 4.6
parent
aa263ec8ae
commit
854b1cd1df
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user