Compare commits

...
Author SHA1 Message Date
rusackasandClaude Opus 4.8 85696292a7 fix: cap total force-render wait to an overall export deadline
Bound the combined per-batch and final whole-container waits to a single
overall budget so a dashboard with many stalled batches can't burn a full
per-batch timeout on each one plus another full timeout on the final
check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 23:08:59 -07:00
Claude Code 0c0235388b fix(dashboard): batch virtualized chart force-render for large-dashboard export
#42561 fixes the client-side Download as Image/PDF path exporting virtualized
rows as loading spinners, but forces every row into view in one instant
`window` event with no batching. On a dashboard with hundreds of charts,
that reintroduces the exact thundering-herd load DASHBOARD_VIRTUALIZATION
exists to prevent, just moved from page-load time to export time, with no
concurrency limit and no signal to the user that anything expensive is
happening.

This adds:

- Row-level batching. Rows now carry a `data-row-id` attribute; the export
  path force-renders them in groups of 5 rather than all at once, waiting
  (with a bounded per-batch timeout) for each batch before moving to the
  next. A chart stuck in one batch doesn't stall every later batch, since
  the per-batch wait is scoped to that batch's own row elements rather than
  the whole container. Dashboards small enough to fit in one batch keep the
  original single-event behavior unchanged.
- An upfront info toast ("Preparing N charts for export...") when a
  multi-batch export starts, so the user knows a large dashboard export is
  underway rather than wondering if the click did nothing. A live
  per-batch progress bar felt like a bigger UI commitment than this
  follow-up warranted; `onProgress` is exposed as an extensibility hook if
  that's wanted later.
- Test coverage for Row.tsx's force-in-view/restore-virtualization event
  handling, which had none before this PR despite being the component that
  does the actual work.
- Test coverage for the batching logic itself: grouping, per-batch timeout
  isolation, and the existing single-pass/flag-off paths (confirmed
  unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 21:46:02 -07:00
Jenwit AmonpongitsaraandClaude Opus 4.8 9b31f3b644 fix(dashboard): force-render virtualized charts before client-side export
When DASHBOARD_VIRTUALIZATION is enabled, dashboard rows more than a
viewport away are unmounted and replaced with a loading spinner. The
client-side "Download as Image/PDF" path captures the live DOM, so those
off-screen charts are exported as loading spinners instead of the actual
charts. The isCurrentUserBot() bypass only covers server-side (headless)
screenshots, so this affects every real-user client-side export.

Add a small force-render contract used only during export:

- downloadUtils.ts: forceLoadAllCharts() dispatches a
  superset-force-all-in-view window event (gated on the feature flag),
  polls until the container's .loading spinners clear (with a timeout and
  a warning toast), and returns whether virtualization was active.
  restoreVirtualization() dispatches superset-restore-virtualization.
- Row.tsx: on force-in-view, disconnect the IntersectionObservers and
  render; on restore, re-observe.
- downloadAsPdf / downloadAsImage: force-load before capture and restore
  on every exit path (including the ag-grid "still loading" early return
  and error paths) so a failed export never leaves virtualization
  disabled for the rest of the session.

Fixes: https://github.com/apache/superset/issues/29719

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Jenwit Amonpongitsara <jenwit.amonpongitsara@agoda.com>
2026-07-29 16:56:37 +07:00
7 changed files with 598 additions and 2 deletions
@@ -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';
@@ -328,4 +333,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);
});
});
@@ -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 () => {
@@ -305,6 +352,7 @@ const Row = memo((props: RowProps) => {
backgroundStyle.className,
)}
data-test={`grid-row-${backgroundStyle.className}`}
data-row-id={rowComponent.id}
ref={containerRef}
editMode={editMode}
>
@@ -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';
@@ -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();
}
}
};
}
+11 -1
View File
@@ -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();
}
});
};
}
@@ -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 }),
);
});
@@ -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));
}