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/09/11 00:37:49 UTC

(superset) 01/01: fix(dashboard,explore): wire addWarningToast into downloadAsImage/downloadAsPdf callers

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch fix/download-warning-toasts-wire-dispatch
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 93f600614d195987b9ce83e7ed107bdf97e0acd8
Author: Evan Rusackas <ev...@rusackas.com>
AuthorDate: Thu Sep 10 17:36:30 2026 -0700

    fix(dashboard,explore): wire addWarningToast into downloadAsImage/downloadAsPdf callers
    
    downloadAsImage and downloadAsPdf both accepted addWarningToast as a raw,
    unbound action creator imported directly from actions.ts. Called without
    dispatch, that only builds a plain Redux action object and never renders a
    toast, so a failed image/PDF export failed silently. Mirrors the fix PR
    #44031 made for downloadAsPivotExcel: both now take an optional trailing
    addWarningToast callback, and every call site threads through an
    already-bound callback from useToasts()/bindActionCreators instead.
    
    Co-Authored-By: Claude Sonnet 5 <no...@anthropic.com>
---
 .../src/dashboard/components/SliceHeader/index.tsx |   2 +
 .../components/SliceHeaderControls/index.tsx       |   5 +
 .../components/SliceHeaderControls/types.ts        |   1 +
 .../components/gridComponents/Chart/Chart.tsx      |   1 +
 .../components/menu/DownloadMenuItems/index.tsx    |  18 +-
 .../useExploreAdditionalActionsMenu/index.tsx      |  39 +++-
 .../useExploreAdditionalActionsMenu.test.tsx       |  16 +-
 .../src/utils/downloadAsImage.test.ts              | 227 +++++++++++++++++----
 superset-frontend/src/utils/downloadAsImage.tsx    |  13 +-
 superset-frontend/src/utils/downloadAsPdf.test.ts  |  66 ++++++
 superset-frontend/src/utils/downloadAsPdf.ts       |   8 +-
 11 files changed, 338 insertions(+), 58 deletions(-)

diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
index f674d91ee79..8fc85064424 100644
--- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
@@ -180,6 +180,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
       dashboardId,
       addSuccessToast,
       addDangerToast,
+      addWarningToast,
       handleToggleFullSize,
       isFullSize,
       chartStatus,
@@ -416,6 +417,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
                   dashboardId={dashboardId}
                   addSuccessToast={addSuccessToast}
                   addDangerToast={addDangerToast}
+                  addWarningToast={addWarningToast}
                   handleToggleFullSize={handleToggleFullSize}
                   isFullSize={isFullSize}
                   isDescriptionExpanded={isExpanded}
diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
index 2620d8f8807..aeca8a3718b 100644
--- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
@@ -142,6 +142,7 @@ export interface SliceHeaderControlsProps {
 
   addDangerToast: (message: string) => void;
   addSuccessToast: (message: string) => void;
+  addWarningToast?: (message: string) => void;
 
   supersetCanExplore?: boolean;
   supersetCanShare?: boolean;
@@ -333,6 +334,8 @@ const SliceHeaderControls = (
             props.slice.slice_name,
             true,
             theme,
+            undefined,
+            props.addWarningToast,
           )(domEvent),
         ).finally(() => {
           if (menu) {
@@ -364,6 +367,7 @@ const SliceHeaderControls = (
             true,
             theme,
             { format: 'png', backgroundType },
+            props.addWarningToast,
           )(domEvent),
         ).finally(() => {
           if (menu) {
@@ -391,6 +395,7 @@ const SliceHeaderControls = (
             getScreenshotNodeSelector(props.slice.slice_id),
             props.slice.slice_name,
             true,
+            props.addWarningToast,
           )(domEvent),
         ).finally(() => {
           if (menu) {
diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts b/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts
index 58d7f9352e2..924902fb51f 100644
--- a/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts
+++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts
@@ -54,6 +54,7 @@ export interface SliceHeaderControlsProps {
 
   addDangerToast: (message: string) => void;
   addSuccessToast: (message: string) => void;
+  addWarningToast?: (message: string) => void;
 
   supersetCanExplore?: boolean;
   supersetCanShare?: boolean;
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx
index 2b0456a731b..64a6cf0a760 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx
@@ -770,6 +770,7 @@ const Chart = (props: ChartProps) => {
         filters={getActiveFilters() || EMPTY_OBJECT}
         addSuccessToast={boundActionCreators.addSuccessToast}
         addDangerToast={boundActionCreators.addDangerToast}
+        addWarningToast={boundActionCreators.addWarningToast}
         handleToggleFullSize={props.handleToggleFullSize}
         isFullSize={props.isFullSize}
         chartStatus={chartStatus || ''}
diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
index 248d2795260..d61b2c989e8 100644
--- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
+++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
@@ -70,7 +70,7 @@ export const useDownloadMenuItems = (
     canExportImage,
   } = props;
 
-  const { addDangerToast, addSuccessToast } = useToasts();
+  const { addDangerToast, addSuccessToast, addWarningToast } = useToasts();
   const dataMask = useSelector((state: RootState) => state.dataMask);
   const SCREENSHOT_NODE_SELECTOR = '.dashboard';
 
@@ -92,7 +92,12 @@ export const useDownloadMenuItems = (
 
   const onDownloadPdf = async (e: SyntheticEvent) => {
     try {
-      downloadAsPdf(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e);
+      downloadAsPdf(
+        SCREENSHOT_NODE_SELECTOR,
+        dashboardTitle,
+        true,
+        addWarningToast,
+      )(e);
     } catch (error) {
       logging.error(error);
       addDangerToast(t('Sorry, something went wrong. Try again later.'));
@@ -102,7 +107,14 @@ export const useDownloadMenuItems = (
 
   const onDownloadImage = async (e: SyntheticEvent) => {
     try {
-      downloadAsImage(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e);
+      downloadAsImage(
+        SCREENSHOT_NODE_SELECTOR,
+        dashboardTitle,
+        true,
+        undefined,
+        undefined,
+        addWarningToast,
+      )(e);
     } catch (error) {
       logging.error(error);
       addDangerToast(t('Sorry, something went wrong. Try again later.'));
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
index 9151ebabc5c..476821f0a9a 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
@@ -161,6 +161,7 @@ export function getExportScreenshotMenuItems({
   transparentKey,
   solidKey,
   pdfKey,
+  addWarningToast,
 }: {
   chartSelector: string;
   sliceName: string;
@@ -172,6 +173,7 @@ export function getExportScreenshotMenuItems({
   transparentKey: string;
   solidKey: string;
   pdfKey: string;
+  addWarningToast?: (message: string) => void;
 }) {
   return [
     {
@@ -186,10 +188,14 @@ export function getExportScreenshotMenuItems({
           onClick: (e: {
             domEvent: React.MouseEvent | React.KeyboardEvent;
           }) => {
-            downloadAsImage(chartSelector, sliceName, true, theme, {
-              format: 'png',
-              backgroundType: 'transparent',
-            })(e.domEvent);
+            downloadAsImage(
+              chartSelector,
+              sliceName,
+              true,
+              theme,
+              { format: 'png', backgroundType: 'transparent' },
+              addWarningToast,
+            )(e.domEvent);
             setIsDropdownVisible(false);
             dispatch(
               logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_PNG, {
@@ -206,10 +212,14 @@ export function getExportScreenshotMenuItems({
           onClick: (e: {
             domEvent: React.MouseEvent | React.KeyboardEvent;
           }) => {
-            downloadAsImage(chartSelector, sliceName, true, theme, {
-              format: 'png',
-              backgroundType: 'solid',
-            })(e.domEvent);
+            downloadAsImage(
+              chartSelector,
+              sliceName,
+              true,
+              theme,
+              { format: 'png', backgroundType: 'solid' },
+              addWarningToast,
+            )(e.domEvent);
             setIsDropdownVisible(false);
             dispatch(
               logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_PNG, {
@@ -227,7 +237,12 @@ export function getExportScreenshotMenuItems({
       label: t('Export as PDF'),
       icon: <Icons.FileOutlined />,
       onClick: (e: { domEvent: React.MouseEvent | React.KeyboardEvent }) => {
-        downloadAsPdf(chartSelector, sliceName, true)(e.domEvent);
+        downloadAsPdf(
+          chartSelector,
+          sliceName,
+          true,
+          addWarningToast,
+        )(e.domEvent);
         setIsDropdownVisible(false);
         dispatch(
           logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_PDF, {
@@ -820,6 +835,8 @@ export const useExploreAdditionalActionsMenu = (
             slice?.slice_name ?? t('New chart'),
             true,
             theme,
+            undefined,
+            addWarningToast,
           )(e.domEvent);
           setIsDropdownVisible(false);
           dispatch(
@@ -841,6 +858,7 @@ export const useExploreAdditionalActionsMenu = (
         transparentKey: MENU_KEYS.EXPORT_ALL_PNG_TRANSPARENT,
         solidKey: MENU_KEYS.EXPORT_ALL_PNG_SOLID,
         pdfKey: MENU_KEYS.EXPORT_ALL_PDF,
+        addWarningToast,
       }),
       {
         key: MENU_KEYS.EXPORT_TO_XLSX,
@@ -941,6 +959,8 @@ export const useExploreAdditionalActionsMenu = (
             slice?.slice_name ?? t('New chart'),
             true,
             theme,
+            undefined,
+            addWarningToast,
           )(e.domEvent);
           setIsDropdownVisible(false);
           dispatch(
@@ -962,6 +982,7 @@ export const useExploreAdditionalActionsMenu = (
         transparentKey: MENU_KEYS.EXPORT_CURRENT_PNG_TRANSPARENT,
         solidKey: MENU_KEYS.EXPORT_CURRENT_PNG_SOLID,
         pdfKey: MENU_KEYS.EXPORT_CURRENT_PDF,
+        addWarningToast,
       }),
       {
         key: MENU_KEYS.EXPORT_CURRENT_XLSX,
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx
index 615b5a844fd..b776bc8690c 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx
@@ -319,6 +319,7 @@ const domEvent = {} as React.MouseEvent;
 const buildScreenshotItems = () => {
   const setIsDropdownVisible = jest.fn();
   const dispatch = jest.fn();
+  const addWarningToast = jest.fn();
   const items = getExportScreenshotMenuItems({
     chartSelector: CHART_SELECTOR,
     sliceName: SLICE_NAME,
@@ -330,8 +331,9 @@ const buildScreenshotItems = () => {
     transparentKey: 'export_png_transparent',
     solidKey: 'export_png_solid',
     pdfKey: 'export_pdf',
+    addWarningToast,
   }) as any[];
-  return { items, setIsDropdownVisible, dispatch };
+  return { items, setIsDropdownVisible, dispatch, addWarningToast };
 };
 
 test('getExportScreenshotMenuItems builds the PNG submenu and PDF item with the provided keys', () => {
@@ -346,7 +348,8 @@ test('getExportScreenshotMenuItems builds the PNG submenu and PDF item with the
 });
 
 test('getExportScreenshotMenuItems transparent option downloads a transparent PNG and dispatches a log event', () => {
-  const { items, setIsDropdownVisible, dispatch } = buildScreenshotItems();
+  const { items, setIsDropdownVisible, dispatch, addWarningToast } =
+    buildScreenshotItems();
 
   items[0].children[0].onClick({ domEvent });
 
@@ -356,13 +359,15 @@ test('getExportScreenshotMenuItems transparent option downloads a transparent PN
     true,
     expect.anything(),
     { format: 'png', backgroundType: 'transparent' },
+    addWarningToast,
   );
   expect(setIsDropdownVisible).toHaveBeenCalledWith(false);
   expect(dispatch).toHaveBeenCalledTimes(1);
 });
 
 test('getExportScreenshotMenuItems solid option downloads a solid PNG and dispatches a log event', () => {
-  const { items, setIsDropdownVisible, dispatch } = buildScreenshotItems();
+  const { items, setIsDropdownVisible, dispatch, addWarningToast } =
+    buildScreenshotItems();
 
   items[0].children[1].onClick({ domEvent });
 
@@ -372,13 +377,15 @@ test('getExportScreenshotMenuItems solid option downloads a solid PNG and dispat
     true,
     expect.anything(),
     { format: 'png', backgroundType: 'solid' },
+    addWarningToast,
   );
   expect(setIsDropdownVisible).toHaveBeenCalledWith(false);
   expect(dispatch).toHaveBeenCalledTimes(1);
 });
 
 test('getExportScreenshotMenuItems PDF option calls downloadAsPdf and dispatches a log event', () => {
-  const { items, setIsDropdownVisible, dispatch } = buildScreenshotItems();
+  const { items, setIsDropdownVisible, dispatch, addWarningToast } =
+    buildScreenshotItems();
 
   items[1].onClick({ domEvent });
 
@@ -386,6 +393,7 @@ test('getExportScreenshotMenuItems PDF option calls downloadAsPdf and dispatches
     CHART_SELECTOR,
     SLICE_NAME,
     true,
+    addWarningToast,
   );
   expect(setIsDropdownVisible).toHaveBeenCalledWith(false);
   expect(dispatch).toHaveBeenCalledTimes(1);
diff --git a/superset-frontend/src/utils/downloadAsImage.test.ts b/superset-frontend/src/utils/downloadAsImage.test.ts
index 1e48b57a6df..30d0cf5326c 100644
--- a/superset-frontend/src/utils/downloadAsImage.test.ts
+++ b/superset-frontend/src/utils/downloadAsImage.test.ts
@@ -18,7 +18,6 @@
  */
 import domToImage from 'dom-to-image-more';
 import { getInstanceByDom } from 'echarts/core';
-import { addWarningToast } from 'src/components/MessageToasts/actions';
 import downloadAsImageOptimized, {
   waitForStableScrollHeight,
 } from './downloadAsImage';
@@ -33,17 +32,15 @@ jest.mock('echarts/core', () => ({
   getInstanceByDom: jest.fn(),
 }));
 
-jest.mock('src/components/MessageToasts/actions', () => ({
-  addWarningToast: jest.fn(),
-}));
-
 jest.mock('@apache-superset/core/translation', () => ({
   t: (str: string) => str,
 }));
 
 const mockToJpeg = domToImage.toJpeg as jest.Mock;
 const mockToPng = domToImage.toPng as jest.Mock;
-const mockAddWarningToast = addWarningToast as jest.Mock;
+// Passed explicitly to downloadAsImageOptimized below, mirroring how a real
+// caller supplies an already-bound `useToasts()` callback.
+const mockAddWarningToast = jest.fn();
 const mockGetInstanceByDom = getInstanceByDom as jest.Mock;
 
 // document.fonts.ready is not implemented in jsdom; provide a resolved promise
@@ -203,7 +200,14 @@ test('waitForStableScrollHeight resolves if scrollHeight throws (element removed
 });
 
 test('shows warning toast when element is not found', async () => {
-  const handler = downloadAsImageOptimized('div', 'test');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'test',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   // closest() returning null simulates a selector that matches nothing
   await handler({ currentTarget: { closest: () => null } } as any);
 
@@ -217,7 +221,14 @@ test('shows "still loading" toast when grid has not yet rendered its first rows'
   const { container, agContainer, cleanup } = buildAgGridElement();
   attachMockApi(agContainer, { firstDataRendered: false });
 
-  const handler = downloadAsImageOptimized('div', 'My Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   await handler(syntheticEventFor(container));
 
   expect(mockAddWarningToast).toHaveBeenCalledWith(
@@ -233,7 +244,14 @@ test('switches to print layout, captures JPEG, and restores normal layout', asyn
   const { container, agContainer, cleanup } = buildAgGridElement();
   const api = attachMockApi(agContainer);
 
-  const handler = downloadAsImageOptimized('div', 'My Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await exportPromise;
@@ -255,7 +273,14 @@ test('restores normal layout in finally even when image capture throws', async (
   const { container, agContainer, cleanup } = buildAgGridElement();
   const api = attachMockApi(agContainer);
 
-  const handler = downloadAsImageOptimized('div', 'My Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await exportPromise;
@@ -275,7 +300,14 @@ test('still captures image when _agGridApi is absent (graceful degradation)', as
   // No API — only the first-data-rendered flag
   (agContainer as any)._agGridFirstDataRendered = true;
 
-  const handler = downloadAsImageOptimized('div', 'My Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await exportPromise;
@@ -311,7 +343,14 @@ test('resolves ag-cell min-height to row pixel height when content fits within i
     return Promise.resolve('data:image/jpeg;base64,test');
   });
 
-  const handler = downloadAsImageOptimized('div', 'My Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await exportPromise;
@@ -352,7 +391,14 @@ test('uses cell scrollHeight when it exceeds row offsetHeight (stale row heights
     return Promise.resolve('data:image/jpeg;base64,test');
   });
 
-  const handler = downloadAsImageOptimized('div', 'My Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await exportPromise;
@@ -387,7 +433,14 @@ test('derives image width from getColumnState by summing visible column pixel wi
     },
   );
 
-  const handler = downloadAsImageOptimized('div', 'My Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await exportPromise;
@@ -412,7 +465,14 @@ test('restores column pixel widths via applyColumnState with flex stripped after
   (api as any).getColumnState = jest.fn(() => savedState);
   (api as any).applyColumnState = jest.fn();
 
-  const handler = downloadAsImageOptimized('div', 'My Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await exportPromise;
@@ -442,7 +502,14 @@ test('restores original column state with flex in finally after capture', async
   (api as any).getColumnState = jest.fn(() => savedState);
   (api as any).applyColumnState = jest.fn();
 
-  const handler = downloadAsImageOptimized('div', 'My Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await exportPromise;
@@ -482,7 +549,14 @@ test('falls back to agRootWrapper.offsetWidth when getColumnState returns no vis
     },
   );
 
-  const handler = downloadAsImageOptimized('div', 'My Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await exportPromise;
@@ -513,7 +587,14 @@ test('restores ag-cell styles after capture even when toJpeg throws', async () =
   row.appendChild(cell);
   agRootWrapper.appendChild(row);
 
-  const handler = downloadAsImageOptimized('div', 'My Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await exportPromise;
@@ -532,7 +613,14 @@ test('calls resetRowHeights after print layout to force ag-grid to re-measure ro
   const api = attachMockApi(agContainer);
   (api as any).resetRowHeights = jest.fn();
 
-  const handler = downloadAsImageOptimized('div', 'My Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await exportPromise;
@@ -550,7 +638,14 @@ test('does not throw when resetRowHeights is absent from the api', async () => {
   const { container, agContainer, cleanup } = buildAgGridElement();
   attachMockApi(agContainer); // api has only setGridOption, no resetRowHeights
 
-  const handler = downloadAsImageOptimized('div', 'My Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await expect(exportPromise).resolves.toBeUndefined();
@@ -572,7 +667,14 @@ test('falls through to clone path for dashboard export with a single ag-grid cha
   dashboard.appendChild(agContainer);
   document.body.appendChild(dashboard);
 
-  const handler = downloadAsImageOptimized('.dashboard', 'My Dashboard', true);
+  const handler = downloadAsImageOptimized(
+    '.dashboard',
+    'My Dashboard',
+    true,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   await handler({ currentTarget: {} } as any);
 
   expect(mockToJpeg).toHaveBeenCalledWith(
@@ -599,7 +701,14 @@ test('falls through to clone path for dashboard export with multiple ag-grid cha
   }
   document.body.appendChild(dashboard);
 
-  const handler = downloadAsImageOptimized('.dashboard', 'My Dashboard', true);
+  const handler = downloadAsImageOptimized(
+    '.dashboard',
+    'My Dashboard',
+    true,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   await handler({ currentTarget: {} } as any);
 
   expect(mockToJpeg).toHaveBeenCalledWith(
@@ -615,7 +724,14 @@ test('captures JPEG for non-ag-grid elements via the clone path', async () => {
   const container = document.createElement('div');
   document.body.appendChild(container);
 
-  const handler = downloadAsImageOptimized('div', 'Bar Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'Bar Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   await handler(syntheticEventFor(container));
 
   expect(mockToJpeg).toHaveBeenCalledWith(
@@ -632,7 +748,14 @@ test('shows warning toast when clone capture throws', async () => {
   const container = document.createElement('div');
   document.body.appendChild(container);
 
-  const handler = downloadAsImageOptimized('div', 'Bar Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'Bar Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   await handler(syntheticEventFor(container));
 
   expect(mockAddWarningToast).toHaveBeenCalledWith(
@@ -648,7 +771,14 @@ test('ag-grid path uses theme colorBgContainer as background', async () => {
   attachMockApi(agContainer);
 
   const theme = { colorBgContainer: '#1a1a2e' } as any;
-  const handler = downloadAsImageOptimized('div', 'My Chart', false, theme);
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    false,
+    theme,
+    undefined,
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await exportPromise;
@@ -668,10 +798,14 @@ test('ag-grid path exports PNG (transparent) via toPng when format is png', asyn
   attachMockApi(agContainer);
 
   const theme = { colorBgContainer: '#1a1a2e' } as any;
-  const handler = downloadAsImageOptimized('div', 'My Chart', false, theme, {
-    format: 'png',
-    backgroundType: 'transparent',
-  });
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    false,
+    theme,
+    { format: 'png', backgroundType: 'transparent' },
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await exportPromise;
@@ -693,10 +827,14 @@ test('ag-grid path exports PNG (solid) via toPng using theme background', async
   attachMockApi(agContainer);
 
   const theme = { colorBgContainer: '#1a1a2e' } as any;
-  const handler = downloadAsImageOptimized('div', 'My Chart', false, theme, {
-    format: 'png',
-    backgroundType: 'solid',
-  });
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    false,
+    theme,
+    { format: 'png', backgroundType: 'solid' },
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await exportPromise;
@@ -716,7 +854,14 @@ test('ag-grid path falls back to white background when theme is absent', async (
   const { container, agContainer, cleanup } = buildAgGridElement();
   attachMockApi(agContainer);
 
-  const handler = downloadAsImageOptimized('div', 'My Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'My Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   const exportPromise = handler(syntheticEventFor(container));
   await jest.runAllTimersAsync();
   await exportPromise;
@@ -734,7 +879,14 @@ test('clone path falls back to white background when theme is absent', async ()
   const container = document.createElement('div');
   document.body.appendChild(container);
 
-  const handler = downloadAsImageOptimized('div', 'Bar Chart');
+  const handler = downloadAsImageOptimized(
+    'div',
+    'Bar Chart',
+    undefined,
+    undefined,
+    undefined,
+    mockAddWarningToast,
+  );
   await handler(syntheticEventFor(container));
 
   expect(mockToJpeg).toHaveBeenCalledWith(
@@ -795,6 +947,7 @@ test('re-renders an ECharts canvas at PNG_SCALE pixel ratio so the export is cri
     false,
     undefined,
     { format: 'png' },
+    mockAddWarningToast,
   );
   await handler(syntheticEventFor(container));
 
@@ -836,6 +989,7 @@ test('preserves a non-ECharts canvas at its on-screen resolution (no re-render)'
     false,
     undefined,
     { format: 'png' },
+    mockAddWarningToast,
   );
   await handler(syntheticEventFor(container));
 
@@ -876,6 +1030,7 @@ test('falls back to a 1:1 copy when the ECharts instance is gone (getInstanceByD
     false,
     undefined,
     { format: 'png' },
+    mockAddWarningToast,
   );
   await handler(syntheticEventFor(container));
 
@@ -920,6 +1075,7 @@ test('falls back to a 1:1 copy (and still exports) when renderToCanvas throws',
     false,
     undefined,
     { format: 'png' },
+    mockAddWarningToast,
   );
   await handler(syntheticEventFor(container));
 
@@ -959,6 +1115,7 @@ test('re-renders an ECharts host only once when it owns multiple canvas layers',
     false,
     undefined,
     { format: 'png' },
+    mockAddWarningToast,
   );
   await handler(syntheticEventFor(container));
 
diff --git a/superset-frontend/src/utils/downloadAsImage.tsx b/superset-frontend/src/utils/downloadAsImage.tsx
index 11d654072ca..623800bb1a6 100644
--- a/superset-frontend/src/utils/downloadAsImage.tsx
+++ b/superset-frontend/src/utils/downloadAsImage.tsx
@@ -21,7 +21,6 @@ import domToImage from 'dom-to-image-more';
 import { kebabCase } from 'lodash-es';
 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';
 
@@ -379,6 +378,10 @@ export default function downloadAsImageOptimized(
   isExactSelector = false,
   theme?: SupersetTheme,
   options: DownloadImageOptions = {},
+  // Bound via `useToasts()`/`bindActionCreators`, not the raw action creator
+  // from `actions.ts`: this module has no dispatch of its own, so an unbound
+  // creator would only build a Redux action object and never render a toast.
+  addWarningToast?: (message: string) => void,
 ) {
   const { format = 'jpeg', backgroundType = 'solid' } = options;
 
@@ -388,7 +391,7 @@ export default function downloadAsImageOptimized(
       : event.currentTarget.closest(selector);
 
     if (!elementToPrint) {
-      addWarningToast(
+      addWarningToast?.(
         t('Image download failed, please refresh and try again.'),
       );
       return;
@@ -433,7 +436,7 @@ export default function downloadAsImageOptimized(
       const isFirstDataRendered = agContainer._agGridFirstDataRendered === true;
 
       if (!isFirstDataRendered) {
-        addWarningToast(
+        addWarningToast?.(
           t('The chart is still loading. Please wait a moment and try again.'),
         );
         // This early return skips the capture, so restore virtualization here;
@@ -533,7 +536,7 @@ export default function downloadAsImageOptimized(
         link.click();
       } catch (error) {
         console.error('Creating image failed', error);
-        addWarningToast(
+        addWarningToast?.(
           t('Image download failed, please refresh and try again.'),
         );
       } finally {
@@ -614,7 +617,7 @@ export default function downloadAsImageOptimized(
       link.click();
     } catch (error) {
       console.error('Creating image failed', error);
-      addWarningToast(
+      addWarningToast?.(
         t('Image download failed, please refresh and try again.'),
       );
     } finally {
diff --git a/superset-frontend/src/utils/downloadAsPdf.test.ts b/superset-frontend/src/utils/downloadAsPdf.test.ts
new file mode 100644
index 00000000000..937f0701e6c
--- /dev/null
+++ b/superset-frontend/src/utils/downloadAsPdf.test.ts
@@ -0,0 +1,66 @@
+/**
+ * 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 downloadAsPdf from './downloadAsPdf';
+
+jest.mock('dom-to-pdf', () => ({
+  __esModule: true,
+  default: jest.fn(),
+}));
+
+jest.mock('src/utils/getBootstrapData', () => ({
+  __esModule: true,
+  default: jest.fn(() => ({ common: { pdf_compression_level: 'NONE' } })),
+}));
+
+jest.mock('@apache-superset/core/translation', () => ({
+  t: (str: string) => str,
+}));
+
+afterEach(() => {
+  jest.restoreAllMocks();
+});
+
+test('warns the user via the bound toast callback and returns early when the target element is not found', async () => {
+  jest.spyOn(document, 'querySelector').mockReturnValue(null);
+  const addWarningToast = jest.fn();
+  const domToPdf = jest.requireMock('dom-to-pdf').default as jest.Mock;
+
+  await downloadAsPdf(
+    '.non-existent-selector',
+    'test-file',
+    true,
+    addWarningToast,
+  )({} as any);
+
+  // Passed in already bound to dispatch (e.g. via `useToasts()`), so calling
+  // it directly is what actually renders the toast -- unlike the raw action
+  // creator, which only builds a Redux action object.
+  expect(addWarningToast).toHaveBeenCalledWith(
+    'PDF download failed, please refresh and try again.',
+  );
+  expect(domToPdf).not.toHaveBeenCalled();
+});
+
+test('does not throw when the target element is not found and no toast callback is provided', async () => {
+  jest.spyOn(document, 'querySelector').mockReturnValue(null);
+
+  await expect(
+    downloadAsPdf('.non-existent-selector', 'test-file', true)({} as any),
+  ).resolves.not.toThrow();
+});
diff --git a/superset-frontend/src/utils/downloadAsPdf.ts b/superset-frontend/src/utils/downloadAsPdf.ts
index 7bd452f43a9..27e898da25e 100644
--- a/superset-frontend/src/utils/downloadAsPdf.ts
+++ b/superset-frontend/src/utils/downloadAsPdf.ts
@@ -21,7 +21,6 @@ import domToPdf from 'dom-to-pdf';
 import { kebabCase } from 'lodash-es';
 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';
 
@@ -43,12 +42,16 @@ const generateFileStem = (description: string, date = new Date()) =>
  * @param description name or a short description of what is being printed.
  *   Value will be normalized, and a date as well as a file extension will be added.
  * @param isExactSelector if false, searches for the closest ancestor that matches selector.
+ * @param addWarningToast bound via `useToasts()`/`bindActionCreators`, not the raw
+ *   action creator from `actions.ts`: this module has no dispatch of its own, so an
+ *   unbound creator would only build a Redux action object and never render a toast.
  * @returns event handler
  */
 export default function downloadAsPdf(
   selector: string,
   description: string,
   isExactSelector = false,
+  addWarningToast?: (message: string) => void,
 ) {
   return async (event: SyntheticEvent) => {
     const elementToPrint = isExactSelector
@@ -56,9 +59,10 @@ export default function downloadAsPdf(
       : event.currentTarget.closest(selector);
 
     if (!elementToPrint) {
-      return addWarningToast(
+      addWarningToast?.(
         t('PDF download failed, please refresh and try again.'),
       );
+      return;
     }
 
     // Force any virtualized (unmounted) charts to render before capturing, so