fix: page-breaking bugs — duplicate onMounted, SSE race, canvas lifecycle, ghost animation

- ArenaPage: merge duplicate onMounted hooks into single parallel fetch
- FightPage: queue SSE events until liveFightData ready, add reconnect with backoff
- FightViewer: fix stale canvas ref with container ref, add sceneReady guard
- HomePage: replace stopCycling boolean with AbortController for clean unmount

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-07 23:37:43 +00:00
co-authored by Claude Opus 4.6
parent 84625bc4d2
commit e31b15ed28
4 changed files with 104 additions and 65 deletions
+11 -14
View File
@@ -21,25 +21,22 @@ interface FightResult {
const fights = ref<FightResult[]>([])
const isLoading = ref(true)
onMounted(async () => {
try {
const res = await fetch('/api/fights')
if (res.ok) {
fights.value = await res.json()
}
} catch { /* */ }
isLoading.value = false
})
const isMocking = ref(false)
const bots = ref<{ id: string; name: string; tier: number }[]>([])
const selectedBotId = ref('')
onMounted(async () => {
try {
const botRes = await fetch('/api/bots')
if (botRes.ok) bots.value = await botRes.json()
} catch { /* */ }
const [fightsRes, botsRes] = await Promise.allSettled([
fetch('/api/fights'),
fetch('/api/bots'),
])
if (fightsRes.status === 'fulfilled' && fightsRes.value.ok) {
fights.value = await fightsRes.value.json()
}
if (botsRes.status === 'fulfilled' && botsRes.value.ok) {
bots.value = await botsRes.value.json()
}
isLoading.value = false
})
async function triggerFight() {
+29 -3
View File
@@ -64,6 +64,7 @@ const liveAnnouncementColor = ref('#ffffff')
const liveAnnouncementVisible = ref(false)
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null)
const pendingSSEEvents = ref<{ type: string; data: any }[]>([])
const humanChoices = ref<string[]>([])
const humanFightDone = ref(false)
const humanFightResult = ref<{ winnerId: string; winnerName: string; isPerfect: boolean } | null>(null)
@@ -341,7 +342,20 @@ function connectSSE() {
} catch { /* */ }
})
eventSource.onerror = () => { /* SSE reconnects automatically */ }
let sseRetries = 0
eventSource.onerror = () => {
// EventSource auto-reconnects, but if it keeps failing, reconnect manually with backoff
sseRetries++
if (sseRetries > 5 && eventSource) {
eventSource.close()
eventSource = null
const delay = Math.min(1000 * 2 ** (sseRetries - 5), 10000)
setTimeout(() => {
if (isLive.value && !eventSource) connectSSE()
}, delay)
}
}
eventSource.onopen = () => { sseRetries = 0 }
}
function disconnectSSE() {
@@ -359,7 +373,10 @@ async function showLiveOverlay(text: string, color: string, duration: number) {
async function handleRoundEnd(data: any) {
const fd = liveFightData.value
if (!fd) return
if (!fd) {
pendingSSEEvents.value.push({ type: 'round_end', data })
return
}
const round = data.round
const result = data.result
@@ -451,7 +468,10 @@ async function handleRoundEnd(data: any) {
async function handleFightEnd(data: any) {
const fd = liveFightData.value
if (!fd) return
if (!fd) {
pendingSSEEvents.value.push({ type: 'fight_end', data })
return
}
// Clear challenge
humanChallenge.value = null
@@ -529,6 +549,12 @@ watch(liveFightData, async (val) => {
await nextTick()
await nextTick()
await initLiveScene()
// Drain any SSE events that arrived before liveFightData was ready
const queued = pendingSSEEvents.value.splice(0)
for (const evt of queued) {
if (evt.type === 'round_end') await handleRoundEnd(evt.data)
else if (evt.type === 'fight_end') await handleFightEnd(evt.data)
}
}
})
+41 -34
View File
@@ -242,54 +242,61 @@ function shuffle<T>(arr: T[]): T[] {
return a
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms))
let cycleAbort: AbortController | null = null
function sleep(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const id = setTimeout(resolve, ms)
signal?.addEventListener('abort', () => { clearTimeout(id); reject(signal.reason) }, { once: true })
})
}
let stopCycling = false
async function cycleTaglines() {
async function cycleTaglines(signal: AbortSignal) {
const shuffled = shuffle(taglines)
let idx = 0
while (!stopCycling) {
const line = shuffled[idx % shuffled.length]
isTypingDone.value = false
try {
while (!signal.aborted) {
const line = shuffled[idx % shuffled.length]
isTypingDone.value = false
// Type in
for (let i = 0; i <= line.length; i++) {
if (stopCycling) return
tagline.value = line.slice(0, i)
await sleep(35)
}
isTypingDone.value = true
// Type in
for (let i = 0; i <= line.length; i++) {
if (signal.aborted) return
tagline.value = line.slice(0, i)
await sleep(35, signal)
}
isTypingDone.value = true
// Hold
await sleep(4000)
if (stopCycling) return
// Hold
await sleep(4000, signal)
// Erase
isTypingDone.value = false
for (let i = line.length; i >= 0; i--) {
if (stopCycling) return
tagline.value = line.slice(0, i)
await sleep(20)
}
// Erase
isTypingDone.value = false
for (let i = line.length; i >= 0; i--) {
if (signal.aborted) return
tagline.value = line.slice(0, i)
await sleep(20, signal)
}
await sleep(300)
idx++
await sleep(300, signal)
idx++
// Reshuffle when we've gone through all
if (idx >= shuffled.length) {
idx = 0
const reshuffled = shuffle(taglines)
shuffled.splice(0, shuffled.length, ...reshuffled)
// Reshuffle when we've gone through all
if (idx >= shuffled.length) {
idx = 0
const reshuffled = shuffle(taglines)
shuffled.splice(0, shuffled.length, ...reshuffled)
}
}
} catch {
// AbortError — expected on unmount
}
}
onMounted(async () => {
cycleTaglines()
cycleAbort = new AbortController()
cycleTaglines(cycleAbort.signal)
try {
const res = await fetch('/api/fights')
@@ -301,7 +308,7 @@ onMounted(async () => {
})
onUnmounted(() => {
stopCycling = true
if (cycleAbort) { cycleAbort.abort(); cycleAbort = null }
})
</script>