You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/07/05 06:28:04 UTC

(superset) branch master updated: perf(explore): reuse chart query data in Results tab (#38165)

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 8229c0183dc perf(explore): reuse chart query data in Results tab (#38165)
8229c0183dc is described below

commit 8229c0183dc030b336c4365b06970be5d7359e44
Author: hayden <sk...@gmail.com>
AuthorDate: Sun Jul 5 15:27:57 2026 +0900

    perf(explore): reuse chart query data in Results tab (#38165)
    
    Co-authored-by: devops-sewhan-kim <se...@a-bly.com>
---
 superset-frontend/playwright/pages/ExplorePage.ts  |  46 +++++
 .../experimental/explore/results-tab-reuse.spec.ts | 123 +++++++++++++
 .../components/DataTablesPane/DataTablesPane.tsx   |   6 +
 .../components/ResultsPaneOnDashboard.tsx          |   2 +
 .../DataTablesPane/components/useResultsPane.tsx   |  94 +++++++---
 .../components/DataTablesPane/test/fixture.tsx     |   8 +-
 .../DataTablesPane/test/useResultsPane.test.tsx    | 201 +++++++++++++++++++++
 .../src/explore/components/DataTablesPane/types.ts |   4 +-
 .../explore/components/ExploreChartPanel/index.tsx |   1 +
 9 files changed, 457 insertions(+), 28 deletions(-)

diff --git a/superset-frontend/playwright/pages/ExplorePage.ts b/superset-frontend/playwright/pages/ExplorePage.ts
index 6cd79ef34ea..2575b55332b 100644
--- a/superset-frontend/playwright/pages/ExplorePage.ts
+++ b/superset-frontend/playwright/pages/ExplorePage.ts
@@ -19,6 +19,7 @@
 
 import { Page, Locator } from '@playwright/test';
 import { TIMEOUT } from '../utils/constants';
+import { AgGrid } from '../components/core/AgGrid';
 
 /**
  * Explore Page object
@@ -30,6 +31,11 @@ export class ExplorePage {
     DATASOURCE_CONTROL: '[data-test="datasource-control"]',
     VIZ_SWITCHER: '[data-test="fast-viz-switcher"]',
     CHART_CONTAINER: '[data-test="chart-container"]',
+    // The bottom data panel (DataTablesPane / SouthPane) with Results/Samples tabs
+    SOUTH_PANE: '[data-test="some-purposeful-instance"]',
+    EXPAND_DATA_PANEL: '[aria-label="Expand data panel"]',
+    RESULTS_TAB: '[data-node-key="results"]',
+    ACTIVE_TABPANE: '.ant-tabs-tabpane-active',
   } as const;
 
   constructor(page: Page) {
@@ -106,4 +112,44 @@ export class ExplorePage {
   getVizSwitcher(): Locator {
     return this.page.locator(ExplorePage.SELECTORS.VIZ_SWITCHER);
   }
+
+  /**
+   * Expands the bottom data panel if it is currently collapsed.
+   * Safe to call when already expanded (no-op).
+   */
+  async expandDataPanel(): Promise<void> {
+    const expandButton = this.page.locator(
+      ExplorePage.SELECTORS.EXPAND_DATA_PANEL,
+    );
+    if (await expandButton.isVisible().catch(() => false)) {
+      await expandButton.click();
+    }
+  }
+
+  /**
+   * Opens the bottom data panel and activates the "Results" tab. Clicking the
+   * already-active tab collapses the panel, so the click is guarded.
+   */
+  async openResultsTab(): Promise<void> {
+    await this.expandDataPanel();
+    const resultsTab = this.page
+      .locator(ExplorePage.SELECTORS.SOUTH_PANE)
+      .locator(ExplorePage.SELECTORS.RESULTS_TAB);
+    const className = (await resultsTab.getAttribute('class')) ?? '';
+    if (!className.includes('ant-tabs-tab-active')) {
+      await resultsTab.click();
+    }
+  }
+
+  /**
+   * Returns an AgGrid wrapper around the currently active Results tab grid.
+   */
+  getResultsGrid(): AgGrid {
+    const grid = this.page
+      .locator(ExplorePage.SELECTORS.SOUTH_PANE)
+      .locator(ExplorePage.SELECTORS.ACTIVE_TABPANE)
+      .locator('[role="grid"]')
+      .first();
+    return new AgGrid(this.page, grid);
+  }
 }
diff --git a/superset-frontend/playwright/tests/experimental/explore/results-tab-reuse.spec.ts b/superset-frontend/playwright/tests/experimental/explore/results-tab-reuse.spec.ts
new file mode 100644
index 00000000000..6a08260d426
--- /dev/null
+++ b/superset-frontend/playwright/tests/experimental/explore/results-tab-reuse.spec.ts
@@ -0,0 +1,123 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Regression coverage for #38152 (PR #38165): the Explore "Results" tab reuses
+ * the chart's already-fetched query data instead of firing a second, identical
+ * request to /api/v1/chart/data. The duplicate only exists across the real
+ * backend round-trip; the reuse-vs-fallback and row-limit slicing logic is
+ * unit-tested in useResultsPane.test.tsx, so this E2E asserts only the
+ * end-to-end win: opening Results issues no extra chart/data request.
+ *
+ * Lives under tests/experimental/ until proven stable in CI; run with:
+ *   INCLUDE_EXPERIMENTAL=true npm run playwright:test \
+ *     tests/experimental/explore/results-tab-reuse.spec.ts -- --headed
+ */
+import { testWithAssets, expect } from '../../../helpers/fixtures';
+import { apiPostChart } from '../../../helpers/api/chart';
+import { getDatasetByName } from '../../../helpers/api/dataset';
+import { ExplorePage } from '../../../pages/ExplorePage';
+import { TIMEOUT } from '../../../utils/constants';
+
+const DATASET_NAME = 'birth_names';
+const CHART_DATA_PATH = '/api/v1/chart/data';
+
+testWithAssets(
+  'Results tab reuses chart data without a duplicate query (#38165)',
+  async ({ page, testAssets }) => {
+    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
+
+    let chartDataRequests = 0;
+    page.on('request', request => {
+      if (
+        request.method() === 'POST' &&
+        request.url().includes(CHART_DATA_PATH)
+      ) {
+        chartDataRequests += 1;
+      }
+    });
+
+    const dataset = await getDatasetByName(page, DATASET_NAME);
+    if (!dataset) {
+      throw new Error(`Dataset ${DATASET_NAME} not found`);
+    }
+    const datasetId = dataset.id;
+
+    const params = {
+      datasource: `${datasetId}__table`,
+      viz_type: 'table',
+      query_mode: 'raw',
+      all_columns: ['name', 'gender', 'num'],
+      adhoc_filters: [],
+      order_by_cols: [],
+      row_limit: 1000,
+      server_pagination: false,
+    };
+    const chartResp = await apiPostChart(page, {
+      slice_name: `results_tab_reuse_${Date.now()}`,
+      viz_type: 'table',
+      datasource_id: datasetId,
+      datasource_type: 'table',
+      params: JSON.stringify(params),
+    });
+    expect(chartResp.ok()).toBe(true);
+    const chartBody = await chartResp.json();
+    const chartId: number = chartBody.result?.id ?? chartBody.id;
+    expect(chartId, 'chart creation should return an id').toBeTruthy();
+    testAssets.trackChart(chartId);
+
+    // Wait for the chart query to finish so its result is in Redux; opening
+    // Results before that races and falls back to its own request.
+    const explorePage = new ExplorePage(page);
+    const chartQueryFinished = page.waitForResponse(
+      response =>
+        response.request().method() === 'POST' &&
+        response.url().includes(CHART_DATA_PATH),
+      { timeout: TIMEOUT.API_RESPONSE },
+    );
+    await explorePage.goto(chartId);
+    await chartQueryFinished;
+
+    const chartContainer = explorePage.getChartContainer();
+    await chartContainer.waitFor({
+      state: 'visible',
+      timeout: TIMEOUT.PAGE_LOAD,
+    });
+    await chartContainer
+      .getByRole('status', { name: 'Loading' })
+      .waitFor({ state: 'hidden', timeout: TIMEOUT.API_RESPONSE })
+      .catch(() => {}); // spinner may have already cleared
+
+    expect(chartDataRequests).toBeGreaterThanOrEqual(1);
+    const requestsAfterChartLoad = chartDataRequests;
+
+    await explorePage.openResultsTab();
+    const grid = explorePage.getResultsGrid();
+    await grid.waitForRows({ timeout: TIMEOUT.API_RESPONSE });
+    expect(await grid.getRowCount()).toBeGreaterThan(0);
+
+    // Allow time for an unwanted duplicate request, then assert none fired.
+    // eslint-disable-next-line playwright/no-wait-for-timeout
+    await page.waitForTimeout(1500);
+    expect(
+      chartDataRequests,
+      'opening Results must not trigger a second chart/data query',
+    ).toBe(requestsAfterChartLoad);
+  },
+);
diff --git a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx
index c7f3fe138c3..03733072063 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx
@@ -103,6 +103,7 @@ export const DataTablesPane = ({
   errorMessage,
   setForceQuery,
   canDownload,
+  queriesResponse,
 }: DataTablesPaneProps) => {
   const [activeTabKey, setActiveTabKey] = useState<string>(ResultTypes.Results);
   const [isRequest, setIsRequest] = useState<Record<ResultTypes, boolean>>({
@@ -128,6 +129,10 @@ export const DataTablesPane = ({
       });
     }
 
+    if (panelOpen && chartStatus === 'loading') {
+      setIsRequest(prev => ({ ...prev, results: false }));
+    }
+
     if (
       panelOpen &&
       activeTabKey.startsWith(ResultTypes.Results) &&
@@ -206,6 +211,7 @@ export const DataTablesPane = ({
     isRequest: isRequest.results,
     setForceQuery,
     canDownload,
+    queriesResponse,
   }).map((pane, idx) => {
     const tabKey =
       idx === 0 ? ResultTypes.Results : `${ResultTypes.Results} ${idx + 1}`;
diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/ResultsPaneOnDashboard.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/ResultsPaneOnDashboard.tsx
index 70e4f755ebc..1b7ba711bae 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/components/ResultsPaneOnDashboard.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/components/ResultsPaneOnDashboard.tsx
@@ -56,6 +56,7 @@ export const ResultsPaneOnDashboard = ({
   dataSize = 50,
   canDownload,
   columnDisplayNames,
+  queriesResponse,
 }: ResultsPaneProps) => {
   const resultsPanes = useResultsPane({
     errorMessage,
@@ -68,6 +69,7 @@ export const ResultsPaneOnDashboard = ({
     isVisible,
     canDownload,
     columnDisplayNames,
+    queriesResponse,
   });
 
   if (resultsPanes.length === 1) {
diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx
index 7f082c10922..4dcf1b5324a 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx
@@ -20,9 +20,11 @@ import { useState, useEffect, useMemo, ReactElement, useCallback } from 'react';
 
 import { t } from '@apache-superset/core/translation';
 import {
+  ChartDataResponseResult,
   ensureIsArray,
   getChartMetadataRegistry,
   getClientErrorObject,
+  QueryData,
 } from '@superset-ui/core';
 import { styled } from '@apache-superset/core/theme';
 import { EmptyState, Loading } from '@superset-ui/core/components';
@@ -45,6 +47,13 @@ const StyledDiv = styled.div`
 
 const cache = new WeakMap();
 
+// `queriesResponse` is the loose `QueryData`; only reuse it when every entry is
+// a full v1 result (colnames/coltypes/data arrays), else fall back to the API.
+const isV1QueryResult = (query: QueryData): query is ChartDataResponseResult =>
+  Array.isArray((query as ChartDataResponseResult).colnames) &&
+  Array.isArray((query as ChartDataResponseResult).coltypes) &&
+  Array.isArray((query as ChartDataResponseResult).data);
+
 export const useResultsPane = ({
   isRequest,
   queryFormData,
@@ -55,6 +64,7 @@ export const useResultsPane = ({
   isVisible,
   canDownload,
   columnDisplayNames,
+  queriesResponse,
 }: ResultsPaneProps): ReactElement[] => {
   const metadata = getChartMetadataRegistry().get(
     queryFormData?.viz_type || queryFormData?.vizType,
@@ -89,7 +99,38 @@ export const useResultsPane = ({
   useEffect(() => {
     // it's an invalid formData when gets a errorMessage
     if (errorMessage) return;
-    if (isRequest && cache.has(cappedFormData)) {
+    if (!isRequest) return;
+
+    // The chart query and the results query produce identical SQL, so reuse the
+    // chart's data instead of a second request. The chart always ran with a
+    // row_limit >= effectiveRowLimit, so its first `effectiveRowLimit` rows
+    // match what a dedicated results query would return — slice locally to keep
+    // the row-limit dropdown working without a duplicate query.
+    if (queriesResponse?.length && queriesResponse.every(isV1QueryResult)) {
+      const mapped = queriesResponse.map(q => {
+        const result = q as ChartDataResponseResult;
+        const limitedData = ensureIsArray(result.data).slice(
+          0,
+          effectiveRowLimit,
+        );
+        return {
+          colnames: result.colnames,
+          coltypes: result.coltypes,
+          data: limitedData,
+          rowcount: limitedData.length,
+        };
+      }) as unknown as QueryResultInterface[];
+      setResultResp(mapped);
+      setResponseError('');
+      if (queryForce) {
+        setForceQuery?.(false);
+      }
+      setIsLoading(false);
+      return;
+    }
+
+    // Fallback: use cached data
+    if (cache.has(cappedFormData)) {
       setResultResp(
         ensureIsArray(cache.get(cappedFormData)) as QueryResultInterface[],
       );
@@ -98,34 +139,35 @@ export const useResultsPane = ({
         setForceQuery?.(false);
       }
       setIsLoading(false);
+      return;
     }
-    if (isRequest && !cache.has(cappedFormData)) {
-      setIsLoading(true);
-      getChartDataRequest({
-        formData: cappedFormData,
-        force: queryForce,
-        resultFormat: 'json',
-        resultType: 'results',
-        ownState,
+
+    // Fallback: fetch from API (legacy charts without queriesResponse)
+    setIsLoading(true);
+    getChartDataRequest({
+      formData: cappedFormData,
+      force: queryForce,
+      resultFormat: 'json',
+      resultType: 'results',
+      ownState,
+    })
+      .then(({ json }) => {
+        setResultResp(ensureIsArray(json.result) as QueryResultInterface[]);
+        setResponseError('');
+        cache.set(cappedFormData, json.result);
+        if (queryForce) {
+          setForceQuery?.(false);
+        }
       })
-        .then(({ json }) => {
-          setResultResp(ensureIsArray(json.result) as QueryResultInterface[]);
-          setResponseError('');
-          cache.set(cappedFormData, json.result);
-          if (queryForce) {
-            setForceQuery?.(false);
-          }
-        })
-        .catch(response => {
-          getClientErrorObject(response).then(({ error, message }) => {
-            setResponseError(error || message || t('Sorry, an error occurred'));
-          });
-        })
-        .finally(() => {
-          setIsLoading(false);
+      .catch(response => {
+        getClientErrorObject(response).then(({ error, message }) => {
+          setResponseError(error || message || t('Sorry, an error occurred'));
         });
-    }
-  }, [cappedFormData, isRequest]);
+      })
+      .finally(() => {
+        setIsLoading(false);
+      });
+  }, [cappedFormData, isRequest, queriesResponse, effectiveRowLimit]);
 
   useEffect(() => {
     if (errorMessage) {
diff --git a/superset-frontend/src/explore/components/DataTablesPane/test/fixture.tsx b/superset-frontend/src/explore/components/DataTablesPane/test/fixture.tsx
index 8be74a02fac..26604e9393a 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/test/fixture.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/test/fixture.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 import { ReactElement } from 'react';
-import { DatasourceType, VizType } from '@superset-ui/core';
+import { DatasourceType, QueryData, VizType } from '@superset-ui/core';
 import { ChartStatus } from 'src/explore/types';
 import {
   DataTablesPaneProps,
@@ -106,12 +106,16 @@ export const createResultsPaneOnDashboardProps = ({
   vizType = VizType.Table,
   queryForce = false,
   isRequest = true,
+  queriesResponse,
+  rowLimit,
 }: {
   sliceId: number;
   vizType?: string;
   errorMessage?: ReactElement;
   queryForce?: boolean;
   isRequest?: boolean;
+  queriesResponse?: QueryData[] | null;
+  rowLimit?: number;
 }) =>
   ({
     isRequest,
@@ -119,10 +123,12 @@ export const createResultsPaneOnDashboardProps = ({
       ...queryFormData,
       slice_id: sliceId,
       viz_type: vizType,
+      ...(rowLimit !== undefined ? { row_limit: rowLimit } : {}),
     },
     queryForce,
     isVisible: true,
     setForceQuery: jest.fn(),
     errorMessage,
     canDownload: true,
+    queriesResponse,
   }) as ResultsPaneProps;
diff --git a/superset-frontend/src/explore/components/DataTablesPane/test/useResultsPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/test/useResultsPane.test.tsx
new file mode 100644
index 00000000000..c9f59a4a112
--- /dev/null
+++ b/superset-frontend/src/explore/components/DataTablesPane/test/useResultsPane.test.tsx
@@ -0,0 +1,201 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { screen, render, waitFor } from 'spec/helpers/testing-library';
+import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
+import { getChartDataRequest } from 'src/components/Chart/chartAction';
+import { ResultsPaneOnDashboard } from '../components';
+import { createResultsPaneOnDashboardProps } from './fixture';
+
+// Mocked so each test can assert whether the results branch hit the network.
+jest.mock('src/components/Chart/chartAction', () => ({
+  getChartDataRequest: jest.fn(),
+}));
+
+const mockedGetChartDataRequest = getChartDataRequest as jest.Mock;
+
+beforeAll(() => {
+  setupAGGridModules();
+});
+
+// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
+describe('useResultsPane query data reuse', () => {
+  beforeEach(() => {
+    mockedGetChartDataRequest.mockReset();
+  });
+
+  test('reuses queriesResponse from Redux and skips the results API call (v1 charts)', async () => {
+    const props = createResultsPaneOnDashboardProps({
+      sliceId: 201,
+      queriesResponse: [
+        {
+          colnames: ['genre'],
+          coltypes: [1],
+          data: [{ genre: 'Action' }, { genre: 'Horror' }],
+          rowcount: 2,
+        },
+      ],
+    });
+
+    render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
+
+    expect(await screen.findByText('Action')).toBeVisible();
+    expect(screen.getByText('Horror')).toBeVisible();
+    expect(mockedGetChartDataRequest).not.toHaveBeenCalled();
+  });
+
+  test('falls back to the results API for legacy charts without a typed queriesResponse', async () => {
+    mockedGetChartDataRequest.mockResolvedValue({
+      json: {
+        result: [
+          {
+            colnames: ['genre'],
+            coltypes: [1],
+            data: [{ genre: 'Drama' }],
+            rowcount: 1,
+          },
+        ],
+      },
+    });
+    const props = createResultsPaneOnDashboardProps({ sliceId: 202 });
+
+    render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
+
+    expect(await screen.findByText('Drama')).toBeVisible();
+    expect(mockedGetChartDataRequest).toHaveBeenCalledTimes(1);
+  });
+
+  test('falls back to the results API when queriesResponse has no colnames', async () => {
+    mockedGetChartDataRequest.mockResolvedValue({
+      json: {
+        result: [
+          {
+            colnames: ['genre'],
+            coltypes: [1],
+            data: [{ genre: 'Thriller' }],
+            rowcount: 1,
+          },
+        ],
+      },
+    });
+    const props = createResultsPaneOnDashboardProps({
+      sliceId: 203,
+      queriesResponse: [{ data: [{ genre: 'ignored' }] }],
+    });
+
+    render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
+
+    expect(await screen.findByText('Thriller')).toBeVisible();
+    expect(mockedGetChartDataRequest).toHaveBeenCalledTimes(1);
+  });
+
+  test('falls back to the API when queriesResponse has colnames but not the full v1 shape', async () => {
+    mockedGetChartDataRequest.mockResolvedValue({
+      json: {
+        result: [
+          {
+            colnames: ['genre'],
+            coltypes: [1],
+            data: [{ genre: 'Indie' }],
+            rowcount: 1,
+          },
+        ],
+      },
+    });
+    const props = createResultsPaneOnDashboardProps({
+      sliceId: 207,
+      // colnames present but coltypes/data missing: not a real v1 result
+      queriesResponse: [{ colnames: ['genre'] }],
+    });
+
+    render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
+
+    expect(await screen.findByText('Indie')).toBeVisible();
+    expect(mockedGetChartDataRequest).toHaveBeenCalledTimes(1);
+  });
+
+  test('reuse path honors the effective row limit (slices Redux data, no extra query)', async () => {
+    const props = createResultsPaneOnDashboardProps({
+      sliceId: 204,
+      rowLimit: 2,
+      queriesResponse: [
+        {
+          colnames: ['genre'],
+          coltypes: [1],
+          data: [
+            { genre: 'Action' },
+            { genre: 'Horror' },
+            { genre: 'Comedy' },
+            { genre: 'Drama' },
+            { genre: 'Sci-Fi' },
+          ],
+          rowcount: 5,
+        },
+      ],
+    });
+
+    render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
+
+    expect(await screen.findByText('Action')).toBeVisible();
+    expect(screen.getByText('Horror')).toBeVisible();
+    expect(screen.queryByText('Comedy')).not.toBeInTheDocument();
+    expect(screen.queryByText('Sci-Fi')).not.toBeInTheDocument();
+    expect(screen.getByText('2 rows')).toBeVisible();
+    expect(mockedGetChartDataRequest).not.toHaveBeenCalled();
+  });
+
+  test('renders an empty (0 rows) result from reused data without an API call', async () => {
+    const props = createResultsPaneOnDashboardProps({
+      sliceId: 205,
+      queriesResponse: [
+        { colnames: ['genre'], coltypes: [1], data: [], rowcount: 0 },
+      ],
+    });
+
+    render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
+
+    expect(await screen.findByText('0 rows')).toBeVisible();
+    expect(mockedGetChartDataRequest).not.toHaveBeenCalled();
+  });
+
+  test('clears the force-query flag when reusing Redux data', async () => {
+    const props = createResultsPaneOnDashboardProps({
+      sliceId: 206,
+      queryForce: true,
+      queriesResponse: [
+        {
+          colnames: ['genre'],
+          coltypes: [1],
+          data: [{ genre: 'Action' }],
+          rowcount: 1,
+        },
+      ],
+    });
+    const setForceQuery = jest.fn();
+
+    render(
+      <ResultsPaneOnDashboard {...props} setForceQuery={setForceQuery} />,
+      {
+        useRedux: true,
+      },
+    );
+
+    await waitFor(() => expect(setForceQuery).toHaveBeenCalledWith(false));
+    expect(mockedGetChartDataRequest).not.toHaveBeenCalled();
+  });
+});
diff --git a/superset-frontend/src/explore/components/DataTablesPane/types.ts b/superset-frontend/src/explore/components/DataTablesPane/types.ts
index 2d89fe5d84f..411e0ef3bff 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/types.ts
+++ b/superset-frontend/src/explore/components/DataTablesPane/types.ts
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { JsonObject, LatestQueryFormData } from '@superset-ui/core';
+import { JsonObject, LatestQueryFormData, QueryData } from '@superset-ui/core';
 import { GenericDataType } from '@apache-superset/core/common';
 import type { ChartStatus, Datasource } from 'src/explore/types';
 
@@ -36,6 +36,7 @@ export interface DataTablesPaneProps {
   errorMessage?: React.ReactNode;
   setForceQuery: SetForceQueryAction;
   canDownload: boolean;
+  queriesResponse?: QueryData[] | null;
 }
 
 export interface ResultsPaneProps {
@@ -51,6 +52,7 @@ export interface ResultsPaneProps {
   canDownload: boolean;
   // Optional map of column/metric name -> verbose label
   columnDisplayNames?: Record<string, string>;
+  queriesResponse?: QueryData[] | null;
 }
 
 export interface SamplesPaneProps {
diff --git a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
index 55a3b7647cf..3145bda73a2 100644
--- a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
@@ -560,6 +560,7 @@ const ExploreChartPanel = ({
           errorMessage={errorMessage}
           setForceQuery={actions.setForceQuery}
           canDownload={canDownload}
+          queriesResponse={chart.queriesResponse}
         />
       </Split>
       {showDatasetModal && (