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