This is an automated email from the ASF dual-hosted git repository.

EnxDev pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 02dbfd2ab5b fix(dashboard): don't register an active tab for childless 
TABS components (#44345)
02dbfd2ab5b is described below

commit 02dbfd2ab5b8f13bd2173e7877cafd02033ec2d0
Author: Enzo Martellucci <[email protected]>
AuthorDate: Wed Sep 23 14:08:07 2026 +0200

    fix(dashboard): don't register an active tab for childless TABS components 
(#44345)
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../components/gridComponents/Tabs/Tabs.test.tsx   | 95 ++++++++++++++++++++++
 .../components/gridComponents/Tabs/Tabs.tsx        | 22 ++++-
 .../gridComponents/TabsRenderer/TabsRenderer.tsx   |  2 +-
 .../src/dashboard/reducers/dashboardState.test.ts  | 23 ++++++
 4 files changed, 140 insertions(+), 2 deletions(-)

diff --git 
a/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.test.tsx 
b/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.test.tsx
index eb8484ab1bc..0d31b9b3514 100644
--- 
a/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.test.tsx
+++ 
b/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.test.tsx
@@ -265,6 +265,101 @@ test('Switching tabs', async () => {
   expect(props.onChangeTab).toHaveBeenCalled();
 });
 
+test.each([false, true])(
+  'A childless TABS component does not register an active tab (editMode=%s)',
+  editMode => {
+    // An unresolved id reaches activeTabs as `undefined`, which
+    // `JSON.stringify` coerces to `null` in the permalink request body.
+    const props = createProps();
+    props.editMode = editMode;
+    props.component.children = [];
+
+    render(<Tabs {...props} />, {
+      useRedux: true,
+      useDnd: true,
+    });
+
+    expect(props.setActiveTab).not.toHaveBeenCalled();
+  },
+);
+
+test.each([false, true])(
+  'The first child added to an empty TABS component is activated 
(editMode=%s)',
+  editMode => {
+    const props = createProps();
+    const [tabId] = props.component.children;
+    props.editMode = editMode;
+    props.component.children = [];
+    const { rerender } = render(<Tabs {...props} />, {
+      useRedux: true,
+      useDnd: true,
+    });
+
+    expect(props.setActiveTab).not.toHaveBeenCalled();
+    rerender(
+      <Tabs {...props} component={{ ...props.component, children: [tabId] }} 
/>,
+    );
+
+    expect(props.setActiveTab.mock.calls).toEqual([[tabId]]);
+    expect(screen.getByRole('tab')).toHaveAttribute('aria-selected', 'true');
+  },
+);
+
+test('A tab added after deleting the last tab is selected and registered', 
async () => {
+  const props = createProps();
+  const [deletedTabId, newTabId] = props.component.children;
+  props.component.children = [deletedTabId];
+  const { rerender } = render(<Tabs {...props} />, {
+    useRedux: true,
+    useDnd: true,
+  });
+
+  expect(props.setActiveTab.mock.calls).toEqual([[deletedTabId]]);
+  expect(screen.getByRole('tab')).toHaveAttribute('aria-selected', 'true');
+
+  await userEvent.click(screen.getByRole('button', { name: 'remove' }));
+  await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
+  expect(props.deleteComponent).toHaveBeenCalledWith(
+    deletedTabId,
+    props.component.id,
+  );
+
+  rerender(
+    <Tabs {...props} component={{ ...props.component, children: [] }} />,
+  );
+  expect(screen.queryByRole('tab')).not.toBeInTheDocument();
+  props.setActiveTab.mockClear();
+
+  await userEvent.click(screen.getByRole('button', { name: 'Add tab' }));
+  expect(props.createComponent).toHaveBeenCalled();
+  expect(props.setActiveTab).not.toHaveBeenCalled();
+
+  rerender(
+    <Tabs
+      {...props}
+      component={{ ...props.component, children: [newTabId] }}
+    />,
+  );
+
+  expect(props.setActiveTab.mock.calls).toEqual([[newTabId, deletedTabId]]);
+  expect(screen.getByRole('tab')).toHaveAttribute('aria-selected', 'true');
+});
+
+test.each([false, true])(
+  'A populated TABS component registers its active tab (editMode=%s)',
+  editMode => {
+    const props = createProps();
+    props.editMode = editMode;
+
+    render(<Tabs {...props} />, {
+      useRedux: true,
+      useDnd: true,
+    });
+
+    expect(props.setActiveTab.mock.calls).toEqual([['TAB-AsMaxdYL_t']]);
+  },
+);
+
 test('activeTabs hydrated from a permalink selects the matching tab content', 
() => {
   // Regression guard for #36132: when a dashboard is opened via a
   // permalink/anchor (including embedded dashboards), the permalink state is
diff --git 
a/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.tsx 
b/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.tsx
index d1f351be0db..10cf3272c9a 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.tsx
@@ -153,7 +153,7 @@ const Tabs = (props: TabsProps): ReactElement => {
     };
   }, [activeTabs, props.component, directPathToChild]);
 
-  const [activeKey, setActiveKey] = useState<string>(initActiveKey);
+  const [activeKey, setActiveKey] = useState<string | 
undefined>(initActiveKey);
   const [selectedTabIndex, setSelectedTabIndex] =
     useState<number>(initTabIndex);
   const [dropPosition, setDropPosition] = useState<string | null>(null);
@@ -166,6 +166,26 @@ const Tabs = (props: TabsProps): ReactElement => {
   const prevTabIds = usePrevious(props.component.children);
 
   useEffect(() => {
+    // Resolve missing or deleted active keys when children become available
+    // so a tab added to an empty container is selected and registered.
+    const tabId = props.component.children[selectedTabIndex];
+    if (
+      tabId &&
+      (!activeKey || !props.component.children.includes(activeKey))
+    ) {
+      setActiveKey(tabId);
+    }
+  }, [activeKey, props.component.children, selectedTabIndex]);
+
+  useEffect(() => {
+    // A TABS component with no children resolves no tab id, so there is
+    // nothing to activate. Dispatching the unresolved id would register an
+    // `undefined` entry in dashboardState.activeTabs, which JSON.stringify
+    // coerces to `null` when the dashboard state is posted to the permalink
+    // endpoint.
+    if (!activeKey) {
+      return;
+    }
     if (prevActiveKey) {
       props.setActiveTab(activeKey, prevActiveKey);
     } else {
diff --git 
a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
 
b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
index 1d2797fb192..2f3eaef3767 100644
--- 
a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
+++ 
b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
@@ -184,7 +184,7 @@ export interface TabsRendererProps {
   tabsDragSourceRef?: RefObject<HTMLDivElement>;
   handleDeleteComponent: () => void;
   tabsComponent: TabsComponent;
-  activeKey: string;
+  activeKey: string | undefined;
   tabIds: string[];
   handleClickTab: (index: number) => void;
   handleEdit: AntdTabsProps['onEdit'];
diff --git a/superset-frontend/src/dashboard/reducers/dashboardState.test.ts 
b/superset-frontend/src/dashboard/reducers/dashboardState.test.ts
index 7ceb1d66824..46226c78f6e 100644
--- a/superset-frontend/src/dashboard/reducers/dashboardState.test.ts
+++ b/superset-frontend/src/dashboard/reducers/dashboardState.test.ts
@@ -274,6 +274,29 @@ describe('DashboardState reducer', () => {
         expect.arrayContaining(['TAB-Outer1', 'TAB-Inner1']),
       );
     });
+
+    // The component tests assert on a `setActiveTab` mock, so they stop short
+    // of the state that actually gets serialized into the permalink body.
+    test('stores a resolved tab id so activeTabs serializes without null', () 
=> {
+      const store = mockStore({
+        dashboardState: { activeTabs: [] },
+        dashboardLayout: { present: { 'TAB-1': { parents: [] } } },
+      });
+      const thunkAction = setActiveTab('TAB-1')(
+        store.dispatch,
+        store.getState as () => RootState,
+      );
+
+      const result = typedDashboardStateReducer(
+        createMockDashboardState({ activeTabs: [] }),
+        thunkAction,
+      );
+
+      expect(result.activeTabs).toEqual(['TAB-1']);
+      expect(JSON.stringify({ activeTabs: result.activeTabs })).toBe(
+        '{"activeTabs":["TAB-1"]}',
+      );
+    });
   });
   // Pins a side effect of seeding activeTabs at hydration (see
   // actions/hydrate.ts / util/getDefaultActiveTabs.ts): a non-empty seeded

Reply via email to