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:
co-authored by
Claude Opus 4.6
parent
84625bc4d2
commit
e31b15ed28
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user