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">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||||
import { useNostr } from '../composables/useNostr'
|
import { useNostr } from '../composables/useNostr'
|
||||||
import { useWallet } from '../composables/useWallet'
|
import { useWallet } from '../composables/useWallet'
|
||||||
import SpritePreview from '../components/SpritePreview.vue'
|
import SpritePreview from '../components/SpritePreview.vue'
|
||||||
@@ -21,6 +21,8 @@ const step = ref<string>('login')
|
|||||||
const isHumanMode = ref(false)
|
const isHumanMode = ref(false)
|
||||||
const selectedHumanSeed = ref('baby_fighter_1')
|
const selectedHumanSeed = ref('baby_fighter_1')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const rateLimitCountdown = ref(0)
|
||||||
|
let rateLimitTimer: ReturnType<typeof setInterval> | null = null
|
||||||
const isJoining = ref(false)
|
const isJoining = ref(false)
|
||||||
const isJoiningRanked = ref(false)
|
const isJoiningRanked = ref(false)
|
||||||
const isJoiningPractice = ref(false)
|
const isJoiningPractice = ref(false)
|
||||||
@@ -99,6 +101,32 @@ const archetypeList = [
|
|||||||
{ id: 'snail', label: 'SNAIL', desc: 'Slow and steady' },
|
{ 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(() => {
|
onMounted(() => {
|
||||||
// If already logged in with a bot, go straight to ready
|
// If already logged in with a bot, go straight to ready
|
||||||
if (isLoggedIn.value) {
|
if (isLoggedIn.value) {
|
||||||
@@ -108,8 +136,14 @@ onMounted(() => {
|
|||||||
pollHandle = setInterval(pollQueue, 3000)
|
pollHandle = setInterval(pollQueue, 3000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeRouteLeave(() => {
|
||||||
|
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
|
||||||
|
if (rateLimitTimer) { clearInterval(rateLimitTimer); rateLimitTimer = null }
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (pollHandle) clearInterval(pollHandle)
|
if (pollHandle) clearInterval(pollHandle)
|
||||||
|
if (rateLimitTimer) { clearInterval(rateLimitTimer); rateLimitTimer = null }
|
||||||
})
|
})
|
||||||
|
|
||||||
async function pollQueue() {
|
async function pollQueue() {
|
||||||
@@ -143,7 +177,7 @@ async function handleLogin() {
|
|||||||
step.value = 'choose-mode'
|
step.value = 'choose-mode'
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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'
|
step.value = 'choose-mode'
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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'
|
step.value = 'choose-mode'
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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)
|
await registerHuman(humanName.value.trim(), selectedHumanSeed.value)
|
||||||
step.value = 'ready'
|
step.value = 'ready'
|
||||||
} catch (e) {
|
} 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)
|
await registerBot(botName.value.trim(), url, selectedArchetype.value)
|
||||||
step.value = 'ready'
|
step.value = 'ready'
|
||||||
} catch (e) {
|
} 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) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
router.push(`/arena/${data.fightId}`)
|
router.push(`/arena/${data.fightId}`)
|
||||||
|
return // Don't reset flag — navigation will unmount component
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json()
|
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 {
|
} catch {
|
||||||
error.value = 'Network error.'
|
error.value = 'Network error.'
|
||||||
@@ -381,12 +417,14 @@ async function fightRanked() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
router.push(`/arena/${data.fightId}`)
|
router.push(`/arena/${data.fightId}`)
|
||||||
|
return // Don't reset flag — navigation will unmount component
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json()
|
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) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Ranked fight error.'
|
handleError(err, 'Ranked fight error.')
|
||||||
}
|
}
|
||||||
isJoiningRanked.value = false
|
isJoiningRanked.value = false
|
||||||
}
|
}
|
||||||
@@ -402,9 +440,11 @@ async function practice() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
router.push(`/arena/${data.fightId}`)
|
router.push(`/arena/${data.fightId}`)
|
||||||
|
return // Don't reset flag — navigation will unmount component
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json()
|
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 {
|
} catch {
|
||||||
error.value = 'Network error.'
|
error.value = 'Network error.'
|
||||||
@@ -1104,6 +1144,12 @@ function handleSignOut() {
|
|||||||
<!-- Error display -->
|
<!-- Error display -->
|
||||||
<div v-if="error" class="mt-4 p-3 border-2 border-ko/30 bg-ko/5 text-center">
|
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user