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

rusackas 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 c026ae2ce7 fix(dashboard): prevent table chart infinite reload loop 
(#36686)
c026ae2ce7 is described below

commit c026ae2ce712126fd009681b36dfd47124567f0b
Author: Joe Li <[email protected]>
AuthorDate: Fri Dec 19 13:45:36 2025 -0800

    fix(dashboard): prevent table chart infinite reload loop (#36686)
    
    Co-authored-by: Claude Opus 4.5 <[email protected]>
---
 .../src/dashboard/components/Dashboard.test.jsx    |  69 ++++++++++
 .../util/activeAllDashboardFilters.test.ts         | 150 +++++++++++++++++++++
 .../dashboard/util/activeAllDashboardFilters.ts    |  17 ++-
 3 files changed, 235 insertions(+), 1 deletion(-)

diff --git a/superset-frontend/src/dashboard/components/Dashboard.test.jsx 
b/superset-frontend/src/dashboard/components/Dashboard.test.jsx
index dd00834ba2..3c950fe7b5 100644
--- a/superset-frontend/src/dashboard/components/Dashboard.test.jsx
+++ b/superset-frontend/src/dashboard/components/Dashboard.test.jsx
@@ -332,4 +332,73 @@ describe('Dashboard', () => {
       expect(mockTriggerQuery).not.toHaveBeenCalled();
     });
   });
+
+  test('should NOT call refresh when ownDataCharts content is unchanged', () 
=> {
+    // When only clientView changes, getRelevantDataMask strips it,
+    // so Dashboard receives identical ownDataCharts and should not refresh
+    const initialOwnDataCharts = {
+      1: { pageSize: 10, currentPage: 0 },
+    };
+
+    const { rerender } = renderDashboard({
+      ownDataCharts: initialOwnDataCharts,
+      dashboardState: {
+        ...dashboardState,
+        editMode: false,
+      },
+    });
+
+    // Rerender with same ownDataCharts (simulates clientView-only change 
after stripping)
+    rerender(
+      <PluginContext.Provider value={{ loading: false }}>
+        <Dashboard
+          {...props}
+          ownDataCharts={initialOwnDataCharts}
+          dashboardState={{
+            ...dashboardState,
+            editMode: false,
+          }}
+        >
+          <ChildrenComponent />
+        </Dashboard>
+      </PluginContext.Provider>,
+    );
+
+    expect(mockTriggerQuery).not.toHaveBeenCalled();
+  });
+
+  test('should call refresh when ownDataCharts pageSize changes', () => {
+    const initialOwnDataCharts = {
+      1: { pageSize: 10, currentPage: 0 },
+    };
+
+    const { rerender } = renderDashboard({
+      ownDataCharts: initialOwnDataCharts,
+      dashboardState: {
+        ...dashboardState,
+        editMode: false,
+      },
+    });
+
+    const updatedOwnDataCharts = {
+      1: { pageSize: 20, currentPage: 0 },
+    };
+
+    rerender(
+      <PluginContext.Provider value={{ loading: false }}>
+        <Dashboard
+          {...props}
+          ownDataCharts={updatedOwnDataCharts}
+          dashboardState={{
+            ...dashboardState,
+            editMode: false,
+          }}
+        >
+          <ChildrenComponent />
+        </Dashboard>
+      </PluginContext.Provider>,
+    );
+
+    expect(mockTriggerQuery).toHaveBeenCalledWith(true, '1');
+  });
 });
diff --git 
a/superset-frontend/src/dashboard/util/activeAllDashboardFilters.test.ts 
b/superset-frontend/src/dashboard/util/activeAllDashboardFilters.test.ts
index e274c5a081..dd2363f5aa 100644
--- a/superset-frontend/src/dashboard/util/activeAllDashboardFilters.test.ts
+++ b/superset-frontend/src/dashboard/util/activeAllDashboardFilters.test.ts
@@ -165,3 +165,153 @@ test('should preserve the structure of the property 
value', () => {
     },
   });
 });
