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

Reply via email to