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/08/10 17:57:29 UTC

(superset) branch master updated: fix(dashboard): force-render virtualized charts before client-side export (#42561)

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 4defdbabd28 fix(dashboard): force-render virtualized charts before client-side export (#42561)
4defdbabd28 is described below

commit 4defdbabd28ee748a5806cf6a09d110bec09c170
Author: jenwitteng <je...@agoda.com>
AuthorDate: Tue Aug 11 00:57:21 2026 +0700

    fix(dashboard): force-render virtualized charts before client-side export (#42561)
    
    Signed-off-by: Jenwit Amonpongitsara <je...@agoda.com>
    Co-authored-by: Claude Opus 4.8 <no...@anthropic.com>
    Co-authored-by: rusackas <ev...@rusackas.com>
---
 .../components/gridComponents/Row/Row.test.tsx     |  59 +++++
 .../components/gridComponents/Row/Row.tsx          |  50 +++-
 superset-frontend/src/dashboard/constants.ts       |  13 ++
 superset-frontend/src/utils/downloadAsImage.tsx    |  17 ++
 superset-frontend/src/utils/downloadAsPdf.ts       |  12 +-
 superset-frontend/src/utils/downloadUtils.test.ts  | 252 +++++++++++++++++++++
 superset-frontend/src/utils/downloadUtils.ts       | 197 ++++++++++++++++
 7 files changed, 598 insertions(+), 2 deletions(-)

diff --git a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.tsx
index 39b2b718ec6..4c25e0f80e3 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.tsx
@@ -18,6 +18,7 @@
  */
 import React from 'react';
 import {
+  act,
   fireEvent,
   render,
   RenderResult,
@@ -25,6 +26,10 @@ import {
 } from 'spec/helpers/testing-library';
 
 import { DASHBOARD_GRID_ID } from 'src/dashboard/util/constants';
+import {
+  FORCE_IN_VIEW_EVENT,
+  RESTORE_VIRTUALIZATION_EVENT,
+} from 'src/dashboard/constants';
 import { getMockStore } from 'spec/fixtures/mockStore';
 import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
 import { initialState } from 'src/SqlLab/fixtures';
@@ -337,4 +342,58 @@ describe('visibility handling for intersection observers', () => {
     expect(() => callback([nonIntersectingEntry])).not.toThrow();
     expect(callback([nonIntersectingEntry])).toBe(false);
   });
+
+  test('force-in-view event with no detail disconnects the observers for every row', () => {
+    setup({ isComponentVisible: true });
+
+    act(() => {
+      window.dispatchEvent(new Event(FORCE_IN_VIEW_EVENT));
+    });
+
+    expect(mockDisconnect).toHaveBeenCalled();
+  });
+
+  test('force-in-view event scoped to other rowIds does not disconnect this row', () => {
+    setup({ isComponentVisible: true });
+
+    act(() => {
+      window.dispatchEvent(
+        new CustomEvent(FORCE_IN_VIEW_EVENT, {
+          detail: { rowIds: ['SOME_OTHER_ROW_ID'] },
+        }),
+      );
+    });
+
+    expect(mockDisconnect).not.toHaveBeenCalled();
+  });
+
+  test('force-in-view event scoped to this rowId disconnects the observers', () => {
+    setup({ isComponentVisible: true });
+
+    act(() => {
+      window.dispatchEvent(
+        new CustomEvent(FORCE_IN_VIEW_EVENT, {
+          detail: { rowIds: [props.id] },
+        }),
+      );
+    });
+
+    expect(mockDisconnect).toHaveBeenCalled();
+  });
+
+  test('restore-virtualization event re-observes after a force-in-view', () => {
+    setup({ isComponentVisible: true });
+    expect(mockObserve).toHaveBeenCalledTimes(2);
+
+    act(() => {
+      window.dispatchEvent(new Event(FORCE_IN_VIEW_EVENT));
+    });
+    act(() => {
+      window.dispatchEvent(new Event(RESTORE_VIRTUALIZATION_EVENT));
+    });
+
+    // The initial mount observes twice (enabler + disabler); restoring
+    // after a force-in-view re-observes both again.
+    expect(mockObserve).toHaveBeenCalledTimes(4);
+  });
 });
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx
index b644a65fef8..e3b3d781a8f 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx
@@ -46,7 +46,11 @@ import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
 import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
 import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
 import { isEmbedded } from 'src/dashboard/util/isEmbedded';
-import { EMPTY_CONTAINER_Z_INDEX } from 'src/dashboard/constants';
+import {
+  EMPTY_CONTAINER_Z_INDEX,
+  FORCE_IN_VIEW_EVENT,
+  RESTORE_VIRTUALIZATION_EVENT,
+} from 'src/dashboard/constants';
 import { isCurrentUserBot } from 'src/utils/isBot';
 
 export type RowProps = {
@@ -207,6 +211,49 @@ const Row = memo((props: RowProps) => {
         observerEnabler.observe(element);
         observerDisabler.observe(element);
       }
+
+      // Client-side "Download as Image/PDF" (see src/utils/downloadUtils.ts)
+      // dispatches these events so off-screen rows render before the capture
+      // and lazy loading is restored afterwards. Without this, virtualized
+      // charts are exported as loading spinners.
+      //
+      // The force event optionally carries a `rowIds` batch in its detail so
+      // large dashboards can be force-rendered a few rows at a time instead
+      // of every row at once (see FORCE_RENDER_BATCH_SIZE in
+      // downloadUtils.ts). No detail means "force every row", preserving the
+      // original single-shot behavior for any other future caller.
+      const handleForceInView = (event: Event) => {
+        const rowIds = (event as CustomEvent<{ rowIds?: string[] }>).detail
+          ?.rowIds;
+        if (rowIds && !rowIds.includes(rowComponent.id as string)) {
+          return;
+        }
+        observerEnabler?.disconnect();
+        observerDisabler?.disconnect();
+        setIsInView(true);
+      };
+      const handleRestoreVirtualization = () => {
+        const el = containerRef.current;
+        if (el) {
+          observerEnabler?.observe(el);
+          observerDisabler?.observe(el);
+        }
+      };
+      window.addEventListener(FORCE_IN_VIEW_EVENT, handleForceInView);
+      window.addEventListener(
+        RESTORE_VIRTUALIZATION_EVENT,
+        handleRestoreVirtualization,
+      );
+
+      return () => {
+        observerEnabler?.disconnect();
+        observerDisabler?.disconnect();
+        window.removeEventListener(FORCE_IN_VIEW_EVENT, handleForceInView);
+        window.removeEventListener(
+          RESTORE_VIRTUALIZATION_EVENT,
+          handleRestoreVirtualization,
+        );
+      };
     }
 
     return () => {
@@ -307,6 +354,7 @@ const Row = memo((props: RowProps) => {
             backgroundStyle.className,
           )}
           data-test={`grid-row-${backgroundStyle.className}`}
+          data-row-id={rowComponent.id}
           ref={containerRef}
           editMode={editMode}
         >
diff --git a/superset-frontend/src/dashboard/constants.ts b/superset-frontend/src/dashboard/constants.ts
index 526b28ae1e5..aa90bd2dfc9 100644
--- a/superset-frontend/src/dashboard/constants.ts
+++ b/superset-frontend/src/dashboard/constants.ts
@@ -51,3 +51,16 @@ export const DEFAULT_CROSS_FILTER_SCOPING: NativeFilterScope = {
 
 export const CHART_WIDTH = 4;
 export const CHART_HEIGHT = 50;
+
+/**
+ * Window events used to coordinate dashboard virtualization when capturing the
+ * dashboard for a client-side export (Download as Image/PDF).
+ *
+ * When DASHBOARD_VIRTUALIZATION is enabled, charts more than a viewport away
+ * are unmounted, so a naive DOM capture records loading spinners instead of the
+ * charts. The export utilities dispatch FORCE_IN_VIEW_EVENT to make every Row
+ * render its content, wait for the charts to finish loading, capture, then
+ * dispatch RESTORE_VIRTUALIZATION_EVENT to re-enable lazy loading.
+ */
+export const FORCE_IN_VIEW_EVENT = 'superset-force-all-in-view';
+export const RESTORE_VIRTUALIZATION_EVENT = 'superset-restore-virtualization';
diff --git a/superset-frontend/src/utils/downloadAsImage.tsx b/superset-frontend/src/utils/downloadAsImage.tsx
index a3be38ba9b8..a79d2f7c356 100644
--- a/superset-frontend/src/utils/downloadAsImage.tsx
+++ b/superset-frontend/src/utils/downloadAsImage.tsx
@@ -23,6 +23,7 @@ import { t } from '@apache-superset/core/translation';
 import { SupersetTheme } from '@apache-superset/core/theme';
 import { addWarningToast } from 'src/components/MessageToasts/actions';
 import type { AgGridContainerElement } from '@superset-ui/core/components';
+import { forceLoadAllCharts, restoreVirtualization } from './downloadUtils';
 
 const IMAGE_DOWNLOAD_QUALITY = 0.95;
 const PNG_SCALE = 2; // Higher quality for PNG
@@ -337,6 +338,11 @@ export default function downloadAsImageOptimized(
       return;
     }
 
+    // Force any virtualized (unmounted) charts to render before capturing, so
+    // off-screen rows are not exported as loading spinners. Must be restored on
+    // every exit path below.
+    const didForceLoad = await forceLoadAllCharts(elementToPrint);
+
     const filter = (node: Element) =>
       typeof node.className === 'string'
         ? !node.className.includes('mapboxgl-control-container') &&
@@ -374,6 +380,11 @@ export default function downloadAsImageOptimized(
         addWarningToast(
           t('The chart is still loading. Please wait a moment and try again.'),
         );
+        // This early return skips the capture, so restore virtualization here;
+        // otherwise it would stay forced-on for the rest of the session.
+        if (didForceLoad) {
+          restoreVirtualization();
+        }
         return;
       }
 
@@ -483,6 +494,9 @@ export default function downloadAsImageOptimized(
             });
           }
         }
+        if (didForceLoad) {
+          restoreVirtualization();
+        }
       }
       return;
     }
