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));
+}