Compare commits

...
Author SHA1 Message Date
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
6 changed files with 281 additions and 2 deletions
@@ -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,38 @@ 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.
const handleForceInView = () => {
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 () => {
@@ -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,119 @@
/**
* 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 { 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) => str,
}));
jest.mock('@apache-superset/core/utils', () => ({
logging: { warn: jest.fn() },
}));
jest.mock('src/components/MessageToasts/actions', () => ({
addWarningToast: jest.fn(),
}));
const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
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('restoreVirtualization dispatches the restore event', () => {
const dispatchSpy = jest.spyOn(window, 'dispatchEvent');
restoreVirtualization();
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: RESTORE_VIRTUALIZATION_EVENT }),
);
});
@@ -0,0 +1,84 @@
/**
* 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 { addWarningToast } from 'src/components/MessageToasts/actions';
import {
FORCE_IN_VIEW_EVENT,
RESTORE_VIRTUALIZATION_EVENT,
} from 'src/dashboard/constants';
/**
* 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);
});
}
/**
* When DASHBOARD_VIRTUALIZATION is enabled, forces all lazy-loaded
* charts to render and waits for them to finish loading.
* Returns true if virtualization was active (caller must restore it).
*/
export async function forceLoadAllCharts(container: Element): Promise<boolean> {
const useVirtualization = isFeatureEnabled(
FeatureFlag.DashboardVirtualization,
);
if (useVirtualization) {
window.dispatchEvent(new Event(FORCE_IN_VIEW_EVENT));
const allLoaded = await waitForChartsToLoad(container);
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));
}