@@ -533,6 +547,9 @@ export default function downloadAsImageOptimized(
       );
     } finally {
       if (cleanup) cleanup();
+      if (didForceLoad) {
+        restoreVirtualization();
+      }
     }
   };
 }
diff --git a/superset-frontend/src/utils/downloadAsPdf.ts b/superset-frontend/src/utils/downloadAsPdf.ts
index a83b96249b1..7bd452f43a9 100644
--- a/superset-frontend/src/utils/downloadAsPdf.ts
+++ b/superset-frontend/src/utils/downloadAsPdf.ts
@@ -23,6 +23,7 @@ import { t } from '@apache-superset/core/translation';
 import { logging } from '@apache-superset/core/utils';
 import { addWarningToast } from 'src/components/MessageToasts/actions';
 import getBootstrapData from 'src/utils/getBootstrapData';
+import { forceLoadAllCharts, restoreVirtualization } from './downloadUtils';
 
 const pdfCompressionLevel = getBootstrapData().common.pdf_compression_level;
 
@@ -49,7 +50,7 @@ export default function downloadAsPdf(
   description: string,
   isExactSelector = false,
 ) {
-  return (event: SyntheticEvent) => {
+  return async (event: SyntheticEvent) => {
     const elementToPrint = isExactSelector
       ? document.querySelector(selector)
       : event.currentTarget.closest(selector);
@@ -60,6 +61,10 @@ export default function downloadAsPdf(
       );
     }
 
+    // Force any virtualized (unmounted) charts to render before capturing, so
+    // off-screen rows are not exported as loading spinners.
+    const didForceLoad = await forceLoadAllCharts(elementToPrint);
+
     const options = {
       margin: 10,
       compression: pdfCompressionLevel,
@@ -74,6 +79,11 @@ export default function downloadAsPdf(
       })
       .catch((e: Error) => {
         logging.error('PDF generation failed', e);
+      })
+      .finally(() => {
+        if (didForceLoad) {
+          restoreVirtualization();
+        }
       });
   };
 }
