mirror of
https://github.com/apache/superset.git
synced 2026-08-14 12:01:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85696292a7 | ||
|
|
0c0235388b | ||
|
|
9b31f3b644 |
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user