Skip to content

Commit 96d6182

Browse files
committed
feat: add ErrorBoundary component for improved error handling in the app
1 parent 6f91f33 commit 96d6182

3 files changed

Lines changed: 234 additions & 6 deletions

File tree

src/App.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useCallback, useState } from 'react';
2+
import ErrorBoundary from './components/ErrorBoundary';
23
import LandingPage from './components/LandingPage';
34
import WorkspaceScreen from './components/WorkspaceScreen';
45
import { getRoomStarterWorkspace, type RoomTemplateId } from './config/roomTemplates';
@@ -53,11 +54,13 @@ export default function App() {
5354
}
5455

5556
return (
56-
<CollabProvider key={roomId} roomId={roomId}>
57-
<WorkspaceScreen
58-
onExitRoom={handleExitRoom}
59-
initialRoomTemplate={getInitialRoomTemplate(createdRoomId, createdRoomTemplate, roomId)}
60-
/>
61-
</CollabProvider>
57+
<ErrorBoundary resetKey={roomId}>
58+
<CollabProvider key={roomId} roomId={roomId}>
59+
<WorkspaceScreen
60+
onExitRoom={handleExitRoom}
61+
initialRoomTemplate={getInitialRoomTemplate(createdRoomId, createdRoomTemplate, roomId)}
62+
/>
63+
</CollabProvider>
64+
</ErrorBoundary>
6265
);
6366
}

src/components/ErrorBoundary.css

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* ErrorBoundary.css — fallback styling shown when WorkspaceScreen (or any
3+
* descendant) throws during render. Uses collab-code's --cc-* dark palette.
4+
*/
5+
6+
.cc-error-boundary {
7+
position: fixed;
8+
inset: 0;
9+
z-index: 200;
10+
display: flex;
11+
align-items: center;
12+
justify-content: center;
13+
padding: 2rem 1.5rem;
14+
background: var(--cc-bg-app);
15+
color: var(--cc-text-primary);
16+
overflow: auto;
17+
}
18+
19+
.cc-error-boundary-card {
20+
max-width: 36rem;
21+
width: 100%;
22+
padding: 2.5rem;
23+
background: var(--cc-bg-panel);
24+
border: 1px solid var(--cc-border);
25+
border-radius: 0.75rem;
26+
box-shadow: var(--cc-shadow-lg);
27+
}
28+
29+
.cc-error-boundary-eyebrow {
30+
margin: 0 0 0.5rem;
31+
font-size: 0.75rem;
32+
font-weight: 700;
33+
letter-spacing: 0.2em;
34+
text-transform: uppercase;
35+
color: var(--cc-danger);
36+
}
37+
38+
.cc-error-boundary-title {
39+
margin: 0 0 0.75rem;
40+
font-size: 1.75rem;
41+
font-weight: 700;
42+
line-height: 1.2;
43+
color: var(--cc-text-primary);
44+
}
45+
46+
.cc-error-boundary-message {
47+
margin: 0 0 1.5rem;
48+
font-size: 1rem;
49+
line-height: 1.55;
50+
color: var(--cc-text-secondary);
51+
}
52+
53+
.cc-error-boundary-actions {
54+
display: flex;
55+
flex-wrap: wrap;
56+
gap: 0.75rem;
57+
}
58+
59+
.cc-error-boundary-btn {
60+
padding: 0.625rem 1.25rem;
61+
background: transparent;
62+
color: var(--cc-text-primary);
63+
border: 1px solid var(--cc-border);
64+
border-radius: 0.5rem;
65+
font-size: 0.9375rem;
66+
font-weight: 600;
67+
cursor: pointer;
68+
transition:
69+
background-color 140ms ease,
70+
border-color 140ms ease,
71+
color 140ms ease;
72+
}
73+
74+
.cc-error-boundary-btn:hover {
75+
border-color: var(--cc-accent);
76+
color: var(--cc-accent);
77+
}
78+
79+
.cc-error-boundary-btn:focus-visible {
80+
outline: 2px solid var(--cc-accent);
81+
outline-offset: 2px;
82+
}
83+
84+
.cc-error-boundary-btn--primary {
85+
background: var(--cc-accent);
86+
color: var(--cc-accent-contrast);
87+
border-color: var(--cc-accent);
88+
}
89+
90+
.cc-error-boundary-btn--primary:hover {
91+
background: var(--cc-accent-strong);
92+
border-color: var(--cc-accent-strong);
93+
color: var(--cc-accent-contrast);
94+
}
95+
96+
.cc-error-boundary-details {
97+
margin-top: 1.75rem;
98+
padding-top: 1.25rem;
99+
border-top: 1px solid var(--cc-border-soft);
100+
font-size: 0.875rem;
101+
color: var(--cc-text-secondary);
102+
}
103+
104+
.cc-error-boundary-details summary {
105+
cursor: pointer;
106+
font-weight: 600;
107+
color: var(--cc-text-primary);
108+
}
109+
110+
.cc-error-boundary-stack {
111+
margin: 0.75rem 0 0;
112+
padding: 0.875rem 1rem;
113+
max-height: 16rem;
114+
overflow: auto;
115+
background: var(--cc-bg-canvas);
116+
border: 1px solid var(--cc-border);
117+
border-radius: 0.375rem;
118+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
119+
font-size: 0.8125rem;
120+
line-height: 1.5;
121+
color: var(--cc-text-primary);
122+
white-space: pre-wrap;
123+
word-break: break-word;
124+
}

