Skip to content

Commit 03c92fb

Browse files
authored
Merge pull request #891 from Chris0Jeky/feat/fe-14-error-boundary
feat(frontend): FE-14 Vue error boundary for crash prevention (#852)
2 parents 7b229d2 + 334e0c9 commit 03c92fb

7 files changed

Lines changed: 867 additions & 3 deletions

File tree

frontend/taskdeck-web/src/App.vue

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { computed, onMounted, watch } from 'vue'
33
import { useRoute } from 'vue-router'
44
import ToastContainer from './components/common/ToastContainer.vue'
55
import AppShell from './components/shell/AppShell.vue'
6+
import ErrorBoundary from './components/ErrorBoundary.vue'
67
import { useSessionStore } from './store/sessionStore'
78
import { useFeatureFlagStore } from './store/featureFlagStore'
89
import { useWorkspaceStore } from './store/workspaceStore'
@@ -39,9 +40,13 @@ watch(
3940
<div id="app">
4041
<a href="#td-main-content" class="td-skip-link">Skip to main content</a>
4142
<!-- Shell layout for workspace routes -->
42-
<AppShell v-if="showShell" />
43+
<ErrorBoundary v-if="showShell">
44+
<AppShell />
45+
</ErrorBoundary>
4346
<!-- Direct render for public routes (login/register) -->
44-
<router-view v-else />
47+
<ErrorBoundary v-else>
48+
<router-view />
49+
</ErrorBoundary>
4550
<ToastContainer />
4651
</div>
4752
</template>
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
<script setup lang="ts">
2+
/**
3+
* ErrorBoundary catches render and lifecycle errors in its descendant tree
4+
* via Vue's `onErrorCaptured` hook and renders a fallback UI in place of the
5+
* crashed subtree. It is deliberately styled with plain inline CSS so that
6+
* it cannot itself crash when design tokens or stylesheets are unavailable.
7+
*
8+
* Scope caveat: Vue's `errorCaptured` does NOT catch async promise rejections
9+
* that originate outside a render/lifecycle call stack. Those are handled
10+
* globally via `app.config.errorHandler` and `window` listeners installed in
11+
* `main.ts`.
12+
*/
13+
import { onErrorCaptured, ref, watch } from 'vue'
14+
import { useRoute, useRouter } from 'vue-router'
15+
import { reportToSentry } from '../utils/errorReporting'
16+
17+
const props = withDefaults(
18+
defineProps<{
19+
/** Reset fallback state automatically when the route changes. */
20+
resetOnRouteChange?: boolean
21+
}>(),
22+
{
23+
resetOnRouteChange: true,
24+
},
25+
)
26+
27+
const emit = defineEmits<{
28+
error: [error: unknown, info: string]
29+
reset: []
30+
}>()
31+
32+
// Track crash state with an explicit boolean so we do not collide with
33+
// descendants that throw `null`/`undefined` — Vue errors can be any value.
34+
const hasCrashed = ref(false)
35+
const crashedError = ref<unknown>(undefined)
36+
const crashInfo = ref<string>('')
37+
38+
// useRoute/useRouter can be undefined in isolated unit tests where no router
39+
// is installed. Guard against that so the boundary can still be mounted in
40+
// minimal test harnesses.
41+
const route = (() => {
42+
try {
43+
return useRoute()
44+
} catch {
45+
return null
46+
}
47+
})()
48+
const router = (() => {
49+
try {
50+
return useRouter()
51+
} catch {
52+
return null
53+
}
54+
})()
55+
56+
function formatError(err: unknown): string {
57+
if (err instanceof Error) {
58+
return err.message || err.name || 'Unknown error'
59+
}
60+
if (typeof err === 'string') return err
61+
try {
62+
return JSON.stringify(err)
63+
} catch {
64+
return 'Unknown error'
65+
}
66+
}
67+
68+
function getStack(err: unknown): string | null {
69+
if (err instanceof Error && typeof err.stack === 'string') {
70+
return err.stack
71+
}
72+
return null
73+
}
74+
75+
function reset() {
76+
hasCrashed.value = false
77+
crashedError.value = undefined
78+
crashInfo.value = ''
79+
emit('reset')
80+
}
81+
82+
function reload() {
83+
if (typeof window !== 'undefined') {
84+
window.location.reload()
85+
}
86+
}
87+
88+
function goHome() {
89+
if (router) {
90+
void router.push('/')
91+
reset()
92+
return
93+
}
94+
if (typeof window !== 'undefined') {
95+
window.location.assign('/')
96+
}
97+
}
98+
99+
onErrorCaptured((err, _instance, info) => {
100+
hasCrashed.value = true
101+
crashedError.value = err
102+
crashInfo.value = info
103+
104+
// Always log so errors are not silently swallowed.
105+
console.error('[ErrorBoundary] caught error', err, info)
106+
107+
// Forward to Sentry via the centralized utility so the lifecycle `info`
108+
// string is preserved and reporting behavior stays consistent across the
109+
// app. `reportToSentry` never throws.
110+
reportToSentry(err, { source: 'ErrorBoundary', info })
111+
112+
emit('error', err, info)
113+
114+
// Stop propagation so ancestors do not unmount the whole app.
115+
return false
116+
})
117+
118+
// Reset the boundary automatically when the route changes so a crash on one
119+
// view does not permanently lock the user out of others.
120+
if (route) {
121+
watch(
122+
() => route.fullPath,
123+
(next, prev) => {
124+
if (props.resetOnRouteChange && hasCrashed.value && next !== prev) {
125+
reset()
126+
}
127+
},
128+
)
129+
}
130+
131+
// Expose for tests / parent components.
132+
defineExpose({ reset })
133+
134+
const isDev = (() => {
135+
try {
136+
return Boolean(import.meta.env?.DEV)
137+
} catch {
138+
return false
139+
}
140+
})()
141+
</script>
142+
143+
<template>
144+
<slot v-if="!hasCrashed" />
145+
<div
146+
v-else
147+
class="td-error-boundary"
148+
role="alert"
149+
aria-live="assertive"
150+
data-testid="error-boundary-fallback"
151+
>
152+
<div class="td-error-boundary__card">
153+
<h2 class="td-error-boundary__title">Something went wrong</h2>
154+
<p class="td-error-boundary__message">
155+
A part of the app crashed unexpectedly. Your session is still active — you can
156+
reload the page or return home to continue working.
157+
</p>
158+
<div class="td-error-boundary__actions">
159+
<button
160+
type="button"
161+
class="td-error-boundary__btn td-error-boundary__btn--primary"
162+
@click="reload"
163+
>
164+
Reload page
165+
</button>
166+
<button
167+
type="button"
168+
class="td-error-boundary__btn td-error-boundary__btn--secondary"
169+
@click="goHome"
170+
>
171+
Go to home
172+
</button>
173+
<button
174+
type="button"
175+
class="td-error-boundary__btn td-error-boundary__btn--ghost"
176+
@click="reset"
177+
>
178+
Dismiss
179+
</button>
180+
</div>
181+
<details v-if="isDev" class="td-error-boundary__details">
182+
<summary>Error details (dev only)</summary>
183+
<p class="td-error-boundary__info">{{ crashInfo }}</p>
184+
<pre class="td-error-boundary__stack">{{ formatError(crashedError) }}</pre>
185+
<pre v-if="getStack(crashedError)" class="td-error-boundary__stack">{{ getStack(crashedError) }}</pre>
186+
</details>
187+
</div>
188+
</div>
189+
</template>
190+
191+
<style scoped>
192+
/*
193+
* Inline, dependency-free styling. The boundary must render correctly even
194+
* when the global stylesheet or design tokens are unavailable (for example
195+
* if the stylesheet itself is the source of the crash). Avoid var(--td-*).
196+
*/
197+
.td-error-boundary {
198+
display: flex;
199+
align-items: center;
200+
justify-content: center;
201+
min-height: 40vh;
202+
padding: 24px;
203+
box-sizing: border-box;
204+
}
205+
206+
.td-error-boundary__card {
207+
max-width: 36rem;
208+
width: 100%;
209+
padding: 24px;
210+
border: 1px solid #f1b0b7;
211+
background: #fff5f5;
212+
color: #6e1f2a;
213+
border-radius: 8px;
214+
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
215+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
216+
}
217+
218+
.td-error-boundary__title {
219+
margin: 0 0 8px;
220+
font-size: 1.125rem;
221+
font-weight: 700;
222+
}
223+
224+
.td-error-boundary__message {
225+
margin: 0 0 16px;
226+
font-size: 0.95rem;
227+
line-height: 1.5;
228+
color: #4a1720;
229+
}
230+
231+
.td-error-boundary__actions {
232+
display: flex;
233+
flex-wrap: wrap;
234+
gap: 8px;
235+
}
236+
237+
.td-error-boundary__btn {
238+
appearance: none;
239+
border: 1px solid transparent;
240+
padding: 8px 14px;
241+
font-size: 0.9rem;
242+
font-weight: 600;
243+
border-radius: 6px;
244+
cursor: pointer;
245+
font-family: inherit;
246+
}
247+
248+
.td-error-boundary__btn--primary {
249+
background: #b02a37;
250+
color: #fff;
251+
}
252+
253+
.td-error-boundary__btn--primary:hover {
254+
background: #951f2c;
255+
}
256+
257+
.td-error-boundary__btn--secondary {
258+
background: #fff;
259+
color: #6e1f2a;
260+
border-color: #d9a1a7;
261+
}
262+
263+
.td-error-boundary__btn--secondary:hover {
264+
background: #fbe7e9;
265+
}
266+
267+
.td-error-boundary__btn--ghost {
268+
background: transparent;
269+
color: #6e1f2a;
270+
}
271+
272+
.td-error-boundary__btn--ghost:hover {
273+
background: #fbe7e9;
274+
}
275+
276+
.td-error-boundary__details {
277+
margin-top: 16px;
278+
font-size: 0.85rem;
279+
}
280+
281+
.td-error-boundary__info {
282+
margin: 8px 0;
283+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
284+
}
285+
286+
.td-error-boundary__stack {
287+
white-space: pre-wrap;
288+
word-break: break-word;
289+
background: #fde2e5;
290+
padding: 8px;
291+
border-radius: 4px;
292+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
293+
font-size: 0.8rem;
294+
max-height: 240px;
295+
overflow: auto;
296+
}
297+
</style>

frontend/taskdeck-web/src/components/shell/AppShell.vue

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import ShellSidebar from './ShellSidebar.vue'
1111
import ShellTopbar from './ShellTopbar.vue'
1212
import ShellCommandPalette from './ShellCommandPalette.vue'
1313
import ShellKeyboardHelp from './ShellKeyboardHelp.vue'
14+
import ErrorBoundary from '../ErrorBoundary.vue'
1415
import type { CommandItem } from './ShellCommandPalette.vue'
1516
1617
const router = useRouter()
@@ -191,7 +192,14 @@ onUnmounted(() => {
191192
<ShellTopbar @open-command-palette="openCommandPalette" />
192193

193194
<main id="td-main-content" class="td-content">
194-
<router-view />
195+
<!--
196+
Per-view ErrorBoundary keeps the sidebar and topbar usable when a
197+
single route component crashes. The outer boundary in App.vue is
198+
the last-resort backstop for crashes in AppShell itself.
199+
-->
200+
<ErrorBoundary>
201+
<router-view />
202+
</ErrorBoundary>
195203
</main>
196204
</div>
197205

frontend/taskdeck-web/src/main.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,23 @@ import { createPinia } from 'pinia'
33
import router from './router'
44
import App from './App.vue'
55
import './style.css'
6+
import {
7+
installVueErrorHandler,
8+
installWindowErrorListeners,
9+
} from './utils/errorReporting'
610

711
const app = createApp(App)
812
const pinia = createPinia()
913

1014
app.use(pinia)
1115
app.use(router)
1216

17+
// Install global crash-prevention hooks before mount so early errors are
18+
// captured. The Vue handler is the top-level backstop for render/lifecycle
19+
// errors; the window listeners catch async rejections and non-Vue errors.
20+
installVueErrorHandler(app)
21+
installWindowErrorListeners()
22+
1323
app.mount('#app')
1424

1525
// Initialize telemetry after mount (non-blocking, opt-in).

0 commit comments

Comments
 (0)