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> {