diff --git a/superset-frontend/src/utils/downloadUtils.test.ts b/superset-frontend/src/utils/downloadUtils.test.ts
new file mode 100644
index 00000000000..7c5360dbfdb
--- /dev/null
+++ b/superset-frontend/src/utils/downloadUtils.test.ts
@@ -0,0 +1,252 @@
+/**
+ * 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 { isFeatureEnabled } from '@superset-ui/core';
+import {
+  addInfoToast,
+  addWarningToast,
+} from 'src/components/MessageToasts/actions';
+import {
+  FORCE_IN_VIEW_EVENT,
+  RESTORE_VIRTUALIZATION_EVENT,
+} from 'src/dashboard/constants';
+import { forceLoadAllCharts, restoreVirtualization } from './downloadUtils';
+
+jest.mock('@superset-ui/core', () => ({
+  isFeatureEnabled: jest.fn(),
+  FeatureFlag: {
+    DashboardVirtualization: 'DASHBOARD_VIRTUALIZATION',
+  },
+}));
+
+jest.mock('src/dashboard/constants', () => ({
+  FORCE_IN_VIEW_EVENT: 'superset-force-all-in-view',
+  RESTORE_VIRTUALIZATION_EVENT: 'superset-restore-virtualization',
+}));
+
+jest.mock('@apache-superset/core/translation', () => ({
+  t: (str: string, values?: Record<string, unknown>) =>
+    values ? `${str} ${JSON.stringify(values)}` : str,
+}));
+
+jest.mock('@apache-superset/core/utils', () => ({
+  logging: { warn: jest.fn() },
+}));
+
+jest.mock('src/components/MessageToasts/actions', () => ({
+  addInfoToast: jest.fn(),
+  addWarningToast: jest.fn(),
+}));
+
+const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
+
+function makeRow(id: string, loading = false): HTMLDivElement {
+  const row = document.createElement('div');
+  row.setAttribute('data-row-id', id);
+  if (loading) {
+    const spinner = document.createElement('div');
+    spinner.className = 'loading';
+    row.appendChild(spinner);
+  }
+  return row;
+}
+
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
+afterEach(() => {
+  jest.useRealTimers();
+});
+
+test('forceLoadAllCharts returns false and dispatches nothing when virtualization is disabled', async () => {
+  mockIsFeatureEnabled.mockReturnValue(false);
+  const dispatchSpy = jest.spyOn(window, 'dispatchEvent');
+  const container = document.createElement('div');
+
+  const result = await forceLoadAllCharts(container);
+
+  expect(result).toBe(false);
+  expect(dispatchSpy).not.toHaveBeenCalled();
+});
+
+test('forceLoadAllCharts dispatches the force-in-view event and resolves true once charts finish loading', async () => {
+  jest.useFakeTimers();
+  mockIsFeatureEnabled.mockReturnValue(true);
+  const dispatchSpy = jest.spyOn(window, 'dispatchEvent');
+  // No `.loading` elements => charts are considered loaded.
+  const container = document.createElement('div');
+
+  const promise = forceLoadAllCharts(container);
+
+  expect(dispatchSpy).toHaveBeenCalledWith(
+    expect.objectContaining({ type: FORCE_IN_VIEW_EVENT }),
+  );
+
+  await jest.advanceTimersByTimeAsync(1000);
+  const result = await promise;
+
+  expect(result).toBe(true);
+  expect(addWarningToast).not.toHaveBeenCalled();
+});
+
+test('forceLoadAllCharts warns when charts never finish loading before the timeout', async () => {
+  jest.useFakeTimers();
+  mockIsFeatureEnabled.mockReturnValue(true);
+  const container = document.createElement('div');
+  const loadingChart = document.createElement('div');
+  loadingChart.className = 'loading';
+  container.appendChild(loadingChart);
+
+  const promise = forceLoadAllCharts(container);
+
+  // Advance past the 60s timeout while a `.loading` element is still present.
+  await jest.advanceTimersByTimeAsync(61_000);
+  const result = await promise;
+
+  // Virtualization was active, so the caller must still restore it.
+  expect(result).toBe(true);
+  expect(addWarningToast).toHaveBeenCalledTimes(1);
+});
+
+test('forceLoadAllCharts dispatches a single force-in-view event when rows fit in one batch', async () => {
+  jest.useFakeTimers();
+  mockIsFeatureEnabled.mockReturnValue(true);
+  const dispatchSpy = jest.spyOn(window, 'dispatchEvent');
+  const container = document.createElement('div');
+  container.append(makeRow('a'), makeRow('b'), makeRow('c'));
+
+  const promise = forceLoadAllCharts(container);
+  await jest.advanceTimersByTimeAsync(2000);
+  const result = await promise;
+
+  expect(result).toBe(true);
+  expect(addInfoToast).not.toHaveBeenCalled();
+  const forceEvents = dispatchSpy.mock.calls
+    .map(([event]) => event as Event)
+    .filter(event => event.type === FORCE_IN_VIEW_EVENT);
+  expect(forceEvents).toHaveLength(1);
+  expect((forceEvents[0] as CustomEvent).detail).toBeUndefined();
+});
+
+test('forceLoadAllCharts batches rows in groups rather than forcing everything at once', async () => {
+  jest.useFakeTimers();
+  mockIsFeatureEnabled.mockReturnValue(true);
+  const dispatchSpy = jest.spyOn(window, 'dispatchEvent');
+  const container = document.createElement('div');
+  const rowIds = Array.from({ length: 12 }, (_, i) => `row-${i}`);
+  rowIds.forEach(id => container.appendChild(makeRow(id)));
+
+  const onProgress = jest.fn();
+  const promise = forceLoadAllCharts(container, onProgress);
+
+  // 3 sequential batch waits + the final whole-container check, ~1s each
+  // since nothing is ever `.loading`.
+  await jest.advanceTimersByTimeAsync(5000);
+  const result = await promise;
+
+  expect(result).toBe(true);
+  expect(addInfoToast).toHaveBeenCalledTimes(1);
+
+  const forceEvents = dispatchSpy.mock.calls
+    .map(([event]) => event as CustomEvent<{ rowIds: string[] }>)
+    .filter(event => event.type === FORCE_IN_VIEW_EVENT);
+
+  expect(forceEvents).toHaveLength(3);
+  expect(forceEvents[0].detail.rowIds).toEqual(rowIds.slice(0, 5));
+  expect(forceEvents[1].detail.rowIds).toEqual(rowIds.slice(5, 10));
+  expect(forceEvents[2].detail.rowIds).toEqual(rowIds.slice(10, 12));
+
+  expect(onProgress).toHaveBeenNthCalledWith(1, {
+    loadedBatches: 1,
+    totalBatches: 3,
+  });
+  expect(onProgress).toHaveBeenNthCalledWith(2, {
+    loadedBatches: 2,
+    totalBatches: 3,
+  });
+  expect(onProgress).toHaveBeenNthCalledWith(3, {
+    loadedBatches: 3,
+    totalBatches: 3,
+  });
+});
+
+test('forceLoadAllCharts moves on to the next batch even if the current one times out', async () => {
+  jest.useFakeTimers();
+  mockIsFeatureEnabled.mockReturnValue(true);
+  const container = document.createElement('div');
+  // Batch 1 (5 rows): one spinner is slow to clear.
+  const stuck = makeRow('stuck', true);
+  container.appendChild(stuck);
+  for (let i = 1; i < 5; i += 1) {
+    container.appendChild(makeRow(`row-${i}`));
+  }
+  // Batch 2 (1 row): loads fine.
+  container.appendChild(makeRow('row-5'));
+
+  const onProgress = jest.fn();
+  const promise = forceLoadAllCharts(container, onProgress);
+
+  // Batch 1 gives up waiting at the 10s per-batch cap since `stuck` hasn't
+  // cleared yet, but moves on to batch 2 (which has nothing loading and
+  // resolves on its own first poll) instead of blocking the whole export
+  // on one slow chart.
+  await jest.advanceTimersByTimeAsync(12_000);
+  expect(onProgress).toHaveBeenCalledTimes(2);
+
+  // The chart finishes just after its batch gave up on it. The final
+  // whole-container check (a safety net for exactly this) should pick that
+  // up on its next poll rather than needing its own full 60s timeout.
+  stuck.querySelector('.loading')?.remove();
+  await jest.advanceTimersByTimeAsync(1000);
+  const result = await promise;
+
+  expect(result).toBe(true);
+  expect(addWarningToast).not.toHaveBeenCalled();
+});
+
+test('forceLoadAllCharts caps the total wait across batches to the overall deadline', async () => {
+  jest.useFakeTimers();
+  mockIsFeatureEnabled.mockReturnValue(true);
+  const container = document.createElement('div');
+  // 8 batches of 5 rows, every row permanently `.loading`: with no overall
+  // deadline this would burn a full 10s per batch (80s) plus another 60s on
+  // the final whole-container check (140s total) before giving up.
+  const rowIds = Array.from({ length: 40 }, (_, i) => `row-${i}`);
+  rowIds.forEach(id => container.appendChild(makeRow(id, true)));
+
+  const promise = forceLoadAllCharts(container);
+
+  // Comfortably past the 60s overall budget, but far short of the 140s the
+  // unbounded, per-timeout-summed behavior would have required.
+  await jest.advanceTimersByTimeAsync(65_000);
+  const result = await promise;
+
+  expect(result).toBe(true);
+  expect(addWarningToast).toHaveBeenCalledTimes(1);
+});
+
+test('restoreVirtualization dispatches the restore event', () => {
+  const dispatchSpy = jest.spyOn(window, 'dispatchEvent');
+
+  restoreVirtualization();
+
+  expect(dispatchSpy).toHaveBeenCalledWith(
+    expect.objectContaining({ type: RESTORE_VIRTUALIZATION_EVENT }),
+  );
+});
diff --git a/superset-frontend/src/utils/downloadUtils.ts b/superset-frontend/src/utils/downloadUtils.ts
new file mode 100644
index 00000000000..603b8fb1dbb
--- /dev/null
+++ b/superset-frontend/src/utils/downloadUtils.ts
@@ -0,0 +1,197 @@
+/**
+ * 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 { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { logging } from '@apache-superset/core/utils';
+import {
+  addInfoToast,
+  addWarningToast,
+} from 'src/components/MessageToasts/actions';
+import {
+  FORCE_IN_VIEW_EVENT,
+  RESTORE_VIRTUALIZATION_EVENT,
+} from 'src/dashboard/constants';
+
+// Rows carry a `data-row-id` attribute (see Row.tsx) so the export path can
+// target a subset of them per batch. How many rows get forced into view at
+// once: large enough that a small dashboard finishes in one batch, small
+// enough that a dashboard with hundreds of rows doesn't fire hundreds of
+// chart queries in the same tick, which is the exact thundering-herd load
+// DASHBOARD_VIRTUALIZATION exists to prevent in the first place.
+const FORCE_RENDER_BATCH_SIZE = 5;
+// Upper bound on how long a single batch is given to finish loading before
+// moving on to the next one. Intentionally shorter than the final,
+// whole-container timeout below: a slow chart in one batch shouldn't stall
+// every later batch, since the final check catches stragglers anyway.
+const BATCH_LOAD_TIMEOUT_MS = 10_000;
+// Overall budget for the whole force-load pass (every batch wait plus the
+// final whole-container check combined). Without this, a dashboard with
+// many permanently-stalled batches could burn its full per-batch timeout
+// on each one, plus another full timeout on the final check, keeping the
+// export blocked far longer than any single timeout value suggests.
+const OVERALL_LOAD_TIMEOUT_MS = 60_000;
+
+export type ForceLoadProgress = {
+  loadedBatches: number;
+  totalBatches: number;
+};
+
+/**
+ * Poll until all `.loading` spinners inside a container disappear,
+ * indicating that lazy-loaded charts have finished rendering.
+ * Returns true if all charts loaded, false if timed out.
+ */
+function waitForChartsToLoad(
+  container: Element,
+  timeoutMs = 60_000,
+): Promise<boolean> {
+  return new Promise(resolve => {
+    const startTime = Date.now();
+    const check = () => {
+      const loadingElements = container.querySelectorAll('.loading');
+      if (loadingElements.length === 0) {
+        resolve(true);
+        return;
+      }
+      if (Date.now() - startTime > timeoutMs) {
+        logging.warn(
+          `Timed out waiting for ${loadingElements.length} chart(s) to load`,
+        );
+        resolve(false);
+        return;
+      }
+      setTimeout(check, 500);
+    };
+    setTimeout(check, 1000);
+  });
+}
+
+/**
+ * Poll until none of the given row elements contain a `.loading` spinner.
+ * Scoped to just those rows (rather than the whole container, like
+ * waitForChartsToLoad above) so a chart stuck in an earlier batch doesn't
+ * force every later batch to also burn its full timeout re-checking that
+ * same stale spinner. Resolves (doesn't reject) either way; a straggler
+ * here is still caught by the final whole-container check afterwards.
+ */
+function waitForRowsToLoad(rows: Element[], timeoutMs: number): Promise<void> {
+  return new Promise(resolve => {
+    const startTime = Date.now();
+    const check = () => {
+      const stillLoading = rows.some(row => row.querySelector('.loading'));
+      if (!stillLoading || Date.now() - startTime > timeoutMs) {
+        resolve();
+        return;
+      }
+      setTimeout(check, 500);
+    };
+    setTimeout(check, 1000);
+  });
+}
+
+function getRowElements(container: Element): Element[] {
+  return Array.from(container.querySelectorAll('[data-row-id]'));
+}
+
+function getRowId(row: Element): string | null {
+  return row.getAttribute('data-row-id');
+}
+
+function chunk<T>(items: T[], size: number): T[][] {
+  const batches: T[][] = [];
+  for (let i = 0; i < items.length; i += size) {
+    batches.push(items.slice(i, i + size));
+  }
+  return batches;
+}
+
+/**
+ * When DASHBOARD_VIRTUALIZATION is enabled, forces lazy-loaded charts to
+ * render in small batches (rather than all at once) and waits for them to
+ * finish loading. Returns true if virtualization was active (caller must
+ * restore it).
+ */
+export async function forceLoadAllCharts(
+  container: Element,
+  onProgress?: (progress: ForceLoadProgress) => void,
+): Promise<boolean> {
+  const useVirtualization = isFeatureEnabled(
+    FeatureFlag.DashboardVirtualization,
+  );
+  if (useVirtualization) {
+    const deadline = Date.now() + OVERALL_LOAD_TIMEOUT_MS;
+    const rowElements = getRowElements(container);
+    const rowBatches = rowElements.length
+      ? chunk(rowElements, FORCE_RENDER_BATCH_SIZE)
+      : [];
+
+    if (rowBatches.length <= 1) {
+      // Nothing to batch (no rows found, or everything fits in one batch):
+      // force everything into view in a single pass, same as before batching.
+      window.dispatchEvent(new Event(FORCE_IN_VIEW_EVENT));
+    } else {
+      addInfoToast(
+        t('Preparing %(count)s charts for export. This may take a moment.', {
+          count: rowElements.length,
+        }),
+      );
+      // eslint-disable-next-line no-restricted-syntax -- batches must be
+      // dispatched sequentially so the query burst is actually staggered.
+      for (const [index, batch] of rowBatches.entries()) {
+        const rowIds = batch
+          .map(getRowId)
+          .filter((id): id is string => id !== null);
+        window.dispatchEvent(
+          new CustomEvent(FORCE_IN_VIEW_EVENT, { detail: { rowIds } }),
+        );
+        // Never wait longer than what's left of the overall budget, so a
+        // string of stalled batches can't each burn a full per-batch
+        // timeout and blow past the deadline in aggregate.
+        const remainingMs = Math.max(0, deadline - Date.now());
+        // eslint-disable-next-line no-await-in-loop -- see above
+        await waitForRowsToLoad(
+          batch,
+          Math.min(BATCH_LOAD_TIMEOUT_MS, remainingMs),
+        );
+        onProgress?.({
+          loadedBatches: index + 1,
+          totalBatches: rowBatches.length,
+        });
+      }
+    }
+
+    const allLoaded = await waitForChartsToLoad(
+      container,
+      Math.max(0, deadline - Date.now()),
+    );
+    if (!allLoaded) {
+      addWarningToast(
+        t('Some charts did not finish loading. The export may be incomplete.'),
+      );
+    }
+  }
+  return useVirtualization;
+}
+
+/**
+ * Restores normal lazy loading behavior after a forced load.
+ */
+export function restoreVirtualization(): void {
+  window.dispatchEvent(new Event(RESTORE_VIRTUALIZATION_EVENT));
+}