fix: prevent double-tap race conditions and poll leaks in JoinBoutPage
- Don't reset isJoining flags on successful navigation (component unmounts) - Add onBeforeRouteLeave guard to clean up polling and rate limit timers - Use handleError with rate limit countdown for all error paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4cc18048e8
commit
dcd8570ed8
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { useNostr } from '../composables/useNostr'
|
||||
import { useWallet } from '../composables/useWallet'
|
||||
import SpritePreview from '../components/SpritePreview.vue'
|
||||
@@ -21,6 +21,8 @@ const step = ref<string>('login')
|
||||
const isHumanMode = ref(false)
|
||||
const selectedHumanSeed = ref('baby_fighter_1')
|
||||
const error = ref('')
|
||||
const rateLimitCountdown = ref(0)
|
||||
let rateLimitTimer: ReturnType<typeof setInterval> | null = null
|
||||
const isJoining = ref(false)
|
||||
const isJoiningRanked = ref(false)
|
||||
const isJoiningPractice = ref(false)
|
||||
@@ -99,6 +101,32 @@ const archetypeList = [
|
||||
{ id: 'snail', label: 'SNAIL', desc: 'Slow and steady' },
|
||||
]
|
||||
|
||||
/** Detect rate limit errors and start a visible countdown */
|
||||
function handleError(e: unknown, fallback: string) {
|
||||
const msg = e instanceof Error ? e.message : fallback
|
||||
error.value = msg
|
||||
// Parse "Slow down" with retry seconds from the error message or check for countdown pattern
|
||||
if (msg.toLowerCase().includes('too many requests') || msg.toLowerCase().includes('slow down')) {
|
||||
startRateLimitTimer(msg)
|
||||
}
|
||||
}
|
||||
|
||||
function startRateLimitTimer(msg: string) {
|
||||
// Try to extract seconds from response (our API returns retryAfterSec)
|
||||
const match = msg.match(/(\d+)\s*s/)
|
||||
let seconds = match ? parseInt(match[1]) : 30
|
||||
if (seconds <= 0 || seconds > 3600) seconds = 30
|
||||
rateLimitCountdown.value = seconds
|
||||
if (rateLimitTimer) clearInterval(rateLimitTimer)
|
||||
rateLimitTimer = setInterval(() => {
|
||||
rateLimitCountdown.value--
|
||||
if (rateLimitCountdown.value <= 0) {
|
||||
if (rateLimitTimer) { clearInterval(rateLimitTimer); rateLimitTimer = null }
|
||||
error.value = ''
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// If already logged in with a bot, go straight to ready
|
||||
if (isLoggedIn.value) {
|
||||
@@ -108,8 +136,14 @@ onMounted(() => {
|
||||
pollHandle = setInterval(pollQueue, 3000)
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
|
||||
if (rateLimitTimer) { clearInterval(rateLimitTimer); rateLimitTimer = null }
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollHandle) clearInterval(pollHandle)
|
||||
if (rateLimitTimer) { clearInterval(rateLimitTimer); rateLimitTimer = null }
|
||||
})
|
||||
|
||||
async function pollQueue() {
|
||||
@@ -143,7 +177,7 @@ async function handleLogin() {
|
||||
step.value = 'choose-mode'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Login failed.'
|
||||
handleError(e, 'Login failed.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +200,7 @@ async function handleNsecBackupDone() {
|
||||
step.value = 'choose-mode'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Login failed.'
|
||||
handleError(e, 'Login failed.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +243,7 @@ async function handleNsecLogin() {
|
||||
step.value = 'choose-mode'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Login failed.'
|
||||
handleError(e, 'Login failed.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +356,7 @@ async function registerHumanFighter() {
|
||||
await registerHuman(humanName.value.trim(), selectedHumanSeed.value)
|
||||
step.value = 'ready'
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Registration failed.'
|
||||
handleError(e, 'Registration failed.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,7 +378,7 @@ async function confirmWebhook() {
|
||||
await registerBot(botName.value.trim(), url, selectedArchetype.value)
|
||||
step.value = 'ready'
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Registration failed.'
|
||||
handleError(e, 'Registration failed.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,9 +391,11 @@ async function fight() {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
router.push(`/arena/${data.fightId}`)
|
||||
return // Don't reset flag — navigation will unmount component
|
||||
} else {
|
||||
const data = await res.json()
|
||||
error.value = data.error || 'Failed to join.'
|
||||
const msg = data.retryAfterSec ? `${data.error} (${data.retryAfterSec}s)` : (data.error || 'Failed to join.')
|
||||
handleError(new Error(msg), 'Failed to join.')
|
||||
}
|
||||
} catch {
|
||||
error.value = 'Network error.'
|
||||
@@ -381,12 +417,14 @@ async function fightRanked() {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
router.push(`/arena/${data.fightId}`)
|
||||
return // Don't reset flag — navigation will unmount component
|
||||
} else {
|
||||
const data = await res.json()
|
||||
error.value = data.error || 'Ranked match failed.'
|
||||
const msg = data.retryAfterSec ? `${data.error} (${data.retryAfterSec}s)` : (data.error || 'Ranked match failed.')
|
||||
handleError(new Error(msg), 'Ranked match failed.')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Ranked fight error.'
|
||||
handleError(err, 'Ranked fight error.')
|
||||
}
|
||||
isJoiningRanked.value = false
|
||||
}
|
||||
@@ -402,9 +440,11 @@ async function practice() {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
router.push(`/arena/${data.fightId}`)
|
||||
return // Don't reset flag — navigation will unmount component
|
||||
} else {
|
||||
const data = await res.json()
|
||||
error.value = data.error || 'Failed to start practice fight.'
|
||||
const msg = data.retryAfterSec ? `${data.error} (${data.retryAfterSec}s)` : (data.error || 'Failed to start practice fight.')
|
||||
handleError(new Error(msg), 'Practice fight failed.')
|
||||
}
|
||||
} catch {
|
||||
error.value = 'Network error.'
|
||||
@@ -1104,6 +1144,12 @@ function handleSignOut() {
|
||||
<!-- Error display -->
|
||||
<div v-if="error" class="mt-4 p-3 border-2 border-ko/30 bg-ko/5 text-center">
|
||||
<p class="font-mono text-xs text-ko">{{ error }}</p>
|
||||
<div v-if="rateLimitCountdown > 0" class="mt-2 flex items-center justify-center gap-2">
|
||||
<div class="w-4 h-4 border-2 border-neon-purple/60 border-t-neon-purple rounded-full animate-spin" />
|
||||
<p class="font-display font-bold text-sm text-neon-purple tracking-wider">
|
||||
{{ rateLimitCountdown }}s
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user