You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2026/06/10 16:53:17 UTC

(superset) branch master updated: fix(embedded): show filter bar controls on embedded dashboards with tabs (#39417)

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

sadpandajoe 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 f27424d72e3 fix(embedded): show filter bar controls on embedded dashboards with tabs (#39417)
f27424d72e3 is described below

commit f27424d72e36160745c3d9be663a3003d0f853b0
Author: Joe Li <jo...@preset.io>
AuthorDate: Wed Jun 10 09:53:10 2026 -0700

    fix(embedded): show filter bar controls on embedded dashboards with tabs (#39417)
    
    Co-authored-by: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
 .../components/nativeFilters/state.test.ts         | 504 ++++++++++++++++++++-
 .../dashboard/components/nativeFilters/state.ts    |  76 +++-
 2 files changed, 569 insertions(+), 11 deletions(-)

diff --git a/superset-frontend/src/dashboard/components/nativeFilters/state.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/state.test.ts
index 38280624caf..dbb78c1167d 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/state.test.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/state.test.ts
@@ -41,11 +41,21 @@ jest.mock('react-redux', () => {
 });
 
 beforeEach(() => {
-  (useSelector as jest.Mock).mockImplementation(selector => {
-    if (selector.name === 'useActiveDashboardTabs') {
-      return ['TAB_1'];
-    }
-    return [];
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: ['TAB_1'] },
+      dashboardLayout: {
+        present: {
+          'CHART-123': {
+            type: 'CHART',
+            meta: { chartId: 123 },
+            parents: ['ROOT_ID', 'TAB_1'],
+          },
+          TAB_1: { type: 'TAB', id: 'TAB_1' },
+        },
+      },
+    };
+    return selector(mockState);
   });
 });
 
@@ -99,6 +109,14 @@ test('useIsFilterInScope should return false for filters with inactive rootPath'
 });
 
 test('useSelectFiltersInScope should return all filters in scope when no tabs exist', () => {
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: [] },
+      dashboardLayout: { present: {} },
+    };
+    return selector(mockState);
+  });
+
   const filters: Filter[] = [
     {
       id: 'filter_1',
@@ -601,3 +619,479 @@ test('useChartCustomizationConfiguration ignores undefined items in metadata', (
     expect.objectContaining({ id: 'CHART_CUSTOMIZATION-1' }),
   );
 });
+
+// --- Embedded / hideTab: activeTabs is empty ---
+// When an embedded dashboard uses hideTab:true, the Tabs component never
+// mounts, so setActiveTab never fires and activeTabs stays []. The same
+// empty state occurs transiently on first render of any tabbed dashboard.
+//
+// useActiveDashboardTabs derives the default first tab from the layout when
+// Redux activeTabs is empty, so scope evaluation uses the correct default
+// tab instead of either "no tabs active" (blank filter bar) or "all tabs"
+// (showing out-of-scope filters).
+
+// Helper: build a layout with ROOT_ID → TABS container → TAB children
+function embeddedLayout(extras: Record<string, Record<string, unknown>> = {}) {
+  return {
+    ROOT_ID: {
+      type: 'ROOT',
+      id: 'ROOT_ID',
+      children: ['TABS-1'],
+    },
+    'TABS-1': {
+      type: 'TABS',
+      id: 'TABS-1',
+      children: ['TAB-Company', 'TAB-Desktop'],
+    },
+    'TAB-Company': {
+      type: 'TAB',
+      id: 'TAB-Company',
+      children: ['CHART-1', 'CHART-2'],
+    },
+    'TAB-Desktop': {
+      type: 'TAB',
+      id: 'TAB-Desktop',
+      children: ['CHART-3'],
+    },
+    'CHART-1': {
+      type: 'CHART',
+      meta: { chartId: 1 },
+      parents: ['ROOT_ID', 'TAB-Company'],
+    },
+    'CHART-2': {
+      type: 'CHART',
+      meta: { chartId: 2 },
+      parents: ['ROOT_ID', 'TAB-Company'],
+    },
+    'CHART-3': {
+      type: 'CHART',
+      meta: { chartId: 3 },
+      parents: ['ROOT_ID', 'TAB-Desktop'],
+    },
+    ...extras,
+  };
+}
+
+test('useIsFilterInScope: filter scoped to default tab is in-scope when activeTabs is empty', () => {
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: [] },
+      dashboardLayout: { present: embeddedLayout() },
+    };
+    return selector(mockState);
+  });
+
+  const filter: Filter = {
+    id: 'filter_default_tab',
+    name: 'Default Tab Filter',
+    filterType: 'filter_select',
+    type: NativeFilterType.NativeFilter,
+    chartsInScope: [1, 2],
+    scope: { rootPath: ['TAB-Company'], excluded: [] },
+    controlValues: {},
+    defaultDataMask: {},
+    cascadeParentIds: [],
+    targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
+    description: 'Filter scoped to default (first) tab',
+  };
+
+  const { result } = renderHook(() => useIsFilterInScope());
+  expect(result.current(filter)).toBe(true);
+});
+
+test('useIsFilterInScope: filter scoped only to non-default tab is out-of-scope when activeTabs is empty', () => {
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: [] },
+      dashboardLayout: { present: embeddedLayout() },
+    };
+    return selector(mockState);
+  });
+
+  const filter: Filter = {
+    id: 'filter_other_tab',
+    name: 'Other Tab Filter',
+    filterType: 'filter_select',
+    type: NativeFilterType.NativeFilter,
+    chartsInScope: [3],
+    scope: { rootPath: ['TAB-Desktop'], excluded: [] },
+    controlValues: {},
+    defaultDataMask: {},
+    cascadeParentIds: [],
+    targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
+    description: 'Filter scoped only to non-default tab',
+  };
+
+  const { result } = renderHook(() => useIsFilterInScope());
+  expect(result.current(filter)).toBe(false);
+});
+
+test('useIsFilterInScope: filter with rootPath to default tab is in-scope when activeTabs is empty', () => {
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: [] },
+      dashboardLayout: { present: embeddedLayout() },
+    };
+    return selector(mockState);
+  });
+
+  const filter: Filter = {
+    id: 'filter_rootpath_default',
+    name: 'RootPath Filter',
+    filterType: 'filter_select',
+    type: NativeFilterType.NativeFilter,
+    scope: { rootPath: ['TAB-Company'], excluded: [] },
+    controlValues: {},
+    defaultDataMask: {},
+    cascadeParentIds: [],
+    targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
+    description: 'Filter using rootPath to default tab',
+  };
+
+  const { result } = renderHook(() => useIsFilterInScope());
+  expect(result.current(filter)).toBe(true);
+});
+
+test('useSelectFiltersInScope: only default-tab filters are in scope when activeTabs is empty (embedded hideTab)', () => {
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: [] },
+      dashboardLayout: { present: embeddedLayout() },
+    };
+    return selector(mockState);
+  });
+
+  const filters: Filter[] = [
+    {
+      id: 'filter_company',
+      name: 'Company Tab Filter',
+      filterType: 'filter_select',
+      type: NativeFilterType.NativeFilter,
+      chartsInScope: [1, 2],
+      scope: { rootPath: ['TAB-Company'], excluded: [] },
+      controlValues: {},
+      defaultDataMask: {},
+      cascadeParentIds: [],
+      targets: [{ column: { name: 'survey_rating' }, datasetId: 1 }],
+      description: 'Filter scoped to default tab',
+    },
+    {
+      id: 'filter_desktop_only',
+      name: 'Desktop Only Filter',
+      filterType: 'filter_select',
+      type: NativeFilterType.NativeFilter,
+      chartsInScope: [3],
+      scope: { rootPath: ['TAB-Desktop'], excluded: [] },
+      controlValues: {},
+      defaultDataMask: {},
+      cascadeParentIds: [],
+      targets: [{ column: { name: 'pool_name' }, datasetId: 2 }],
+      description: 'Filter scoped only to non-default tab',
+    },
+  ];
+
+  const { result } = renderHook(() => useSelectFiltersInScope(filters));
+  expect(result.current[0]).toHaveLength(1);
+  expect(result.current[0][0]).toEqual(
+    expect.objectContaining({ id: 'filter_company' }),
+  );
+  expect(result.current[1]).toHaveLength(1);
+  expect(result.current[1][0]).toEqual(
+    expect.objectContaining({ id: 'filter_desktop_only' }),
+  );
+});
+
+test('useSelectFiltersInScope: dividers are always in scope even when activeTabs is empty', () => {
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: [] },
+      dashboardLayout: { present: embeddedLayout() },
+    };
+    return selector(mockState);
+  });
+
+  const items: (Filter | Divider)[] = [
+    {
+      id: 'divider_embedded',
+      type: NativeFilterType.Divider,
+      title: 'Section',
+      description: 'Divider in embedded mode',
+    },
+    {
+      id: 'filter_default',
+      name: 'Default Tab Filter',
+      filterType: 'filter_select',
+      type: NativeFilterType.NativeFilter,
+      chartsInScope: [1],
+      scope: { rootPath: ['TAB-Company'], excluded: [] },
+      controlValues: {},
+      defaultDataMask: {},
+      cascadeParentIds: [],
+      targets: [{ column: { name: 'col' }, datasetId: 1 }],
+      description: 'Filter in default tab',
+    },
+  ];
+
+  const { result } = renderHook(() => useSelectFiltersInScope(items));
+  expect(result.current[0]).toHaveLength(2);
+  expect(result.current[1]).toHaveLength(0);
+});
+
+test('useSelectFiltersInScope: correctly scopes chartsInScope filters when activeTabs is populated', () => {
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: ['TAB-Company'] },
+      dashboardLayout: { present: embeddedLayout() },
+    };
+    return selector(mockState);
+  });
+
+  const filters: Filter[] = [
+    {
+      id: 'filter_active_tab',
+      name: 'Active Tab Filter',
+      filterType: 'filter_select',
+      type: NativeFilterType.NativeFilter,
+      chartsInScope: [1],
+      scope: { rootPath: ['TAB-Company'], excluded: [] },
+      controlValues: {},
+      defaultDataMask: {},
+      cascadeParentIds: [],
+      targets: [{ column: { name: 'col' }, datasetId: 1 }],
+      description: 'Filter scoped to active tab',
+    },
+    {
+      id: 'filter_inactive_tab',
+      name: 'Inactive Tab Filter',
+      filterType: 'filter_select',
+      type: NativeFilterType.NativeFilter,
+      chartsInScope: [3],
+      scope: { rootPath: ['TAB-Desktop'], excluded: [] },
+      controlValues: {},
+      defaultDataMask: {},
+      cascadeParentIds: [],
+      targets: [{ column: { name: 'col' }, datasetId: 2 }],
+      description: 'Filter scoped to inactive tab',
+    },
+  ];
+
+  const { result } = renderHook(() => useSelectFiltersInScope(filters));
+  expect(result.current[0]).toHaveLength(1);
+  expect(result.current[0][0]).toEqual(
+    expect.objectContaining({ id: 'filter_active_tab' }),
+  );
+  expect(result.current[1]).toHaveLength(1);
+  expect(result.current[1][0]).toEqual(
+    expect.objectContaining({ id: 'filter_inactive_tab' }),
+  );
+});
+
+// Layout-derived default-tab fallback edge cases. These pin behavior of
+// useActiveDashboardTabs when activeTabs is empty across structural variants
+// of dashboardLayout, so the fallback can't silently regress.
+
+test('useIsFilterInScope: dashboard with no top-level tabs (root child is GRID_ID) treats filters as in-scope', () => {
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: [] },
+      dashboardLayout: {
+        present: {
+          ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
+          GRID_ID: { type: 'GRID', id: 'GRID_ID', children: ['CHART-1'] },
+          'CHART-1': {
+            type: 'CHART',
+            meta: { chartId: 1 },
+            parents: ['ROOT_ID', 'GRID_ID'],
+          },
+        },
+      },
+    };
+    return selector(mockState);
+  });
+
+  const filter: Filter = {
+    id: 'filter_no_tabs',
+    name: 'No-tabs filter',
+    filterType: 'filter_select',
+    type: NativeFilterType.NativeFilter,
+    chartsInScope: [1],
+    scope: { rootPath: ['ROOT_ID'], excluded: [] },
+    controlValues: {},
+    defaultDataMask: {},
+    cascadeParentIds: [],
+    targets: [{ column: { name: 'col' }, datasetId: 1 }],
+    description: 'Filter on a no-tabs dashboard',
+  };
+
+  const { result } = renderHook(() => useIsFilterInScope());
+  // Chart has no TAB ancestors → tabParents is empty → considered in-scope.
+  expect(result.current(filter)).toBe(true);
+});
+
+test('useIsFilterInScope: missing dashboardLayout falls back without crashing', () => {
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: [] },
+      dashboardLayout: { present: undefined },
+    };
+    return selector(mockState);
+  });
+
+  const filter: Filter = {
+    id: 'filter_no_layout',
+    name: 'No layout',
+    filterType: 'filter_select',
+    type: NativeFilterType.NativeFilter,
+    chartsInScope: [1],
+    scope: { rootPath: ['TAB-A'], excluded: [] },
+    controlValues: {},
+    defaultDataMask: {},
+    cascadeParentIds: [],
+    targets: [{ column: { name: 'col' }, datasetId: 1 }],
+    description: 'Filter when layout is missing',
+  };
+
+  // The hook should not throw when layout is missing. Without the
+  // useActiveDashboardTabs guard, indexing dashboardLayout[ROOT_ID] would
+  // crash here.
+  const { result } = renderHook(() => useIsFilterInScope());
+  expect(() => result.current(filter)).not.toThrow();
+});
+
+// Shared fixture for the two nested-tabs tests below. Layout is identical;
+// only the redux activeTabs differs (empty for the default-path test,
+// inner-only for the hideTab ancestor-merge test).
+const nestedTabsLayout = () => ({
+  ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['TABS-1'] },
+  'TABS-1': {
+    type: 'TABS',
+    id: 'TABS-1',
+    children: ['TAB-Outer1', 'TAB-Outer2'],
+  },
+  'TAB-Outer1': {
+    type: 'TAB',
+    id: 'TAB-Outer1',
+    children: ['TABS-2'],
+  },
+  'TAB-Outer2': {
+    type: 'TAB',
+    id: 'TAB-Outer2',
+    children: ['CHART-Outer2'],
+  },
+  'TABS-2': {
+    type: 'TABS',
+    id: 'TABS-2',
+    children: ['TAB-Inner1', 'TAB-Inner2'],
+  },
+  'TAB-Inner1': {
+    type: 'TAB',
+    id: 'TAB-Inner1',
+    children: ['CHART-Inner1'],
+  },
+  'TAB-Inner2': {
+    type: 'TAB',
+    id: 'TAB-Inner2',
+    children: ['CHART-Inner2'],
+  },
+  'CHART-Inner1': {
+    type: 'CHART',
+    meta: { chartId: 11 },
+    parents: ['ROOT_ID', 'TAB-Outer1', 'TABS-2', 'TAB-Inner1'],
+  },
+  'CHART-Inner2': {
+    type: 'CHART',
+    meta: { chartId: 12 },
+    parents: ['ROOT_ID', 'TAB-Outer1', 'TABS-2', 'TAB-Inner2'],
+  },
+  'CHART-Outer2': {
+    type: 'CHART',
+    meta: { chartId: 20 },
+    parents: ['ROOT_ID', 'TAB-Outer2'],
+  },
+});
+
+const mockNestedTabsState = (activeTabs: string[]) => ({
+  dashboardState: { activeTabs },
+  dashboardLayout: { present: nestedTabsLayout() },
+});
+
+test('useIsFilterInScope: deeply nested tabs — default path includes inner-tab default', () => {
+  // ROOT → TABS-1 → [TAB-Outer1, TAB-Outer2]
+  //                  └─ TAB-Outer1 → TABS-2 → [TAB-Inner1, TAB-Inner2]
+  // Default path should be ['TAB-Outer1', 'TAB-Inner1'].
+  (useSelector as jest.Mock).mockImplementation((selector: Function) =>
+    selector(mockNestedTabsState([])),
+  );
+
+  const innerDefaultFilter: Filter = {
+    id: 'filter_inner1',
+    name: 'Inner default-tab filter',
+    filterType: 'filter_select',
+    type: NativeFilterType.NativeFilter,
+    chartsInScope: [11],
+    scope: { rootPath: ['TAB-Inner1'], excluded: [] },
+    controlValues: {},
+    defaultDataMask: {},
+    cascadeParentIds: [],
+    targets: [{ column: { name: 'col' }, datasetId: 1 }],
+    description: 'Filter scoped to inner default tab',
+  };
+
+  const innerNonDefaultFilter: Filter = {
+    ...innerDefaultFilter,
+    id: 'filter_inner2',
+    chartsInScope: [12],
+    scope: { rootPath: ['TAB-Inner2'], excluded: [] },
+  };
+
+  const { result } = renderHook(() => useIsFilterInScope());
+  // CHART-Inner1's tab parents [TAB-Outer1, TAB-Inner1] are both in default
+  // path → in scope.
+  expect(result.current(innerDefaultFilter)).toBe(true);
+  // CHART-Inner2's tab parent TAB-Inner2 is not in default path → out of scope.
+  expect(result.current(innerNonDefaultFilter)).toBe(false);
+});
+
+test('useIsFilterInScope: nested Tabs mounted under hideTab:true — outer ancestor merged so outer-tab scoping is preserved', () => {
+  // hideTab:true skips the top-level Tabs but a nested Tabs can still mount
+  // and dispatch setActiveTab. activeTabs holds only the inner id; without
+  // ancestor merging, filters whose charts have tabParents=[outer, inner]
+  // would be marked out-of-scope because the outer id is missing.
+  (useSelector as jest.Mock).mockImplementation((selector: Function) =>
+    selector(mockNestedTabsState(['TAB-Inner1'])),
+  );
+
+  const innerActiveFilter: Filter = {
+    id: 'filter_inner1_active',
+    name: 'Filter scoped to active inner tab',
+    filterType: 'filter_select',
+    type: NativeFilterType.NativeFilter,
+    chartsInScope: [11],
+    scope: { rootPath: ['TAB-Inner1'], excluded: [] },
+    controlValues: {},
+    defaultDataMask: {},
+    cascadeParentIds: [],
+    targets: [{ column: { name: 'col' }, datasetId: 1 }],
+    description: 'Filter on the active inner tab',
+  };
+
+  const otherOuterFilter: Filter = {
+    id: 'filter_other_outer',
+    name: 'Filter scoped to non-default outer tab',
+    filterType: 'filter_select',
+    type: NativeFilterType.NativeFilter,
+    chartsInScope: [20],
+    scope: { rootPath: ['TAB-Outer2'], excluded: [] },
+    controlValues: {},
+    defaultDataMask: {},
+    cascadeParentIds: [],
+    targets: [{ column: { name: 'col' }, datasetId: 2 }],
+    description: 'Filter on the other outer tab — must stay out of scope',
+  };
+
+  const { result } = renderHook(() => useIsFilterInScope());
+  // Outer ancestor TAB-Outer1 is merged into the active path → in scope.
+  expect(result.current(innerActiveFilter)).toBe(true);
+  // TAB-Outer2 is not in the active path → out of scope, scoping preserved.
+  expect(result.current(otherOuterFilter)).toBe(false);
+});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/state.ts
index cfb58d0f442..af2b87a8076 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/state.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/state.ts
@@ -30,7 +30,8 @@ import {
 } from '@superset-ui/core';
 import { FilterElement } from './FilterBar/FilterControls/types';
 import { ActiveTabs, DashboardLayout, RootState } from '../../types';