+
+test('should strip clientView from ownState to prevent infinite reload loops', 
() => {
+  // TableChart writes clientView to ownState on every filtered-row change
+  // If clientView is not stripped, Dashboard treats it as a chart-state change
+  // and re-triggers queries, causing an infinite reload loop
+  const mockDataMaskWithClientView: DataMaskStateWithId = {
+    chart1: {
+      id: 'chart1',
+      ownState: {
+        pageSize: 10,
+        currentPage: 0,
+        // clientView is added by TableChart for export functionality
+        // but should NOT trigger chart re-queries
+        clientView: {
+          rows: [{ id: 1, name: 'Test' }],
+          columns: [{ key: 'id' }, { key: 'name' }],
+          count: 1,
+        },
+      },
+    },
+    chart2: {
+      id: 'chart2',
+      ownState: {
+        sortBy: [{ id: 'col1', desc: true }],
+        clientView: {
+          rows: [{ id: 2, name: 'Test2' }],
+          columns: [{ key: 'id' }, { key: 'name' }],
+          count: 1,
+        },
+      },
+    },
+  };
+
+  const result = getRelevantDataMask(mockDataMaskWithClientView, 'ownState');
+
+  // clientView should be stripped from ownState
+  // Only pageSize, currentPage, sortBy etc should remain
+  expect(result).toEqual({
+    chart1: {
+      pageSize: 10,
+      currentPage: 0,
+    },
+    chart2: {
+      sortBy: [{ id: 'col1', desc: true }],
+    },
+  });
+});
+
+test('should return equal results when only clientView changes', () => {
+  // When TableChart updates clientView (on every filtered-row change),
+  // the selector output should remain equal, so Dashboard.jsx's
+  // areObjectsEqual comparison returns true and doesn't trigger re-queries
+
+  const dataMaskBefore: DataMaskStateWithId = {
+    chart1: {
+      id: 'chart1',
+      ownState: {
+        pageSize: 10,
+        currentPage: 0,
+        clientView: {
+          rows: [{ id: 1, name: 'Original' }],
+          count: 1,
+        },
+      },
+    },
+  };
+
+  const dataMaskAfter: DataMaskStateWithId = {
+    chart1: {
+      id: 'chart1',
+      ownState: {
+        pageSize: 10,
+        currentPage: 0,
+        // clientView changed (simulating TableChart updating filtered rows)
+        clientView: {
+          rows: [
+            { id: 1, name: 'Updated' },
+            { id: 2, name: 'New Row' },
+          ],
+          count: 2,
+        },
+      },
+    },
+  };
+
+  const resultBefore = getRelevantDataMask(dataMaskBefore, 'ownState');
+  const resultAfter = getRelevantDataMask(dataMaskAfter, 'ownState');
+
+  // Both results should be equal since clientView is stripped
+  // This is what prevents the infinite reload loop in Dashboard
+  expect(resultBefore).toEqual(resultAfter);
+  expect(resultBefore).toEqual({
+    chart1: { pageSize: 10, currentPage: 0 },
+  });
+});
+
+test('should return extraFormData unchanged (clientView stripping only applies 
to ownState)', () => {
+  // Verify extraFormData is passed through without modification
+  const mockDataMask: DataMaskStateWithId = {
+    filter1: {
+      id: 'filter1',
+      extraFormData: {
+        filters: [{ col: 'country', op: 'IN', val: ['USA'] }],
+        granularity_sqla: 'ds',
+      },
+    },
+    filter2: {
+      id: 'filter2',
+      extraFormData: {
+        time_range: 'Last 7 days',
+      },
+    },
+  };
+
+  const result = getRelevantDataMask(mockDataMask, 'extraFormData');
+
+  // extraFormData should be returned unchanged
+  expect(result).toEqual({
+    filter1: {
+      filters: [{ col: 'country', op: 'IN', val: ['USA'] }],
+      granularity_sqla: 'ds',
+    },
+    filter2: {
+      time_range: 'Last 7 days',
+    },
+  });
+});
+
+test('should return filterState unchanged (clientView stripping only applies 
to ownState)', () => {
+  // Verify filterState is passed through without modification
+  const mockDataMask: DataMaskStateWithId = {
+    filter1: {
+      id: 'filter1',
+      filterState: {
+        value: ['A', 'B'],
+        label: 'Categories A and B',
+      },
+    },
+  };
+
+  const result = getRelevantDataMask(mockDataMask, 'filterState');
+
+  // filterState should be returned unchanged
+  expect(result).toEqual({
+    filter1: {
+      value: ['A', 'B'],
+      label: 'Categories A and B',
+    },
+  });
+});
diff --git a/superset-frontend/src/dashboard/util/activeAllDashboardFilters.ts 
b/superset-frontend/src/dashboard/util/activeAllDashboardFilters.ts
index 102a2161ea..c44aa7662a 100644
--- a/superset-frontend/src/dashboard/util/activeAllDashboardFilters.ts
+++ b/superset-frontend/src/dashboard/util/activeAllDashboardFilters.ts
@@ -22,6 +22,7 @@ import {
   JsonObject,
   PartialFilters,
 } from '@superset-ui/core';
+import { omit } from 'lodash';
 import { ActiveFilters, ChartConfiguration } from '../types';
 
 export const getRelevantDataMask = (
@@ -31,7 +32,21 @@ export const getRelevantDataMask = (
   Object.fromEntries(
     Object.values(dataMask)
       .filter(item => item[prop])
-      .map(item => [item.id, item[prop]]),
+      .map(item => {
+        const value = item[prop];
+        // TableChart writes clientView to ownState on every filtered-row 
change for export
+        // but clientView changes should NOT trigger chart re-queries
+        // Only clone when clientView exists to avoid unnecessary allocations
+        if (
+          prop === 'ownState' &&
+          value &&
+          typeof value === 'object' &&
+          'clientView' in value
+        ) {
+          return [item.id, omit(value, ['clientView'])];
+        }
+        return [item.id, value];
+      }),
   );
 
 interface LayerInfo {

Reply via email to