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 531b2be06 [ISSUE #2891][ISSUE #2890][ISSUE #2888][ISSUE #2899][ISSUE
#2900][ISSUE #2901] fix(web): consolidate in-flight mutation guards and
lifecycle fixes (#2893)
531b2be06 is described below
commit 531b2be066a2f504637190e448170e5c746f4f1a
Author: shown <[email protected]>
AuthorDate: Fri Sep 4 14:14:11 2026 +0800
[ISSUE #2891][ISSUE #2890][ISSUE #2888][ISSUE #2899][ISSUE #2900][ISSUE
#2901] fix(web): consolidate in-flight mutation guards and lifecycle fixes
(#2893)
* [ISSUE #2891] fix(alerts): deduplicate delivery retries
Signed-off-by: yuluo-yx <[email protected]>
* [ISSUE #2890] fix(proxy): serialize address mutations
Signed-off-by: yuluo-yx <[email protected]>
* [ISSUE #2888] fix(dlq): deduplicate resend actions
Signed-off-by: yuluo-yx <[email protected]>
* [ISSUE #2899] Clear stale broker topology after load failure
Signed-off-by: yuluo-yx <[email protected]>
* [ISSUE #2900] Deduplicate Queue Browser requests
Signed-off-by: yuluo-yx <[email protected]>
* [ISSUE #2901] Preserve polling request ownership across rerenders
Signed-off-by: yuluo-yx <[email protected]>
---------
Signed-off-by: yuluo-yx <[email protected]>
---
web/src/components/QueueBrowser.tsx | 25 +++++++++----
web/src/components/__tests__/QueueBrowser.test.tsx | 37 ++++++++++++++++++-
web/src/hooks/useVisiblePolling.test.tsx | 34 ++++++++++++++++++
web/src/hooks/useVisiblePolling.ts | 15 ++++----
web/src/pages/instance/__tests__/DLQPage.test.tsx | 25 +++++++++++++
web/src/pages/instance/dlq.tsx | 7 ++++
.../__tests__/NotificationDeliveriesPage.test.tsx | 24 ++++++++++++-
web/src/pages/ops/notificationDeliveries.tsx | 11 +++++-
web/src/pages/studio/BrokerCluster.tsx | 1 +
web/src/pages/studio/Proxy.tsx | 8 +++++
.../pages/studio/__tests__/BrokerCluster.test.tsx | 42 ++++++++++++++++++++++
web/src/pages/studio/__tests__/Proxy.test.tsx | 18 ++++++++++
12 files changed, 231 insertions(+), 16 deletions(-)
diff --git a/web/src/components/QueueBrowser.tsx
b/web/src/components/QueueBrowser.tsx
index 9b9c5b005..4057fae22 100644
--- a/web/src/components/QueueBrowser.tsx
+++ b/web/src/components/QueueBrowser.tsx
@@ -59,9 +59,11 @@ export const useQueueBrowser = (instanceId?: string) => {
const [queues, setQueues] = useState<QueueOffset[]>([]);
const [loading, setLoading] = useState(false);
const [offsets, setOffsets] = useState<Record<string, number>>({});
- const [pulling, setPulling] = useState<string | null>(null);
+ const [pulling, setPulling] = useState<Set<string>>(() => new Set());
const [entries, setEntries] = useState<PulledEntry[]>([]);
const requestSeqRef = useRef(0);
+ const loadingRef = useRef(false);
+ const pullingRef = useRef(new Set<string>());
useEffect(() => {
const requestId = ++requestSeqRef.current;
@@ -70,13 +72,16 @@ export const useQueueBrowser = (instanceId?: string) => {
setQueues([]);
setOffsets({});
setEntries([]);
+ loadingRef.current = false;
setLoading(false);
- setPulling(null);
+ pullingRef.current.clear();
+ setPulling(new Set());
});
}, [instanceId, topic]);
const loadQueues = useCallback(async () => {
- if (!instanceId || !topic) return;
+ if (!instanceId || !topic || loadingRef.current) return;
+ loadingRef.current = true;
const requestId = ++requestSeqRef.current;
setLoading(true);
setQueues([]);
@@ -97,7 +102,10 @@ export const useQueueBrowser = (instanceId?: string) => {
message.error(err instanceof Error ? err.message : '加载队列信息失败');
}
} finally {
- if (requestId === requestSeqRef.current) setLoading(false);
+ if (requestId === requestSeqRef.current) {
+ loadingRef.current = false;
+ setLoading(false);
+ }
}
}, [instanceId, topic]);
@@ -105,8 +113,10 @@ export const useQueueBrowser = (instanceId?: string) => {
if (!instanceId || !topic) return;
const requestId = requestSeqRef.current;
const key = `${queue.brokerName}-${queue.queueId}`;
+ if (pullingRef.current.has(key)) return;
const offset = offsets[key] ?? queue.minOffset;
- setPulling(key);
+ pullingRef.current.add(key);
+ setPulling(new Set(pullingRef.current));
try {
const msg = await pullMessageAtOffset({
instanceId,
@@ -125,7 +135,8 @@ export const useQueueBrowser = (instanceId?: string) => {
message.error(err instanceof Error ? err.message : '拉取消息失败');
}
} finally {
- if (requestId === requestSeqRef.current) setPulling(null);
+ pullingRef.current.delete(key);
+ if (requestId === requestSeqRef.current) setPulling(new
Set(pullingRef.current));
}
};
@@ -272,7 +283,7 @@ export const QueueBrowserResults = ({ state }: { state:
QueueBrowserState }) =>
<Button
size="small"
type="primary"
- loading={state.pulling === key}
+ loading={state.pulling.has(key)}
onClick={() => void state.handlePull(record)}
>
查看
diff --git a/web/src/components/__tests__/QueueBrowser.test.tsx
b/web/src/components/__tests__/QueueBrowser.test.tsx
index 261a47411..008bff0e7 100644
--- a/web/src/components/__tests__/QueueBrowser.test.tsx
+++ b/web/src/components/__tests__/QueueBrowser.test.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { act, render, screen, waitFor } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { MessageRecord, QueueOffset } from '../../api/message';
@@ -85,6 +85,7 @@ function QueueBrowserProbe({ instanceId = 'instance-a' }: {
instanceId?: string
{state.entries.map((entry) => entry.message?.msgId ??
'empty').join(',')}
</output>
<output aria-label="loading">{String(state.loading)}</output>
+ <output aria-label="pulling">{state.pulling.size > 0 ? 'true' :
'false'}</output>
</div>
);
}
@@ -180,4 +181,38 @@ describe('QueueBrowser request ownership', () => {
expect(screen.getByLabelText('entries')).toHaveTextContent('');
expect(screen.queryByText('stale-message')).not.toBeInTheDocument();
});
+
+ it('deduplicates pulls for the same queue before loading state renders',
async () => {
+ const pull = createDeferred<MessageRecord | null>();
+ vi.mocked(getQueueOffsets).mockResolvedValue([queue('broker-a')]);
+ vi.mocked(pullMessageAtOffset).mockReturnValue(pull.promise);
+ const user = userEvent.setup();
+ render(<QueueBrowserProbe />);
+
+ await user.click(screen.getByRole('button', { name: 'topic-a' }));
+ await user.click(screen.getByRole('button', { name: 'load' }));
+ await waitFor(() =>
expect(screen.getByLabelText('queues')).toHaveTextContent('broker-a'));
+
+ const pullButton = screen.getByRole('button', { name: 'pull' });
+ fireEvent.click(pullButton);
+ fireEvent.click(pullButton);
+
+ expect(pullMessageAtOffset).toHaveBeenCalledTimes(1);
+ await act(async () => pull.resolve(messageRecord('message-a')));
+ });
+
+ it('deduplicates queue loads before loading state renders', async () => {
+ const queues = createDeferred<QueueOffset[]>();
+ vi.mocked(getQueueOffsets).mockReturnValue(queues.promise);
+ const user = userEvent.setup();
+ render(<QueueBrowserProbe />);
+
+ await user.click(screen.getByRole('button', { name: 'topic-a' }));
+ const loadButton = screen.getByRole('button', { name: 'load' });
+ fireEvent.click(loadButton);
+ fireEvent.click(loadButton);
+
+ expect(getQueueOffsets).toHaveBeenCalledTimes(1);
+ await act(async () => queues.resolve([queue('broker-a')]));
+ });
});
diff --git a/web/src/hooks/useVisiblePolling.test.tsx
b/web/src/hooks/useVisiblePolling.test.tsx
index 6682fe69b..a9b908bf5 100644
--- a/web/src/hooks/useVisiblePolling.test.tsx
+++ b/web/src/hooks/useVisiblePolling.test.tsx
@@ -128,4 +128,38 @@ describe('useVisiblePolling', () => {
});
expect(poll).toHaveBeenCalledTimes(1);
});
+
+ it('keeps the in-flight guard when the poll callback changes', async () => {
+ const first = deferred();
+ const oldPoll = vi.fn().mockReturnValue(first.promise);
+ const newPoll = vi.fn().mockResolvedValue(undefined);
+ const { rerender } = renderHook(({ poll }) => useVisiblePolling(true,
1_000, poll), {
+ initialProps: { poll: oldPoll },
+ });
+
+ await act(async () => {
+ vi.advanceTimersByTime(1_000);
+ await Promise.resolve();
+ });
+ rerender({ poll: newPoll });
+
+ await act(async () => {
+ document.dispatchEvent(new Event('visibilitychange'));
+ vi.advanceTimersByTime(1_000);
+ await Promise.resolve();
+ });
+ expect(newPoll).not.toHaveBeenCalled();
+
+ await act(async () => {
+ first.resolve();
+ await first.promise;
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(1_000);
+ await Promise.resolve();
+ });
+ expect(newPoll).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/web/src/hooks/useVisiblePolling.ts
b/web/src/hooks/useVisiblePolling.ts
index bcdc2cd2f..f1b544cd3 100644
--- a/web/src/hooks/useVisiblePolling.ts
+++ b/web/src/hooks/useVisiblePolling.ts
@@ -15,27 +15,30 @@
* limitations under the License.
*/
-import { useEffect } from 'react';
+import { useEffect, useRef } from 'react';
export function useVisiblePolling(
enabled: boolean,
intervalMs: number,
poll: () => void | Promise<void>,
): void {
+ const pollInFlightRef = useRef<Promise<void> | null>(null);
+
useEffect(() => {
if (!enabled) return;
- let pollInFlight = false;
const pollWhenVisible = () => {
- if (document.visibilityState !== 'visible' || pollInFlight) return;
+ if (document.visibilityState !== 'visible' || pollInFlightRef.current)
return;
- pollInFlight = true;
- void Promise.resolve()
+ const inFlight = Promise.resolve()
.then(poll)
.catch(() => undefined)
.finally(() => {
- pollInFlight = false;
+ if (pollInFlightRef.current === inFlight) {
+ pollInFlightRef.current = null;
+ }
});
+ pollInFlightRef.current = inFlight;
};
const intervalId = window.setInterval(pollWhenVisible, intervalMs);
document.addEventListener('visibilitychange', pollWhenVisible);
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index 57aa6aba0..ed15295ed 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -396,6 +396,31 @@ describe('DLQ page', () => {
expect(await screen.findByText('DLQ provider is not
configured')).toBeInTheDocument();
});
+ it('submits one resend when confirm is clicked twice before rendering',
async () => {
+ let resolveResend!: (result: DLQResendResult) => void;
+ vi.mocked(messageService.resendDLQ).mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveResend = resolve;
+ }),
+ );
+ const user = userEvent.setup();
+ renderWithProviders(<DLQPage />);
+
+ const row = (await screen.findByText('cg-order')).closest('tr');
+ if (!row) throw new Error('DLQ group row not found');
+ await user.click(within(row).getByRole('button', { name: '重投消息' }));
+ await user.type(screen.getByPlaceholderText('输入目标 Topic 名称'),
'orders-retry');
+ const confirm = screen.getByRole('button', { name: '确认重投' });
+ act(() => {
+ confirm.click();
+ confirm.click();
+ });
+
+ expect(messageService.resendDLQ).toHaveBeenCalledTimes(1);
+ await act(async () => resolveResend({ matched: 7, resent: 7, failed: 0,
outcome: 'SUCCESS' }));
+ });
+
it('warns when DLQ resend scans only part of the available queues', async ()
=> {
vi.mocked(messageService.resendDLQ).mockResolvedValue({
matched: 3,
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index f97ebda50..3476cb666 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -143,6 +143,7 @@ const DLQPage = () => {
const detailRequestIdRef = useRef(0);
const retryRequestIdRef = useRef(0);
const groupRequestIdRef = useRef(0);
+ const resendInFlightRef = useRef(false);
useEffect(
() => () => {
@@ -245,7 +246,9 @@ const DLQPage = () => {
return;
}
if (!retryGroup || !selectedInstanceId) return;
+ if (resendInFlightRef.current) return;
+ resendInFlightRef.current = true;
const requestId = retryRequestIdRef.current + 1;
retryRequestIdRef.current = requestId;
const groupName = retryGroup.groupName;
@@ -279,6 +282,7 @@ const DLQPage = () => {
setRetryError(getErrorMessage(error, DEFAULT_RETRY_ERROR));
}
} finally {
+ resendInFlightRef.current = false;
if (retryRequestIdRef.current === requestId) {
setRetrySubmitting(false);
}
@@ -353,6 +357,8 @@ const DLQPage = () => {
const resendSelectedMessages = async (msgIds: string[]) => {
if (!selectedInstanceId || !detailGroup || msgIds.length === 0) return;
+ if (resendInFlightRef.current) return;
+ resendInFlightRef.current = true;
setDetailResending(true);
setDetailError(null);
try {
@@ -373,6 +379,7 @@ const DLQPage = () => {
} catch (error) {
setDetailError(getErrorMessage(error, '重发死信消息失败,请稍后重试'));
} finally {
+ resendInFlightRef.current = false;
setDetailResending(false);
}
};
diff --git a/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
b/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
index 3ec320a68..e5c590c11 100644
--- a/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
+++ b/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
@@ -5,7 +5,7 @@
* The ASF licenses this file to You under the Apache License, Version 2.0.
*/
import { App } from 'antd';
-import { render, screen, waitFor } from '@testing-library/react';
+import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { LangProvider } from '../../../i18n/LangContext';
@@ -83,6 +83,28 @@ describe('NotificationDeliveriesPage', () => {
await waitFor(() =>
expect(listAlertDeliveriesPage).toHaveBeenCalledTimes(2));
});
+ it('queues one retry when the action is clicked twice before rendering',
async () => {
+ const retry = deferred<void>();
+ vi.mocked(retryAlertDelivery).mockImplementation(() => retry.promise);
+ render(
+ <App>
+ <LangProvider>
+ <NotificationDeliveriesPage />
+ </LangProvider>
+ </App>,
+ );
+
+ await screen.findByText('Broker disk usage');
+ const retryButton = screen.getByRole('button', { name: '重新投递' });
+ act(() => {
+ retryButton.click();
+ retryButton.click();
+ });
+
+ expect(retryAlertDelivery).toHaveBeenCalledTimes(1);
+ retry.resolve();
+ });
+
it('refreshes a completed retry with the latest filters', async () => {
const retry = deferred<void>();
vi.mocked(retryAlertDelivery).mockImplementation(() => retry.promise);
diff --git a/web/src/pages/ops/notificationDeliveries.tsx
b/web/src/pages/ops/notificationDeliveries.tsx
index 2cd308206..952883a04 100644
--- a/web/src/pages/ops/notificationDeliveries.tsx
+++ b/web/src/pages/ops/notificationDeliveries.tsx
@@ -4,7 +4,7 @@
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0.
*/
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import {
Button,
Card,
@@ -55,6 +55,8 @@ const NotificationDeliveriesPage = () => {
const [selectedDelivery, setSelectedDelivery] =
useState<NotificationDeliveryRecord>();
const [retryingIds, setRetryingIds] = useState<Set<number>>(() => new Set());
const [retryingVisible, setRetryingVisible] = useState(false);
+ const retryingIdsInFlight = useRef(new Set<number>());
+ const retryingVisibleInFlight = useRef(false);
const [refreshNonce, setRefreshNonce] = useState(0);
const refresh = () => {
@@ -63,6 +65,8 @@ const NotificationDeliveriesPage = () => {
};
const retryDelivery = async (record: NotificationDeliveryRecord) => {
+ if (retryingVisibleInFlight.current ||
retryingIdsInFlight.current.has(record.id)) return;
+ retryingIdsInFlight.current.add(record.id);
setRetryingIds((current) => new Set(current).add(record.id));
try {
await retryAlertDelivery(record.id);
@@ -76,6 +80,7 @@ const NotificationDeliveriesPage = () => {
} catch {
message.error(t('deliveries.retryFailed'));
} finally {
+ retryingIdsInFlight.current.delete(record.id);
setRetryingIds((current) => {
const next = new Set(current);
next.delete(record.id);
@@ -87,6 +92,9 @@ const NotificationDeliveriesPage = () => {
const retryVisibleFailures = async () => {
const ids = items.filter((item) => item.status === 'FAILED').map((item) =>
item.id);
if (ids.length === 0) return;
+ if (retryingVisibleInFlight.current || ids.some((id) =>
retryingIdsInFlight.current.has(id)))
+ return;
+ retryingVisibleInFlight.current = true;
setRetryingVisible(true);
try {
const result = await retryAlertDeliveries(ids);
@@ -101,6 +109,7 @@ const NotificationDeliveriesPage = () => {
} catch {
message.error(t('deliveries.bulkRetryFailed'));
} finally {
+ retryingVisibleInFlight.current = false;
setRetryingVisible(false);
}
};
diff --git a/web/src/pages/studio/BrokerCluster.tsx
b/web/src/pages/studio/BrokerCluster.tsx
index 6b7ccd4a1..d592e7388 100644
--- a/web/src/pages/studio/BrokerCluster.tsx
+++ b/web/src/pages/studio/BrokerCluster.tsx
@@ -217,6 +217,7 @@ const BrokerClusterPage = () => {
setProxyData(mapped.proxies);
} catch {
if (!mountedRef.current || requestId !== loadRequestId.current) return;
+ clearData();
message.error(t('common.refreshFailed'));
} finally {
if (mountedRef.current && requestId === loadRequestId.current) {
diff --git a/web/src/pages/studio/Proxy.tsx b/web/src/pages/studio/Proxy.tsx
index 5014517d8..04dc6d4d5 100644
--- a/web/src/pages/studio/Proxy.tsx
+++ b/web/src/pages/studio/Proxy.tsx
@@ -78,6 +78,7 @@ const ProxyPage: React.FC = () => {
const [newProxyAddress, setNewProxyAddress] = useState('');
const [nodeFilter, setNodeFilter] = useState('');
const [addressMutationLoading, setAddressMutationLoading] = useState(false);
+ const addressMutationInFlight = useRef(false);
const [removingProxyAddress, setRemovingProxyAddress] = useState<string |
null>(null);
const [clusterId, setClusterId] = useState<string>(
readLocalStorage('clusterId') || 'DefaultCluster',
@@ -176,6 +177,7 @@ const ProxyPage: React.FC = () => {
};
const handleRefresh = async () => {
+ if (addressMutationInFlight.current) return;
if (await loadProxyNodes()) {
message.success(t('common.refreshSuccess'));
}
@@ -189,11 +191,13 @@ const ProxyPage: React.FC = () => {
};
const handleAddProxyAddress = async () => {
+ if (addressMutationInFlight.current) return;
const addr = newProxyAddress.trim();
if (!addr) {
message.warning(t('proxy.addressRequired'));
return;
}
+ addressMutationInFlight.current = true;
const requestId = ++loadRequestId.current;
setAddressMutationLoading(true);
setLoading(true);
@@ -208,6 +212,7 @@ const ProxyPage: React.FC = () => {
message.error(t('proxy.addAddressFailed'));
}
} finally {
+ addressMutationInFlight.current = false;
if (requestId === loadRequestId.current) {
setAddressMutationLoading(false);
setLoading(false);
@@ -216,6 +221,8 @@ const ProxyPage: React.FC = () => {
};
const handleRemoveProxyAddress = async (addr: string) => {
+ if (addressMutationInFlight.current) return;
+ addressMutationInFlight.current = true;
const requestId = ++loadRequestId.current;
setRemovingProxyAddress(addr);
setLoading(true);
@@ -233,6 +240,7 @@ const ProxyPage: React.FC = () => {
message.error(t('proxy.removeAddressFailed'));
}
} finally {
+ addressMutationInFlight.current = false;
if (requestId === loadRequestId.current) {
setRemovingProxyAddress(null);
setLoading(false);
diff --git a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
index 4125fd8a8..21f19ba64 100644
--- a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
+++ b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
@@ -265,6 +265,48 @@ describe('BrokerCluster Page', () => {
expect(screen.queryByText('proxy-a')).not.toBeInTheDocument();
});
+ it('clears topology from the previous instance when the next instance fails
to load', async () => {
+ vi.mocked(listInstances).mockResolvedValue([
+ {
+ id: 1,
+ name: 'instance-1',
+ remark: '',
+ type: 'DIRECT',
+ endpoint: '10.0.1.20:9876',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ gmtCreate: '',
+ gmtModified: '',
+ },
+ {
+ id: 2,
+ name: 'instance-2',
+ remark: '',
+ type: 'DIRECT',
+ endpoint: '10.0.2.20:9876',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ gmtCreate: '',
+ gmtModified: '',
+ },
+ ]);
+ vi.mocked(listClusters)
+ .mockResolvedValueOnce(clusterFixture)
+ .mockRejectedValueOnce(new Error('instance-2 unavailable'));
+ const user = userEvent.setup();
+ renderWithProviders(<BrokerCluster />);
+ await screen.findByText('broker-api-a');
+
+ await user.click(screen.getByRole('combobox', { name: '选择实例' }));
+ await user.click(
+ await screen.findByText('instance-2', { selector:
'.ant-select-item-option-content' }),
+ );
+
+ await waitFor(() =>
expect(listClusters).toHaveBeenLastCalledWith('instance-2'));
+ await waitFor(() =>
expect(screen.queryByText('broker-api-a')).not.toBeInTheDocument());
+ expect(screen.getByRole('button', { name: '导出' })).toBeDisabled();
+ });
+
it('polls only while live refresh is enabled and the document is visible',
async () => {
const visibilityState = vi.spyOn(document, 'visibilityState',
'get').mockReturnValue('hidden');
renderWithProviders(<BrokerCluster />);
diff --git a/web/src/pages/studio/__tests__/Proxy.test.tsx
b/web/src/pages/studio/__tests__/Proxy.test.tsx
index 4ac5d3a8a..fc8d318be 100644
--- a/web/src/pages/studio/__tests__/Proxy.test.tsx
+++ b/web/src/pages/studio/__tests__/Proxy.test.tsx
@@ -208,6 +208,24 @@ describe('ProxyPage', () => {
expect(await screen.findByText('请输入 Proxy 地址')).toBeInTheDocument();
});
+ it('submits one address mutation when add is clicked twice before
rendering', async () => {
+ const mutation = createDeferred<typeof proxyHome>();
+ vi.mocked(addProxyAddress).mockImplementation(() => mutation.promise);
+ const user = userEvent.setup();
+ renderPage();
+ await screen.findByText('127.0.0.1:8081');
+
+ await user.type(screen.getByLabelText('Proxy 地址'), '10.0.0.10:8081');
+ const addButton = screen.getByRole('button', { name: '新增' });
+ act(() => {
+ addButton.click();
+ addButton.click();
+ });
+
+ expect(addProxyAddress).toHaveBeenCalledTimes(1);
+ mutation.resolve(proxyHome);
+ });
+
it('removes a Proxy address and applies the updated address list', async ()
=> {
const user = userEvent.setup();
vi.mocked(queryProxyHomePage).mockResolvedValueOnce({