src/components/ErrorBoundary.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { Component, type ErrorInfo, type ReactNode } from 'react';
2+
import './ErrorBoundary.css';
3+
4+
interface ErrorBoundaryProps {
5+
/**
6+
* When `resetKey` changes (e.g. the user joins a different room), the
7+
* boundary resets and re-renders its children. Pass the current roomId to
8+
* get room-scoped recovery without reloading the whole app.
9+
*/
10+
resetKey?: string;
11+
children: ReactNode;
12+
}
13+
14+
interface ErrorBoundaryState {
15+
error: Error | null;
16+
}
17+
18+
/**
19+
* Catches render-time errors from its subtree and renders a fallback UI
20+
* instead of unmounting the entire app. React's error boundary API is
21+
* class-only, so this is intentionally a class component.
22+
*
23+
* Non-optional in a collaborative app: a thrown error in the workspace
24+
* (Monaco, Yjs, terminal, file tree) should not leave the user stranded
25+
* with a blank page.
26+
*/
27+
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
28+
state: ErrorBoundaryState = { error: null };
29+
30+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
31+
return { error };
32+
}
33+
34+
componentDidUpdate(prevProps: ErrorBoundaryProps) {
35+
if (this.state.error && prevProps.resetKey !== this.props.resetKey) {
36+
this.setState({ error: null });
37+
}
38+
}
39+
40+
componentDidCatch(error: Error, info: ErrorInfo) {
41+
console.error('ErrorBoundary caught:', error, info);
42+
}
43+
44+
private handleReset = () => {
45+
this.setState({ error: null });
46+
};
47+
48+
private handleReload = () => {
49+
window.location.reload();
50+
};
51+
52+
private handleExit = () => {
53+
window.location.hash = '';
54+
this.setState({ error: null });
55+
};
56+
57+
render() {
58+
if (!this.state.error) {
59+
return this.props.children;
60+
}
61+
62+
return (
63+
<div className="cc-error-boundary" role="alert" aria-live="assertive">
64+
<div className="cc-error-boundary-card">
65+
<p className="cc-error-boundary-eyebrow">Something broke</p>
66+
<h1 className="cc-error-boundary-title">The workspace hit an unexpected error.</h1>
67+
<p className="cc-error-boundary-message">
68+
Try again, reload the page, or exit back to the landing screen. Your collaborators are
69+
unaffected — they'll stay in the room.
70+
</p>
71+
<div className="cc-error-boundary-actions">
72+
<button
73+
type="button"
74+
className="cc-error-boundary-btn cc-error-boundary-btn--primary"
75+
onClick={this.handleReload}
76+
>
77+
Reload page
78+
</button>
79+
<button type="button" className="cc-error-boundary-btn" onClick={this.handleReset}>
80+
Try again
81+
</button>
82+
<button type="button" className="cc-error-boundary-btn" onClick={this.handleExit}>
83+
Exit room
84+
</button>
85+
</div>
86+
{import.meta.env.DEV && (
87+
<details className="cc-error-boundary-details">
88+
<summary>Error details (dev only)</summary>
89+
<pre className="cc-error-boundary-stack">
90+
{this.state.error.message}
91+
{this.state.error.stack ? `\n\n${this.state.error.stack}` : ''}
92+
</pre>
93+
</details>
94+
)}
95+
</div>
96+
</div>
97+
);
98+
}
99+
}
100+
101+
export default ErrorBoundary;

0 commit comments

Comments
 (0)