fix: add tab error boundary (#7987)

This commit is contained in:
Sid
2026-05-13 18:48:57 +05:30
committed by GitHub
parent 9f75959452
commit 9df06e152a
12 changed files with 247 additions and 5 deletions

View File

@@ -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 (
<div className="h-full flex flex-col items-center justify-center gap-3 px-6 text-center">
<IconAlertTriangle size={36} strokeWidth={1.5} style={{ color: theme?.status?.warning?.text }} />
<h2 className="text-lg font-medium">Something went wrong</h2>
{isClosable ? (
<p className="text-sm opacity-70 max-w-md">
This tab encountered an unexpected error. Close it and try reopening the request. If the
error repeats, the request file may be corrupt.
</p>
) : (
<p className="text-sm opacity-70 max-w-md">
This panel encountered an unexpected error. Restart Bruno to recover.
</p>
)}
{errorMessage && (
<p className="text-xs font-mono opacity-50 max-w-md break-all">{errorMessage}</p>
)}
{isClosable && (
<Button size="md" data-testid="tab-panel-error-boundary-close-tab" color="primary" onClick={onClose}>
Close Tab
</Button>
)}
</div>
);
}
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 (
<TabPanelErrorBoundaryInner isClosable={isClosable} onClose={handleClose} theme={theme}>
{children}
</TabPanelErrorBoundaryInner>
);
};
export default TabPanelErrorBoundary;

View File

@@ -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() {
) : (
<>
<RequestTabs />
<RequestTabPanel key={activeTabUid} />
<TabPanelErrorBoundary key={activeTabUid} tabUid={activeTabUid}>
<RequestTabPanel key={activeTabUid} />
</TabPanelErrorBoundary>
</>
)}
</section>

View File

@@ -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) {

View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "invalid-tags-bru",
"type": "collection"
}

View File

@@ -0,0 +1,3 @@
meta {
name: invalid-tags-bru
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,6 @@
opencollection: "1.0.0"
info:
name: invalid-tags-yml
bundled: false

View File

@@ -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

View File

@@ -0,0 +1,12 @@
{
"lastOpenedCollections": [
"{{collectionPath}}/invalid-tags-bru",
"{{collectionPath}}/invalid-tags-yml"
],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
}
}
}

View File

@@ -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);
});
});