-import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes';
+import { CHART_TYPE, TAB_TYPE, TABS_TYPE } from '../../util/componentTypes';
+import { DASHBOARD_ROOT_ID } from '../../util/constants';
 import { isChartCustomizationId } from './FiltersConfigModal/utils';
 import {
   migrateChartCustomizationArray,
@@ -38,6 +39,7 @@ import {
 } from '../../util/migrateChartCustomization';
 
 const EMPTY_ARRAY: ChartCustomizationConfiguration = [];
+const EMPTY_ACTIVE_TABS: ActiveTabs = [];
 const defaultFilterConfiguration: (Filter | Divider)[] = [];
 
 export const selectFilterConfiguration: (
@@ -175,22 +177,83 @@ export function useDashboardHasTabs() {
   const dashboardLayout = useDashboardLayout();
   return useMemo(
     () =>
-      Object.values(dashboardLayout).some(element => element.type === TAB_TYPE),
+      dashboardLayout
+        ? Object.values(dashboardLayout).some(
+            element => element.type === TAB_TYPE,
+          )
+        : false,
     [dashboardLayout],
   );
 }
 
-function useActiveDashboardTabs() {
-  return useSelector<RootState, ActiveTabs>(
+function useActiveDashboardTabs(): ActiveTabs {
+  const reduxTabs = useSelector<RootState, ActiveTabs>(
     state => state.dashboardState?.activeTabs,
   );
+  const dashboardLayout = useDashboardLayout();
+
+  return useMemo(() => {
+    const reduxList = reduxTabs ?? [];
+    const reduxFallback = reduxList.length ? reduxList : EMPTY_ACTIVE_TABS;
+    if (!dashboardLayout) return reduxFallback;
+
+    // Tabbed dashboards always nest the top-level TABS container as the first
+    // child of ROOT. If that invariant doesn't hold (no-tabs layout), no
+    // fallback applies and we use reduxTabs as-is.
+    const root = dashboardLayout[DASHBOARD_ROOT_ID];
+    if (!root?.children?.length) return reduxFallback;
+    const topContainer = dashboardLayout[root.children[0]];
+    if (topContainer?.type !== TABS_TYPE || !topContainer.children?.length) {
+      return reduxFallback;
+    }
+
+    // Walk every TABS container along the active path. For each container,
+    // pick the child Redux marked active; otherwise pick the first child (the
+    // default the live Tabs component would render). This handles:
+    //   - empty reduxTabs (hideTab:true, no permalink) → full default path
+    //   - reduxTabs missing an outer ancestor (hideTab:true skipped the
+    //     top-level Tabs, but a nested Tabs dispatched setActiveTab) → fill
+    //     in the missing ancestor so outer-tab scoping is preserved
+    //   - fully populated reduxTabs (normal hydration) → same result
+    const reduxSet = new Set(reduxList);
+    const result: ActiveTabs = [];
+    const queue: string[] = [
+      topContainer.children.find(c => reduxSet.has(c)) ??
+        topContainer.children[0],
+    ];
+    while (queue.length > 0) {
+      const tabId = queue.shift()!;
+      result.push(tabId);
+      const tab = dashboardLayout[tabId];
+      if (!tab?.children) continue;
+      for (const childId of tab.children) {
+        const child = dashboardLayout[childId];
+        if (child?.type !== TABS_TYPE || !child.children?.length) continue;
+        queue.push(
+          child.children.find(c => reduxSet.has(c)) ?? child.children[0],
+        );
+      }
+    }
+
+    // Preserve any reduxTabs entries that fell outside the traversed path so
+    // we never silently drop a redux-marked active tab id.
+    const resultSet = new Set(result);
+    for (const id of reduxList) {
+      if (!resultSet.has(id)) result.push(id);
+    }
+    return result;
+  }, [reduxTabs, dashboardLayout]);
 }
 
 function useSelectChartTabParents() {
   const dashboardLayout = useDashboardLayout();
   const layoutChartItems = useMemo(
     () =>
-      Object.values(dashboardLayout).filter(item => item.type === CHART_TYPE),
+      dashboardLayout
+        ? Object.values(dashboardLayout).filter(
+            item => item.type === CHART_TYPE,
+          )
+        : [],
     [dashboardLayout],
   );
   return useCallback(
@@ -199,7 +262,7 @@ function useSelectChartTabParents() {
         layoutItem => layoutItem.meta?.chartId === chartId,
       );
       return chartLayoutItem?.parents?.filter(
-        (parent: string) => dashboardLayout[parent]?.type === TAB_TYPE,
+        (parent: string) => dashboardLayout?.[parent]?.type === TAB_TYPE,
       );
     },
     [dashboardLayout, layoutChartItems],
@@ -332,6 +395,7 @@ export function useSelectCustomizationsInScope(
       | ChartCustomizationDivider
     )[] = [];
 
+    // we check customization scopes only on dashboards with tabs
     if (!dashboardHasTabs) {
       customizationsInScope = customizations;
     } else {