What changed, and why

Clicking "Sign out" did nothing visible. Only a page refresh revealed the session was actually gone โ€” and re-login then failed.

The cause was the scope of one call. signOut() ran with the default scope: 'global', and @supabase/auth-js makes that server revoke before it clears the local session. So a network or 5xx blip on the revoke rejected the promise, and the lines meant to follow โ€” re-anonymize, reload the balance โ€” never ran. The popover sat unchanged (nothing happened), while the session quietly went away by the next load.

The fix moves the reset into a small, resilient helper and switches to a local sign-out, so the UI always lands on a clean signed-out state.

The resilient reset โ€” utils/sign-out.ts

Two deliberate choices. First, scope: 'local' โ€” clear this session's tokens locally, with no global server revoke that can hang or reject. Second, every step is best-effort: neither the sign-out nor the re-anonymize is allowed to throw out of here, so whatever the network does, the caller's UI reset still runs.

A local sign-out, then a fresh anonymous session โ€” each wrapped so it can't abort the reset.

utils/sign-out.ts ยท 44 lines
utils/sign-out.ts44 lines ยท TypeScript
โ‹ฏ 22 lines hidden (lines 1โ€“22)
1/**
2 * Returning to anonymous play on sign-out.
3 *
4 * Sign-out has to always land the UI on a clean signed-out state, so it can't hang
5 * on a server round-trip. We sign out with `scope: 'local'` โ€” clear THIS session's
6 * tokens locally, with no global revoke that can reject on a network blip and (per
7 * @supabase/auth-js, which calls the server BEFORE clearing the local session) abort
8 * the whole sign-out before the local session is even cleared. That stranded the old
9 * code: a rejected global revoke skipped the re-anon + balance reload, so the click
10 * did nothing visible while the session quietly went away on the next refresh.
11 *
12 * Then we drop straight back to an anonymous session so play continues. Every step
13 * is best-effort: a thrown or errored call never blocks the reset, so the caller can
14 * always reload the balance and render the signed-out state.
15 */
16 
17interface AuthResult {
18 error: unknown
20 
21/** The slice of the Supabase auth client this needs โ€” kept local so the util carries
22 * no Nuxt/Supabase runtime imports and unit-tests in the node env. */
23export interface SignOutClient {
24 signOut(options: { scope: 'local' }): Promise<AuthResult>
25 signInAnonymously(): Promise<AuthResult>
27 
28/**
29 * Clears the current session locally and drops back to a fresh anonymous one.
30 * Resilient by design โ€” neither call is allowed to throw out of here, so the UI
31 * reset that follows always runs.
32 */
33export async function resetToAnonymous(client: SignOutClient): Promise<void> {
34 try {
35 await client.signOut({ scope: 'local' })
36 } catch {
37 // Clearing the local session must never block the reset.
38 }
39 try {
40 await client.signInAnonymously()
41 } catch {
42 // Anonymous provider off or rate-limited โ†’ the device-cookie identity carries on.
43 }

Wiring it โ€” RunsBalance.vue

The component hands the reset to the helper through thin adapters over the live Supabase client, then reloads the balance so the gauge and popover reflect the new (anonymous) state. The popover is left open on purpose: it re-renders to the signed-out view, which is the visible feedback the old path never gave.

No more close(): the popover re-renders to the signed-out state in place.

components/RunsBalance.vue ยท 111 lines
components/RunsBalance.vue111 lines ยท Vue
โ‹ฏ 75 lines hidden (lines 1โ€“75)
1<template>
2 <!-- The run balance, as a header gauge (โ—ˆ N runs), doubling as the entry to a
3 small account popover. Mirrors MessagesCounter's mono-gauge idiom. -->
4 <div ref="rootRef" data-testid="runs-balance" class="relative">
5 <button type="button" data-testid="runs-balance-toggle"
6 class="inline-flex items-baseline gap-1 rv-mono text-sm rv-tap rv-hover-fg"
7 :aria-expanded="open" aria-haspopup="dialog" :aria-label="aria" @click="toggle">
8 <span class="rv-faint" aria-hidden="true">โ—ˆ</span>
9 <span class="rv-fg font-semibold">{{ display }}</span>
10 <span class="rv-faint text-xs hidden sm:inline">{{ runs === 1 ? 'run' : 'runs' }}</span>
11 </button>
12 
13 <Transition name="rv-pop">
14 <div v-if="open" data-testid="account-popover" role="dialog" aria-label="Your account"
15 class="absolute right-0 mt-2 w-72 rv-bg border rv-line rounded-md shadow-xl z-40 p-3.5 text-left">
16 <p class="rv-label">Your runs</p>
17 <div class="flex items-baseline gap-1.5 mt-0.5">
18 <span class="rv-mono text-2xl font-extrabold rv-fg leading-none">{{ runs }}</span>
19 <span class="rv-muted text-xs">{{ runs === 1 ? 'run left' : 'runs left' }}</span>
20 </div>
21 <p class="rv-faint text-[11px] leading-relaxed mt-2">
22 <template v-if="runs <= 0">Out of runs โ€” grab a pack to keep playing.</template>
23 <template v-else>Each run is one full game. Packs are one-time and never expire.</template>
24 </p>
25 
26 <button type="button" data-testid="account-get-runs" class="rv-btn rv-btn--primary w-full mt-3 text-sm justify-center"
27 @click="getRuns">Get more runs</button>
28 
29 <div class="mt-3 pt-2.5 border-t rv-line">
30 <template v-if="gameStore.accountEmail">
31 <p class="rv-faint text-[11px] leading-relaxed">Signed in as <span class="rv-fg">{{ gameStore.accountEmail }}</span></p>
32 <button type="button" data-testid="account-signout" class="rv-tap rv-hover-fg text-[11px] mt-1 underline" @click="signOut">Sign out</button>
33 </template>
34 <SignInForm v-else-if="gameStore.accountAnonymous"
35 hint="Save your runs to an account โ€” keep them across devices, and unlock buying." />
36 <p v-else-if="gameStore.deviceRef" class="rv-faint text-[10px]">
37 Anonymous play ยท support id <span class="rv-mono">{{ gameStore.deviceRef }}</span>
38 </p>
39 </div>
40 </div>
41 </Transition>
42 </div>
43</template>
44 
45<script setup lang="ts">
46/**
47 * RunsBalance โ€” the persistent header gauge for runs remaining, and the entry to a
48 * lightweight account popover (runs left, a one-line explainer, "get more runs",
49 * and a short support id). Play is anonymous (device-keyed) until real accounts
50 * exist, so "account" is deliberately minimal. Closes on outside-click / Escape.
51 */
52import { ref, computed, onMounted, onUnmounted } from 'vue'
53import { useGameStore } from '~/stores/game'
54import { resetToAnonymous } from '~/utils/sign-out'
55 
56const gameStore = useGameStore()
57const open = ref(false)
58const rootRef = ref<HTMLElement | null>(null)
59 
60const runs = computed(() => gameStore.runsRemaining ?? 0)
61// A dash until the balance has loaded, so the gauge never flashes a misleading 0.
62const display = computed(() => (gameStore.runsRemaining == null ? 'โ€“' : String(runs.value)))
63const aria = computed(() =>
64 gameStore.runsRemaining == null ? 'Loading your runs' : `${runs.value} ${runs.value === 1 ? 'run' : 'runs'} remaining; open account`
66 
67function toggle() { open.value = !open.value }
68function close() { open.value = false }
69 
70function getRuns() {
71 close()
72 void gameStore.openBuyModal()
74 
75const supabase = useSupabaseClient()
76async function signOut() {
77 // Reset to anonymous resiliently (no failure-prone global revoke that could
78 // abort the whole sign-out), then reload the balance. The popover stays open so
79 // it re-renders to the signed-out state โ€” visible feedback that the click landed.
80 await resetToAnonymous({
81 signOut: (options) => supabase.auth.signOut(options),
82 signInAnonymously: () => supabase.auth.signInAnonymously()
83 })
84 await gameStore.loadBalance()
โ‹ฏ 26 lines hidden (lines 86โ€“111)
86 
87function onDocClick(e: MouseEvent) {
88 if (open.value && rootRef.value && !rootRef.value.contains(e.target as Node)) close()
90function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') close() }
91 
92onMounted(() => {
93 document.addEventListener('click', onDocClick)
94 document.addEventListener('keydown', onKeydown)
95})
96onUnmounted(() => {
97 document.removeEventListener('click', onDocClick)
98 document.removeEventListener('keydown', onKeydown)
99})
100</script>
101 
102<style scoped>
103.rv-pop-enter-active,
104.rv-pop-leave-active { transition: opacity var(--rv-dur-1) var(--rv-ease); }
105.rv-pop-enter-from,
106.rv-pop-leave-to { opacity: 0; }
107@media (prefers-reduced-motion: reduce) {
108 .rv-pop-enter-active,
109 .rv-pop-leave-active { transition: none; }
111</style>

Verification

The reset is pure over its injected client, so the test drives the exact failure that caused the bug: a signOut that throws must still reach the re-anonymize, and a throwing re-anonymize must still resolve cleanly.

The happy path plus both throw-paths that the old code aborted on.

tests/unit/utils/sign-out.spec.ts ยท 29 lines
tests/unit/utils/sign-out.spec.ts29 lines ยท TypeScript
โ‹ฏ 10 lines hidden (lines 1โ€“10)
1import { describe, it, expect, vi } from 'vitest'
2import { resetToAnonymous } from '../../../utils/sign-out'
3 
4describe('resetToAnonymous (sign-out โ†’ fresh anonymous session)', () => {
5 const client = (over: Record<string, unknown> = {}) => ({
6 signOut: vi.fn().mockResolvedValue({ error: null }),
7 signInAnonymously: vi.fn().mockResolvedValue({ error: null }),
8 ...over
9 })
10 
11 it('signs out locally, then re-anonymizes', async () => {
12 const c = client()
13 await resetToAnonymous(c)
14 expect(c.signOut).toHaveBeenCalledWith({ scope: 'local' })
15 expect(c.signInAnonymously).toHaveBeenCalledOnce()
16 })
17 
18 it('still re-anonymizes when sign-out throws (the bug: a rejected revoke must not abort the reset)', async () => {
19 const c = client({ signOut: vi.fn().mockRejectedValue(new Error('network')) })
20 await expect(resetToAnonymous(c)).resolves.toBeUndefined()
21 expect(c.signInAnonymously).toHaveBeenCalledOnce()
22 })
23 
24 it('never throws when re-anonymizing fails (provider off / rate-limited)', async () => {
25 const c = client({ signInAnonymously: vi.fn().mockRejectedValue(new Error('rate limit')) })
26 await expect(resetToAnonymous(c)).resolves.toBeUndefined()
27 expect(c.signOut).toHaveBeenCalledOnce()
โ‹ฏ 2 lines hidden (lines 28โ€“29)
28 })
29})