Skip to content

Commit 9df06e1

Browse files
authored
fix: add tab error boundary (usebruno#7987)
1 parent 9f75959 commit 9df06e1

12 files changed

Lines changed: 247 additions & 5 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React from 'react';
2+
import { IconAlertTriangle } from '@tabler/icons';
3+
import { useDispatch, useSelector } from 'react-redux';
4+
import find from 'lodash/find';
5+
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
6+
import { NON_CLOSABLE_TAB_TYPES } from 'providers/ReduxStore/slices/tabs';
7+
import Button from 'ui/Button';
8+
import { useTheme } from 'providers/Theme';
9+
10+
class TabPanelErrorBoundaryInner extends React.Component {
11+
constructor(props) {
12+
super(props);
13+
this.state = { hasError: false, error: null };
14+
}
15+
16+
static getDerivedStateFromError(error) {
17+
return { hasError: true, error };
18+
}
19+
20+
componentDidCatch(error, errorInfo) {
21+
console.error('[TabPanelErrorBoundary] Unexpected render error:', error, errorInfo);
22+
}
23+
24+
render() {
25+
const { theme } = this.props;
26+
27+
if (this.state.hasError) {
28+
const { isClosable, onClose } = this.props;
29+
const errorMessage = this.state.error?.message;
30+
31+
return (
32+
<div className="h-full flex flex-col items-center justify-center gap-3 px-6 text-center">
33+
<IconAlertTriangle size={36} strokeWidth={1.5} style={{ color: theme?.status?.warning?.text }} />
34+
<h2 className="text-lg font-medium">Something went wrong</h2>
35+
{isClosable ? (
36+
<p className="text-sm opacity-70 max-w-md">
37+
This tab encountered an unexpected error. Close it and try reopening the request. If the
38+
error repeats, the request file may be corrupt.
39+
</p>
40+
) : (
41+
<p className="text-sm opacity-70 max-w-md">
42+
This panel encountered an unexpected error. Restart Bruno to recover.
43+
</p>
44+
)}
45+
{errorMessage && (
46+
<p className="text-xs font-mono opacity-50 max-w-md break-all">{errorMessage}</p>
47+
)}
48+
{isClosable && (
49+
<Button size="md" data-testid="tab-panel-error-boundary-close-tab" color="primary" onClick={onClose}>
50+
Close Tab
51+
</Button>
52+
)}
53+
</div>
54+
);
55+
}
56+
57+
return this.props.children;
58+
}
59+
}
60+
61+
const TabPanelErrorBoundary = ({ tabUid, children }) => {
62+
const dispatch = useDispatch();
63+
const tabs = useSelector((state) => state.tabs.tabs);
64+
const focusedTab = find(tabs, (t) => t.uid === tabUid);
65+
const isClosable = !focusedTab || !NON_CLOSABLE_TAB_TYPES.includes(focusedTab.type);
66+
const { theme } = useTheme();
67+
68+
const handleClose = () => {
69+
dispatch(closeTabs({ tabUids: [tabUid] }));
70+
};
71+
72+
return (
73+
<TabPanelErrorBoundaryInner isClosable={isClosable} onClose={handleClose} theme={theme}>
74+
{children}
75+
</TabPanelErrorBoundaryInner>
76+
);
77+
};
78+
79+
export default TabPanelErrorBoundary;

packages/bruno-app/src/pages/Bruno/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Sidebar from 'components/Sidebar';
77
import StatusBar from 'components/StatusBar';
88
import AppTitleBar from 'components/AppTitleBar';
99
import ApiSpecPanel from 'components/ApiSpecPanel';
10+
import TabPanelErrorBoundary from 'components/RequestTabPanel/TabPanelErrorBoundary';
1011
// import ErrorCapture from 'components/ErrorCapture';
1112
import { useSelector } from 'react-redux';
1213
import { isElectron } from 'utils/common/platform';
@@ -145,7 +146,9 @@ export default function Main() {
145146
) : (
146147
<>
147148
<RequestTabs />
148-
<RequestTabPanel key={activeTabUid} />
149+
<TabPanelErrorBoundary key={activeTabUid} tabUid={activeTabUid}>
150+
<RequestTabPanel key={activeTabUid} />
151+
</TabPanelErrorBoundary>
149152
</>
150153
)}
151154
</section>

packages/bruno-app/src/providers/ReduxStore/slices/tabs.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { isActiveTab as checkIsActiveTab, deserializeTab } from 'utils/snapshot'
88

99
const MAX_RECENTLY_CLOSED_TABS = 50;
1010

11+
export const NON_CLOSABLE_TAB_TYPES = ['workspaceOverview', 'workspaceEnvironments'];
12+
1113
const initialState = {
1214
tabs: [],
1315
activeTabUid: null,
@@ -304,12 +306,10 @@ export const tabsSlice = createSlice({
304306
const activeTab = find(state.tabs, (t) => t.uid === state.activeTabUid);
305307
const tabUids = action.payload.tabUids || [];
306308

307-
const nonClosableTypes = ['workspaceOverview', 'workspaceEnvironments'];
308-
309309
// Push closed tabs onto the recently closed stack (LIFO)
310310
// Exclude transient requests — they have no persisted file and can't be reopened
311311
const closedTabs = state.tabs.filter((t) =>
312-
tabUids.includes(t.uid) && !nonClosableTypes.includes(t.type) && !t.isTransient
312+
tabUids.includes(t.uid) && !NON_CLOSABLE_TAB_TYPES.includes(t.type) && !t.isTransient
313313
);
314314
if (closedTabs.length > 0) {
315315
state.recentlyClosedTabs.push(...closedTabs);
@@ -320,7 +320,7 @@ export const tabsSlice = createSlice({
320320
}
321321

322322
state.tabs = filter(state.tabs, (t) =>
323-
!tabUids.includes(t.uid) || nonClosableTypes.includes(t.type)
323+
!tabUids.includes(t.uid) || NON_CLOSABLE_TAB_TYPES.includes(t.type)
324324
);
325325

326326
if (activeTab && state.tabs.length) {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"version": "1",
3+
"name": "invalid-tags-bru",
4+
"type": "collection"
5+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
meta {
2+
name: invalid-tags-bru
3+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
meta {
2+
name: invalid-tags-bru-request
3+
type: http
4+
seq: 1
5+
tags: smoke
6+
}
7+
8+
get {
9+
url: https://httpbin.org/get
10+
body: none
11+
auth: none
12+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
meta:
2+
name: control-yml-request
3+
type: http
4+
seq: 3
5+
6+
info:
7+
name: control-yml-request
8+
type: http
9+
seq: 3
10+
11+
http:
12+
method: GET
13+
url: https://httpbin.org/get
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
meta:
2+
name: invalid-tags-yml-request
3+
type: http
4+
seq: 1
5+
tags: smoke
6+
7+
info:
8+
name: invalid-tags-yml-request
9+
type: http
10+
seq: 1
11+
tags: smoke
12+
13+
http:
14+
method: GET
15+
url: https://httpbin.org/get
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
opencollection: "1.0.0"
2+
3+
info:
4+
name: invalid-tags-yml
5+
6+
bundled: false
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
meta:
2+
name: valid-tags-yml-request
3+
type: http
4+
seq: 2
5+
tags:
6+
- smoke
7+
- sanity
8+
9+
info:
10+
name: valid-tags-yml-request
11+
type: http
12+
seq: 2
13+
tags:
14+
- smoke
15+
- sanity
16+
17+
http:
18+
method: GET
19+
url: https://httpbin.org/get

0 commit comments

Comments
 (0)