diff --git a/packages/bruno-app/src/components/RequestTabPanel/TabPanelErrorBoundary.js b/packages/bruno-app/src/components/RequestTabPanel/TabPanelErrorBoundary.js
new file mode 100644
index 000000000..c5e512300
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestTabPanel/TabPanelErrorBoundary.js
@@ -0,0 +1,79 @@
+import React from 'react';
+import { IconAlertTriangle } from '@tabler/icons';
+import { useDispatch, useSelector } from 'react-redux';
+import find from 'lodash/find';
+import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
+import { NON_CLOSABLE_TAB_TYPES } from 'providers/ReduxStore/slices/tabs';
+import Button from 'ui/Button';
+import { useTheme } from 'providers/Theme';
+
+class TabPanelErrorBoundaryInner extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError(error) {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error, errorInfo) {
+ console.error('[TabPanelErrorBoundary] Unexpected render error:', error, errorInfo);
+ }
+
+ render() {
+ const { theme } = this.props;
+
+ if (this.state.hasError) {
+ const { isClosable, onClose } = this.props;
+ const errorMessage = this.state.error?.message;
+
+ return (
+
+
+
Something went wrong
+ {isClosable ? (
+
+ This tab encountered an unexpected error. Close it and try reopening the request. If the
+ error repeats, the request file may be corrupt.
+
+ ) : (
+
+ This panel encountered an unexpected error. Restart Bruno to recover.
+
+ )}
+ {errorMessage && (
+
{errorMessage}
+ )}
+ {isClosable && (
+
+ )}
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+const TabPanelErrorBoundary = ({ tabUid, children }) => {
+ const dispatch = useDispatch();
+ const tabs = useSelector((state) => state.tabs.tabs);
+ const focusedTab = find(tabs, (t) => t.uid === tabUid);
+ const isClosable = !focusedTab || !NON_CLOSABLE_TAB_TYPES.includes(focusedTab.type);
+ const { theme } = useTheme();
+
+ const handleClose = () => {
+ dispatch(closeTabs({ tabUids: [tabUid] }));
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default TabPanelErrorBoundary;
diff --git a/packages/bruno-app/src/pages/Bruno/index.js b/packages/bruno-app/src/pages/Bruno/index.js
index 35275bb29..8c637f88f 100644
--- a/packages/bruno-app/src/pages/Bruno/index.js
+++ b/packages/bruno-app/src/pages/Bruno/index.js
@@ -7,6 +7,7 @@ import Sidebar from 'components/Sidebar';
import StatusBar from 'components/StatusBar';
import AppTitleBar from 'components/AppTitleBar';
import ApiSpecPanel from 'components/ApiSpecPanel';
+import TabPanelErrorBoundary from 'components/RequestTabPanel/TabPanelErrorBoundary';
// import ErrorCapture from 'components/ErrorCapture';
import { useSelector } from 'react-redux';
import { isElectron } from 'utils/common/platform';
@@ -145,7 +146,9 @@ export default function Main() {
) : (
<>
-
+
+
+
>
)}
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
index 281bd98fe..a862affdf 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
@@ -8,6 +8,8 @@ import { isActiveTab as checkIsActiveTab, deserializeTab } from 'utils/snapshot'
const MAX_RECENTLY_CLOSED_TABS = 50;
+export const NON_CLOSABLE_TAB_TYPES = ['workspaceOverview', 'workspaceEnvironments'];
+
const initialState = {
tabs: [],
activeTabUid: null,
@@ -304,12 +306,10 @@ export const tabsSlice = createSlice({
const activeTab = find(state.tabs, (t) => t.uid === state.activeTabUid);
const tabUids = action.payload.tabUids || [];
- const nonClosableTypes = ['workspaceOverview', 'workspaceEnvironments'];
-
// Push closed tabs onto the recently closed stack (LIFO)
// Exclude transient requests — they have no persisted file and can't be reopened
const closedTabs = state.tabs.filter((t) =>
- tabUids.includes(t.uid) && !nonClosableTypes.includes(t.type) && !t.isTransient
+ tabUids.includes(t.uid) && !NON_CLOSABLE_TAB_TYPES.includes(t.type) && !t.isTransient
);
if (closedTabs.length > 0) {
state.recentlyClosedTabs.push(...closedTabs);
@@ -320,7 +320,7 @@ export const tabsSlice = createSlice({
}
state.tabs = filter(state.tabs, (t) =>
- !tabUids.includes(t.uid) || nonClosableTypes.includes(t.type)
+ !tabUids.includes(t.uid) || NON_CLOSABLE_TAB_TYPES.includes(t.type)
);
if (activeTab && state.tabs.length) {
diff --git a/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/bruno.json b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/bruno.json
new file mode 100644
index 000000000..a3bf1e183
--- /dev/null
+++ b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/bruno.json
@@ -0,0 +1,5 @@
+{
+ "version": "1",
+ "name": "invalid-tags-bru",
+ "type": "collection"
+}
diff --git a/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/collection.bru b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/collection.bru
new file mode 100644
index 000000000..6d411094c
--- /dev/null
+++ b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/collection.bru
@@ -0,0 +1,3 @@
+meta {
+ name: invalid-tags-bru
+}
diff --git a/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/invalid-tags-bru-request.bru b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/invalid-tags-bru-request.bru
new file mode 100644
index 000000000..d170bc5ba
--- /dev/null
+++ b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/invalid-tags-bru-request.bru
@@ -0,0 +1,12 @@
+meta {
+ name: invalid-tags-bru-request
+ type: http
+ seq: 1
+ tags: smoke
+}
+
+get {
+ url: https://httpbin.org/get
+ body: none
+ auth: none
+}
diff --git a/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/control-yml-request.yml b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/control-yml-request.yml
new file mode 100644
index 000000000..568bb136c
--- /dev/null
+++ b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/control-yml-request.yml
@@ -0,0 +1,13 @@
+meta:
+ name: control-yml-request
+ type: http
+ seq: 3
+
+info:
+ name: control-yml-request
+ type: http
+ seq: 3
+
+http:
+ method: GET
+ url: https://httpbin.org/get
diff --git a/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/invalid-tags-yml-request.yml b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/invalid-tags-yml-request.yml
new file mode 100644
index 000000000..3a69b9c10
--- /dev/null
+++ b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/invalid-tags-yml-request.yml
@@ -0,0 +1,15 @@
+meta:
+ name: invalid-tags-yml-request
+ type: http
+ seq: 1
+ tags: smoke
+
+info:
+ name: invalid-tags-yml-request
+ type: http
+ seq: 1
+ tags: smoke
+
+http:
+ method: GET
+ url: https://httpbin.org/get
diff --git a/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/opencollection.yml b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/opencollection.yml
new file mode 100644
index 000000000..4d7d0a368
--- /dev/null
+++ b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/opencollection.yml
@@ -0,0 +1,6 @@
+opencollection: "1.0.0"
+
+info:
+ name: invalid-tags-yml
+
+bundled: false
diff --git a/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/valid-tags-yml-request.yml b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/valid-tags-yml-request.yml
new file mode 100644
index 000000000..db3a729ff
--- /dev/null
+++ b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/valid-tags-yml-request.yml
@@ -0,0 +1,19 @@
+meta:
+ name: valid-tags-yml-request
+ type: http
+ seq: 2
+ tags:
+ - smoke
+ - sanity
+
+info:
+ name: valid-tags-yml-request
+ type: http
+ seq: 2
+ tags:
+ - smoke
+ - sanity
+
+http:
+ method: GET
+ url: https://httpbin.org/get
diff --git a/tests/request/tab-panel-error-boundary/init-user-data/preferences.json b/tests/request/tab-panel-error-boundary/init-user-data/preferences.json
new file mode 100644
index 000000000..dc873c76f
--- /dev/null
+++ b/tests/request/tab-panel-error-boundary/init-user-data/preferences.json
@@ -0,0 +1,12 @@
+{
+ "lastOpenedCollections": [
+ "{{collectionPath}}/invalid-tags-bru",
+ "{{collectionPath}}/invalid-tags-yml"
+ ],
+ "preferences": {
+ "onboarding": {
+ "hasLaunchedBefore": true,
+ "hasSeenWelcomeModal": true
+ }
+ }
+}
diff --git a/tests/request/tab-panel-error-boundary/tab-panel-error-boundary.spec.ts b/tests/request/tab-panel-error-boundary/tab-panel-error-boundary.spec.ts
new file mode 100644
index 000000000..4eaa135f1
--- /dev/null
+++ b/tests/request/tab-panel-error-boundary/tab-panel-error-boundary.spec.ts
@@ -0,0 +1,75 @@
+import { expect, Page, test } from '../../../playwright';
+import { openRequest } from '../../utils/page';
+import { buildCommonLocators } from '../../utils/page/locators';
+
+// NOTE: Rewritten instead of `selectRequestPaneTab` from actions.ts because we want to avoid assertion
+// of tab active state
+const openRequestSettingsTab = async (page: Page) => {
+ const requestPane = page.locator('[data-testid="request-pane"] > .px-4');
+ await expect(requestPane).toBeVisible();
+
+ const settingsTab = requestPane.locator('.tabs').getByRole('tab', { name: 'Settings' });
+ if (await settingsTab.isVisible().catch(() => false)) {
+ await settingsTab.click();
+ return;
+ }
+
+ const moreTabs = requestPane.locator('.tabs .more-tabs');
+ await expect(moreTabs).toBeVisible();
+ await moreTabs.click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Settings' }).click({ force: true });
+};
+
+const assertTabPanelErrorBoundary = async (page: Page, collectionName: string, requestName: string) => {
+ const commonLocators = buildCommonLocators(page);
+
+ await test.step(`Open ${requestName} and trigger render error`, async () => {
+ await openRequest(page, collectionName, requestName);
+ await expect(commonLocators.tabs.activeRequestTab()).toContainText(requestName);
+ await openRequestSettingsTab(page);
+ });
+
+ await test.step('Verify fallback UI', async () => {
+ await expect(page.getByText('Something went wrong')).toBeVisible();
+ await expect(
+ page.getByText(
+ 'This tab encountered an unexpected error. Close it and try reopening the request. If the error repeats, the request file may be corrupt.'
+ )
+ ).toBeVisible();
+ await expect(page.getByTestId('tab-panel-error-boundary-close-tab')).toBeVisible();
+ });
+
+ await test.step('Close errored tab via boundary action', async () => {
+ await page.getByTestId('tab-panel-error-boundary-close-tab').click();
+ await expect(commonLocators.tabs.requestTab(requestName)).not.toBeVisible();
+ });
+};
+
+const assertBoundaryVisible = async (page: Page) => {
+ await expect(page.getByText('Something went wrong')).toBeVisible();
+ await expect(page.getByTestId('tab-panel-error-boundary-close-tab')).toBeVisible();
+};
+
+test.describe.serial('Tab Panel Error Boundary', () => {
+ test('handles invalid tags type in YAML request metadata', async ({ pageWithUserData: page }) => {
+ await assertTabPanelErrorBoundary(page, 'invalid-tags-yml', 'invalid-tags-yml-request');
+ });
+
+ test('shows error only on failed tab and allows switching to valid tab', async ({ pageWithUserData: page }) => {
+ const commonLocators = buildCommonLocators(page);
+
+ await openRequest(page, 'invalid-tags-yml', 'invalid-tags-yml-request', { persist: true });
+ await openRequestSettingsTab(page);
+ await assertBoundaryVisible(page);
+
+ await openRequest(page, 'invalid-tags-yml', 'control-yml-request');
+ await expect(commonLocators.tabs.activeRequestTab()).toContainText('control-yml-request');
+ await expect(page.getByText('Something went wrong')).toHaveCount(0);
+ await expect(page.getByTestId('tab-panel-error-boundary-close-tab')).toHaveCount(0);
+ await expect(page.locator('[data-testid="request-pane"]')).toBeVisible();
+
+ await commonLocators.tabs.requestTab('invalid-tags-yml-request').click();
+ await expect(commonLocators.tabs.activeRequestTab()).toContainText('invalid-tags-yml-request');
+ await assertBoundaryVisible(page);
+ });
+});