fix(native-filters): prevent infinite recursion in filter scope tree traversal (#39355)

This commit is contained in:
Luiz Otavio
2026-04-15 08:16:12 -03:00
committed by GitHub
parent 3e25f02da9
commit 86575e129b
2 changed files with 87 additions and 1 deletions

View File

@@ -18,7 +18,7 @@
*/
import { Layout, LayoutItem, Charts } from 'src/dashboard/types';
import { VizType } from '@superset-ui/core';
import { buildTree } from './utils';
import { buildTree, getTreeCheckedItems } from './utils';
import type { TreeItem } from './types';
// The types defined for Layout and sub elements is not compatible with the data we get back fro a real dashboard layout
@@ -18048,6 +18048,80 @@ describe('Ensure buildTree does not throw runtime errors when encountering an in
}).not.toThrow();
});
test('Does not infinitely recurse when layout contains a cycle (nested tabs circular reference)', () => {
// Simulate a corrupted layout where TAB-A → TAB-B → TAB-A (cycle)
const circularLayout = {
ROOT_ID: {
id: 'ROOT_ID',
type: 'ROOT',
children: ['TAB-A'],
parents: [],
},
'TAB-A': {
id: 'TAB-A',
type: 'TAB',
children: ['TAB-B'],
parents: ['ROOT_ID'],
meta: { text: 'Tab A' },
},
'TAB-B': {
id: 'TAB-B',
type: 'TAB',
// Points back to TAB-A, creating a cycle
children: ['TAB-A'],
parents: ['ROOT_ID', 'TAB-A'],
meta: { text: 'Tab B' },
},
};
const rootNode = circularLayout.ROOT_ID;
const rootTreeItem: TreeItem = { key: 'ROOT_ID', title: 'Root', children: [], nodeType: 'TAB' };
expect(() => {
buildTree(
rootNode as unknown as LayoutItem,
rootTreeItem,
circularLayout as unknown as Layout,
{} as Charts,
['ROOT_ID', 'TAB-A', 'TAB-B'],
[],
() => 'title',
);
}).not.toThrow();
});
test('getTreeCheckedItems does not infinitely recurse when scope rootPath creates a cycle', () => {
// Simulate a corrupted layout where ROW-A → ROW-B → ROW-A (cycle via children)
const circularLayout = {
ROOT_ID: {
id: 'ROOT_ID',
type: 'ROOT',
children: ['ROW-A'],
parents: [],
},
'ROW-A': {
id: 'ROW-A',
type: 'ROW',
children: ['ROW-B'],
parents: ['ROOT_ID'],
},
'ROW-B': {
id: 'ROW-B',
type: 'ROW',
// Points back to ROW-A, creating a cycle
children: ['ROW-A'],
parents: ['ROOT_ID', 'ROW-A'],
},
};
expect(() => {
getTreeCheckedItems(
{ rootPath: ['ROOT_ID'], excluded: [] },
circularLayout as unknown as Layout,
);
}).not.toThrow();
});
test('Avoids runtime error with invalid inputs', () => {
expect(() => {
buildTree(

View File

@@ -100,6 +100,7 @@ export const buildTree = (
initiallyExcludedCharts: number[],
buildTreeLeafTitle: BuildTreeLeafTitle,
sliceEntities?: Record<number, Slice>,
visited: Set<string> = new Set(),
) => {
if (!node) {
return;
@@ -154,6 +155,10 @@ export const buildTree = (
if (node.type !== CHART_TYPE) {
node?.children?.forEach?.(child => {
if (visited.has(child)) {
return;
}
visited.add(child);
const childNode = layout?.[child];
if (childNode) {
buildTree(
@@ -165,6 +170,7 @@ export const buildTree = (
initiallyExcludedCharts,
buildTreeLeafTitle,
sliceEntities,
visited,
);
} else {
logging.warn(
@@ -192,13 +198,19 @@ const checkTreeItem = (
layout: Layout,
items: string[],
excluded: number[],
visited: Set<string> = new Set(),
) => {
items.forEach(item => {
if (visited.has(item)) {
return;
}
visited.add(item);
checkTreeItem(
checkedItems,
layout,
addInvisibleParents(layout, item),
excluded,
visited,
);
if (
layout[item]?.type === CHART_TYPE &&