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 b35310b86 fix(cluster): block duplicate NameServer registry submits 
while one is in flight (#4794)
b35310b86 is described below

commit b35310b86f7e58af13b3b0e3e1f8117ad8f0405e
Author: 烤化の初雪 <[email protected]>
AuthorDate: Thu Sep 24 18:16:42 2026 +0800

    fix(cluster): block duplicate NameServer registry submits while one is in 
flight (#4794)
    
    test(cluster): pin that a rejected registry create releases the submit guard
    
    The failure path releases nsSubmittingRef in finally so a rejected
    create can be retried immediately, exactly as the PR description
    promises: reject the first deferred request, confirm again, and the
    retry reaches createNameserverRegistry with the same payload.
    
    fix(cluster): block duplicate NameServer registry submits while one is in 
flight
    
    The registry create/edit modal kept its OK button enabled for the whole
    request: handleNsSubmit had no in-flight guard and the modal had no
    confirmLoading, unlike the sibling connect and config modals. A second
    confirm click while the create was on the wire re-validated the form
    and posted the same registry entry again, producing duplicate
    NameServer registry rows. Guard the handler with an in-flight ref and
    wire confirmLoading so the button also shows the request state.
---
 .../pages/cluster/__tests__/ClusterPage.test.tsx   | 74 ++++++++++++++++++++++
 web/src/pages/cluster/index.tsx                    | 11 ++++
 2 files changed, 85 insertions(+)

diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx 
b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index fdef6239b..83a07623d 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -1419,4 +1419,78 @@ describe('Cluster page', () => {
 
     expect(screen.getByText('502')).toBeInTheDocument();
   });
+
+  it('ignores extra confirm clicks while a NameServer create is in flight', 
async () => {
+    vi.useRealTimers();
+    // The modal stays open until the create resolves, and the OK button has 
no in-flight guard
+    // otherwise: a second click while the request is on the wire would POST 
the same registry
+    // entry twice.
+    const user = userEvent.setup();
+    const create = deferred<unknown>();
+    clusterServiceMocks.createNameserverRegistry.mockImplementationOnce(
+      () => create.promise as Promise<never>,
+    );
+    renderWithProviders(<ClusterPage />);
+    await user.click(screen.getByRole('tab', { name: /NameServer 管理/ }));
+    expect(await 
screen.findByText('rocketmq1-nameserver:9876')).toBeInTheDocument();
+
+    fireEvent.click(screen.getByRole('button', { name: /新建 NameServer/ }));
+    await act(async () => {
+      await Promise.resolve();
+    });
+    // In test env rc-dialog assigns every modal the same ariaId ("test-id"), 
so accessible
+    // names of simultaneous dialogs collide and role queries are unreliable; 
locate the modal
+    // by its title text instead.
+    const nsModalTitle = await screen.findByText(
+      (content, element) =>
+        element?.className === 'ant-modal-title' && content === '新建 
NameServer',
+      {},
+      { timeout: 5000 },
+    );
+    const dialog = nsModalTitle.closest('.ant-modal') as HTMLElement;
+    await user.type(within(dialog).getByLabelText('名称'), 'rocketmq9');
+    await user.type(within(dialog).getByLabelText('NameServer 地址'), 
'rocketmq9-nameserver:9876');
+
+    const confirmButton = within(dialog).getByRole('button', { name: /确\s*认/ 
});
+    fireEvent.click(confirmButton);
+    await waitFor(() =>
+      
expect(clusterServiceMocks.createNameserverRegistry).toHaveBeenCalledTimes(1),
+    );
+
+    fireEvent.click(confirmButton);
+    fireEvent.click(confirmButton);
+    // Flush the microtasks the extra handlers are waiting on: on unguarded 
code the second
+    // click posts the same entry again once its validateFields settles.
+    await act(async () => {
+      await Promise.resolve();
+      await Promise.resolve();
+    });
+    
expect(clusterServiceMocks.createNameserverRegistry).toHaveBeenCalledTimes(1);
+
+    // The failure path must release the guard: a rejected create lets the 
next confirm retry.
+    create.reject(new Error('registry unavailable'));
+    await waitFor(() =>
+      
expect(clusterServiceMocks.createNameserverRegistry).toHaveBeenCalledTimes(1),
+    );
+    clusterServiceMocks.createNameserverRegistry.mockResolvedValueOnce({
+      id: 9,
+      name: 'rocketmq9',
+      namesrvAddr: 'rocketmq9-nameserver:9876',
+    } as never);
+    // The guard releases once the catch path settles; poll the click until 
the retry lands.
+    for (
+      let attempt = 0;
+      attempt < 20 && 
clusterServiceMocks.createNameserverRegistry.mock.calls.length < 2;
+      attempt++
+    ) {
+      fireEvent.click(confirmButton);
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 50));
+      });
+    }
+    
expect(clusterServiceMocks.createNameserverRegistry).toHaveBeenCalledTimes(2);
+    
expect(clusterServiceMocks.createNameserverRegistry).toHaveBeenLastCalledWith(
+      expect.objectContaining({ name: 'rocketmq9', namesrvAddr: 
'rocketmq9-nameserver:9876' }),
+    );
+  });
 });
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index c768612f5..d3f9e7ba9 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -285,9 +285,14 @@ const ClusterPage = () => {
   const [nsCreateModalOpen, setNsCreateModalOpen] = useState(false);
   const [nsModalMode, setNsModalMode] = useState<'create' | 'edit'>('create');
   const [nsEditId, setNsEditId] = useState<number | null>(null);
+  const [nsSubmitting, setNsSubmitting] = useState(false);
+  const nsSubmittingRef = useRef(false);
   const [nsCreateForm] = Form.useForm();
 
   const handleNsSubmit = useCallback(async () => {
+    // The dialog stays open until the request resolves, so without an 
in-flight guard a second
+    // confirm click while the request is on the wire would POST the same 
registry entry again.
+    if (nsSubmittingRef.current) return;
     let values: Record<string, string>;
     try {
       values = await nsCreateForm.validateFields();
@@ -301,6 +306,8 @@ const ClusterPage = () => {
       k8sId: values.k8sId || undefined,
       description: values.description || undefined,
     };
+    nsSubmittingRef.current = true;
+    setNsSubmitting(true);
     try {
       if (nsModalMode === 'edit' && nsEditId !== null) {
         await updateNameserverRegistry({ id: nsEditId, ...payload });
@@ -314,6 +321,9 @@ const ClusterPage = () => {
       await loadNsRegistry();
     } catch {
       message.error(t('cluster.nsOperationFailed'));
+    } finally {
+      nsSubmittingRef.current = false;
+      setNsSubmitting(false);
     }
   }, [loadNsRegistry, nsCreateForm, nsEditId, nsModalMode, t]);
 
@@ -1903,6 +1913,7 @@ const ClusterPage = () => {
         open={nsCreateModalOpen}
         onCancel={() => setNsCreateModalOpen(false)}
         onOk={() => void handleNsSubmit()}
+        confirmLoading={nsSubmitting}
         okText={t('common.confirm')}
         cancelText={t('common.cancel')}
         destroyOnHidden

Reply via email to