From e31b15ed28ab9d1356025b3b9a82b1b34b3e3e43 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 7 Mar 2026 23:37:43 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20page-breaking=20bugs=20=E2=80=94=20dupli?= =?UTF-8?q?cate=20onMounted,=20SSE=20race,=20canvas=20lifecycle,=20ghost?= =?UTF-8?q?=20animation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/components/FightViewer.vue | 37 +++++++----- frontend/src/pages/ArenaPage.vue | 25 ++++----- frontend/src/pages/FightPage.vue | 32 ++++++++++- frontend/src/pages/HomePage.vue | 75 ++++++++++++++----------- 4 files changed, 104 insertions(+), 65 deletions(-) diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index 4529490..24fd4d7 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -42,8 +42,10 @@ const props = defineProps<{ fight: FightData; autoplay?: boolean }>() const emit = defineEmits<{ 'replay-done': [] }>() const canvasRef = ref() +const canvasContainer = ref() const logEl = ref() let scene: FightSceneController | null = null +const sceneReady = ref(false) const isReplaying = ref(false) const displayHpA = ref(100) @@ -94,32 +96,39 @@ function mapHp(hp: number, winnerId: string | null, botId: string | undefined): onUnmounted(() => { stopAllAudio() + sceneReady.value = false if (scene) { scene.destroy(); scene = null } }) async function initScene() { - if (!canvasRef.value || !props.fight.botA || !props.fight.botB) return - // Destroy previous scene fully — replace canvas to avoid "KAPLAY already initialized" + if (!props.fight.botA || !props.fight.botB) return + // Destroy previous scene fully + sceneReady.value = false if (scene) { scene.destroy(); scene = null } - const container = canvasRef.value.parentElement - if (container) { - // Replace canvas element so Kaplay gets a fresh context - const oldCanvas = canvasRef.value - const newCanvas = document.createElement('canvas') - newCanvas.className = oldCanvas.className - newCanvas.width = container.clientWidth - newCanvas.height = container.clientHeight + const container = canvasContainer.value + if (!container) return + + // Replace canvas element so Kaplay gets a fresh context + const oldCanvas = canvasRef.value + const newCanvas = document.createElement('canvas') + newCanvas.className = 'w-full h-full block' + newCanvas.width = container.clientWidth + newCanvas.height = container.clientHeight + if (oldCanvas) { oldCanvas.replaceWith(newCanvas) - canvasRef.value = newCanvas + } else { + container.prepend(newCanvas) } + canvasRef.value = newCanvas scene = await createFightScene({ - canvas: canvasRef.value, + canvas: newCanvas, botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype, customization: props.fight.botA.customization as any, wins: props.fight.botA.wins, losses: props.fight.botA.losses }, botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype, customization: props.fight.botB.customization as any, wins: props.fight.botB.wins, losses: props.fight.botB.losses }, arena: props.fight.arena, }) + sceneReady.value = true } const challengeLabel = (type: string) => { @@ -262,7 +271,7 @@ async function replay() { const bWon = round.winnerId === props.fight.botB!.id try { - await scene!.playRound({ + await scene?.playRound({ round: round.roundNumber, challengeType: round.challengeType, winnerId: round.winnerId, @@ -501,7 +510,7 @@ async function replay() { -
+
diff --git a/frontend/src/pages/ArenaPage.vue b/frontend/src/pages/ArenaPage.vue index 76ed8ab..df705c2 100644 --- a/frontend/src/pages/ArenaPage.vue +++ b/frontend/src/pages/ArenaPage.vue @@ -21,25 +21,22 @@ interface FightResult { const fights = ref([]) 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() { diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue index 4e2f3e8..f44725f 100644 --- a/frontend/src/pages/FightPage.vue +++ b/frontend/src/pages/FightPage.vue @@ -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([]) 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) + } } }) diff --git a/frontend/src/pages/HomePage.vue b/frontend/src/pages/HomePage.vue index 50ea9cb..7378ba5 100644 --- a/frontend/src/pages/HomePage.vue +++ b/frontend/src/pages/HomePage.vue @@ -242,54 +242,61 @@ function shuffle(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((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 } })