feat: add ErrorBoundary component with onErrorCaptured (BUG-F5)
Catches runtime errors in child components, displays user-friendly error message with reload button. Wired into App.vue wrapping router-view. Tests verify error capture and button rendering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
74cb5cc728
commit
e7d3cab85a
@@ -2,6 +2,7 @@
|
|||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
import NavBar from './components/NavBar.vue'
|
import NavBar from './components/NavBar.vue'
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary.vue'
|
||||||
import { ensureAudioContext } from './game/audio'
|
import { ensureAudioContext } from './game/audio'
|
||||||
|
|
||||||
// Unlock AudioContext + SpeechSynthesis on first user interaction (mobile requires gesture)
|
// Unlock AudioContext + SpeechSynthesis on first user interaction (mobile requires gesture)
|
||||||
@@ -21,7 +22,9 @@ onMounted(() => {
|
|||||||
<div class="fixed inset-0 crt-overlay z-40" />
|
<div class="fixed inset-0 crt-overlay z-40" />
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<main class="flex-1 min-h-0 relative z-10 overflow-y-auto pb-14 md:pb-0">
|
<main class="flex-1 min-h-0 relative z-10 overflow-y-auto pb-14 md:pb-0">
|
||||||
<RouterView />
|
<ErrorBoundary>
|
||||||
|
<RouterView />
|
||||||
|
</ErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onErrorCaptured } from 'vue'
|
||||||
|
|
||||||
|
const hasError = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
onErrorCaptured((err) => {
|
||||||
|
hasError.value = true
|
||||||
|
errorMessage.value = err instanceof Error ? err.message : String(err)
|
||||||
|
console.error('[ErrorBoundary] caught:', err)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="hasError" class="flex flex-col items-center justify-center min-h-[50vh] gap-4 px-6 text-center">
|
||||||
|
<p class="font-display text-lg text-neon-pink">Something went wrong</p>
|
||||||
|
<p class="text-sm text-text-muted max-w-md">{{ errorMessage }}</p>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 font-display text-sm font-bold tracking-wider text-surface bg-neon-cyan rounded hover:opacity-80 transition-opacity"
|
||||||
|
@click="reload"
|
||||||
|
>
|
||||||
|
RELOAD
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<slot v-else />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { defineComponent, onMounted } from 'vue'
|
||||||
|
import ErrorBoundary from '../ErrorBoundary.vue'
|
||||||
|
|
||||||
|
const ThrowingChild = defineComponent({
|
||||||
|
setup() {
|
||||||
|
onMounted(() => {
|
||||||
|
throw new Error('Test error from child')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
template: '<div>Should not render</div>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const GoodChild = defineComponent({
|
||||||
|
template: '<div>All good</div>',
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ErrorBoundary', () => {
|
||||||
|
it('renders slot content when no error', () => {
|
||||||
|
const wrapper = mount(ErrorBoundary, {
|
||||||
|
slots: { default: GoodChild },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('All good')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('catches error from child and shows error message', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const wrapper = mount(ErrorBoundary, {
|
||||||
|
slots: { default: ThrowingChild },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for onMounted to fire
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Something went wrong')
|
||||||
|
expect(wrapper.text()).toContain('Test error from child')
|
||||||
|
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows reload button on error', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const wrapper = mount(ErrorBoundary, {
|
||||||
|
slots: { default: ThrowingChild },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const button = wrapper.find('button')
|
||||||
|
expect(button.exists()).toBe(true)
|
||||||
|
expect(button.text()).toBe('RELOAD')
|
||||||
|
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user