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

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 8d9e357be fix(web): defer download URL revocation (#2536)
8d9e357be is described below

commit 8d9e357be136175b0cd607a130967ff35c69ee14
Author: yyqdbngt <[email protected]>
AuthorDate: Sat Aug 22 15:46:41 2026 +0800

    fix(web): defer download URL revocation (#2536)
    
    Signed-off-by: yyqdbngt <[email protected]>
---
 web/src/components/AlertRuleAssetList.tsx   | 13 ++----------
 web/src/components/GrafanaDashboardList.tsx | 16 +++------------
 web/src/pages/instance/dlq.tsx              |  9 ++------
 web/src/utils/download.test.ts              | 32 ++++++++++++++++++++++++++++-
 web/src/utils/download.ts                   |  9 +++++---
 5 files changed, 44 insertions(+), 35 deletions(-)

diff --git a/web/src/components/AlertRuleAssetList.tsx 
b/web/src/components/AlertRuleAssetList.tsx
index 9013ea8c8..330f16ddf 100644
--- a/web/src/components/AlertRuleAssetList.tsx
+++ b/web/src/components/AlertRuleAssetList.tsx
@@ -26,6 +26,7 @@ import {
   listAlertRuleAssets,
 } from '../services/alertRuleAssetService';
 import type { AlertRuleAssetInfo } from '../api/alertRuleAssets';
+import { downloadBlob } from '../utils/download';
 
 const { Text } = Typography;
 
@@ -107,21 +108,11 @@ export const AlertRuleAssetList: React.FC = () => {
     setViewLoading(false);
   };
 
-  const triggerDownload = (name: string, content: Blob | string) => {
-    const blob = typeof content === 'string' ? new Blob([content], { type: 
'text/yaml' }) : content;
-    const url = URL.createObjectURL(blob);
-    const a = document.createElement('a');
-    a.href = url;
-    a.download = `${name}.yaml`;
-    a.click();
-    URL.revokeObjectURL(url);
-  };
-
   const handleExport = async (info: AlertRuleAssetInfo) => {
     setExportingNames((current) => new Set(current).add(info.name));
     try {
       const blob = await exportAlertRuleAsset(info.name);
-      triggerDownload(info.name, blob);
+      downloadBlob(blob, `${info.name}.yaml`);
       message.success(t('alertAssets.exported'));
     } catch {
       message.error(t('alertAssets.exportFailed'));
diff --git a/web/src/components/GrafanaDashboardList.tsx 
b/web/src/components/GrafanaDashboardList.tsx
index e6300c48c..01850690e 100644
--- a/web/src/components/GrafanaDashboardList.tsx
+++ b/web/src/components/GrafanaDashboardList.tsx
@@ -27,6 +27,7 @@ import {
   listGrafanaDashboards,
 } from '../services/grafanaService';
 import type { GrafanaDashboardInfo } from '../api/metrics';
+import { downloadBlob } from '../utils/download';
 
 const { Paragraph, Text } = Typography;
 
@@ -103,22 +104,11 @@ export const GrafanaDashboardList: React.FC = () => {
     setViewLoading(false);
   };
 
-  const triggerDownload = (filename: string, content: Blob | string) => {
-    const blob =
-      typeof content === 'string' ? new Blob([content], { type: 
'application/json' }) : content;
-    const url = URL.createObjectURL(blob);
-    const a = document.createElement('a');
-    a.href = url;
-    a.download = filename;
-    a.click();
-    URL.revokeObjectURL(url);
-  };
-
   const handleExport = async (info: GrafanaDashboardInfo) => {
     setExportingUids((current) => new Set(current).add(info.uid));
     try {
       const blob = await exportGrafanaDashboard(info.uid);
-      triggerDownload(`${info.uid}.json`, blob);
+      downloadBlob(blob, `${info.uid}.json`);
       message.success(t('grafana.exported'));
     } catch {
       message.error(t('grafana.exportFailed'));
@@ -135,7 +125,7 @@ export const GrafanaDashboardList: React.FC = () => {
     setExportingAll(true);
     try {
       const download = await exportGrafanaDashboards();
-      triggerDownload(download.filename, download.blob);
+      downloadBlob(download.blob, download.filename);
       message.success(t('grafana.exportAllDone'));
     } catch {
       message.error(t('grafana.exportAllFailed'));
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 0a4fcfde6..ffbc55858 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -39,7 +39,7 @@ import { useLang } from '../../i18n/LangContext';
 import type { DLQGroup } from '../../api/message';
 import { exportDLQMessages, listDLQGroups, resendDLQ } from 
'../../services/messageService';
 import { useInstanceFilter } from '../../hooks/useInstanceFilter';
-import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
+import { buildCsv, downloadBlob, downloadCsv, type CsvColumn } from 
'../../utils/download';
 import { tableScrollX } from '../../utils/table';
 
 const { Text } = Typography;
@@ -272,12 +272,7 @@ const DLQPage = () => {
         startTime: exportRange[0].valueOf(),
         endTime: exportRange[1].valueOf(),
       });
-      const url = URL.createObjectURL(blob);
-      const link = document.createElement('a');
-      link.href = url;
-      link.download = `${group.groupName}-dlq-messages.json`;
-      link.click();
-      URL.revokeObjectURL(url);
+      downloadBlob(blob, `${group.groupName}-dlq-messages.json`);
       message.success(`已导出 ${group.groupName} 的死信消息(${blob.size} 字节)`);
     } catch (error) {
       message.error(getErrorMessage(error, '导出死信消息失败,请稍后重试'));
diff --git a/web/src/utils/download.test.ts b/web/src/utils/download.test.ts
index 11c2af54d..04ae3a066 100644
--- a/web/src/utils/download.test.ts
+++ b/web/src/utils/download.test.ts
@@ -38,11 +38,13 @@ describe('buildCsv', () => {
 
 describe('downloadBlob', () => {
   afterEach(() => {
+    vi.useRealTimers();
     document.body.innerHTML = '';
     vi.restoreAllMocks();
   });
 
-  it('clicks an attached temporary anchor and removes it after download', () 
=> {
+  it('defers object URL revocation until the browser can start the download', 
() => {
+    vi.useFakeTimers();
     const createObjectURL = vi.fn(() => 'blob:download');
     const revokeObjectURL = vi.fn();
     Object.defineProperty(URL, 'createObjectURL', {
@@ -68,6 +70,34 @@ describe('downloadBlob', () => {
     expect(createObjectURL).toHaveBeenCalledWith(blob);
     expect(clickSpy).toHaveBeenCalledTimes(1);
     
expect(document.querySelector('a[download="export.csv"]')).not.toBeInTheDocument();
+    expect(revokeObjectURL).not.toHaveBeenCalled();
+
+    vi.runAllTimers();
+
     expect(revokeObjectURL).toHaveBeenCalledWith('blob:download');
   });
+
+  it('still removes the anchor and schedules cleanup when the click fails', () 
=> {
+    vi.useFakeTimers();
+    const revokeObjectURL = vi.fn();
+    Object.defineProperty(URL, 'createObjectURL', {
+      writable: true,
+      value: vi.fn(() => 'blob:failed-download'),
+    });
+    Object.defineProperty(URL, 'revokeObjectURL', {
+      writable: true,
+      value: revokeObjectURL,
+    });
+    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {
+      throw new Error('download blocked');
+    });
+
+    expect(() => downloadBlob(new Blob(['content']), 
'export.csv')).toThrow('download blocked');
+    
expect(document.querySelector('a[download="export.csv"]')).not.toBeInTheDocument();
+    expect(revokeObjectURL).not.toHaveBeenCalled();
+
+    vi.runAllTimers();
+
+    expect(revokeObjectURL).toHaveBeenCalledWith('blob:failed-download');
+  });
 });
diff --git a/web/src/utils/download.ts b/web/src/utils/download.ts
index d54ca6380..61dba59f8 100644
--- a/web/src/utils/download.ts
+++ b/web/src/utils/download.ts
@@ -22,9 +22,12 @@ export const downloadBlob = (blob: Blob, filename: string) 
=> {
   anchor.download = filename;
   anchor.style.display = 'none';
   document.body.appendChild(anchor);
-  anchor.click();
-  anchor.remove();
-  URL.revokeObjectURL(url);
+  try {
+    anchor.click();
+  } finally {
+    anchor.remove();
+    window.setTimeout(() => URL.revokeObjectURL(url), 0);
+  }
 };
 
 export interface CsvColumn<T> {

Reply via email to