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 93d91935 fix: prevent stale message requests from overwriting current
state (#735)
93d91935 is described below
commit 93d9193562cac526b7c69b6e5312ffd00dd145df
Author: Rui <[email protected]>
AuthorDate: Mon Aug 3 11:16:11 2026 +0800
fix: prevent stale message requests from overwriting current state (#735)
---
.../__tests__/MessagePageAsyncState.test.tsx | 264 +++++++++++++++++++++
web/src/pages/instance/message.tsx | 49 +++-
2 files changed, 305 insertions(+), 8 deletions(-)
diff --git a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
new file mode 100644
index 00000000..e51b716c
--- /dev/null
+++ b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
@@ -0,0 +1,264 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { App, ConfigProvider, message } from 'antd';
+import { act, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
+import type { MessageRecord, TraceRecord } from '../../../api/message';
+import { LangProvider } from '../../../i18n/LangContext';
+import MessagePage from '../message';
+
+const serviceMocks = vi.hoisted(() => ({
+ getMessageTrace: vi.fn(),
+ queryMessages: vi.fn(),
+}));
+
+vi.mock('../../../services/messageService', () => serviceMocks);
+
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+const createDeferred = <T,>() => {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+};
+
+const createMessage = (msgId: string): MessageRecord => ({
+ msgId,
+ topic: `topic-${msgId}`,
+ tag: 'tag',
+ key: `key-${msgId}`,
+ body: '{}',
+ storeTime: '2026-07-31T00:00:00Z',
+ bornHost: '127.0.0.1:1000',
+ storeHost: '127.0.0.1:10911',
+ properties: {},
+ size: 2,
+});
+
+const createTrace = (title: string): TraceRecord => ({
+ nodes: [
+ {
+ title,
+ timestamp: '2026-07-31T00:00:00Z',
+ costTime: 1,
+ status: 'finish',
+ description: `${title} description`,
+ },
+ ],
+ consumerStatus: [],
+});
+
+const renderPage = () =>
+ render(
+ <ConfigProvider theme={{ token: { motion: false } }}>
+ <App>
+ <LangProvider>
+ <MessagePage />
+ </LangProvider>
+ </App>
+ </ConfigProvider>,
+ );
+
+describe('MessagePage async request ownership', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ serviceMocks.getMessageTrace.mockResolvedValue(null);
+ vi.spyOn(message, 'success').mockImplementation(vi.fn());
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('does not restore query results after the user resets an in-flight
query', async () => {
+ const query = createDeferred<MessageRecord[]>();
+ serviceMocks.queryMessages.mockReturnValue(query.promise);
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(screen.getByRole('button', { name: /查询/ }));
+ await waitFor(() =>
expect(serviceMocks.queryMessages).toHaveBeenCalledTimes(1));
+ await user.click(screen.getByRole('button', { name: /重置/ }));
+
+ await act(async () => {
+ query.resolve([createMessage('late-after-reset')]);
+ });
+
+ expect(screen.queryByText('late-after-reset')).not.toBeInTheDocument();
+ });
+
+ it('keeps the latest query loading and ignores an earlier query result',
async () => {
+ const firstQuery = createDeferred<MessageRecord[]>();
+ const secondQuery = createDeferred<MessageRecord[]>();
+ serviceMocks.queryMessages
+ .mockReturnValueOnce(firstQuery.promise)
+ .mockReturnValueOnce(secondQuery.promise);
+ const user = userEvent.setup();
+ renderPage();
+
+ const queryButton = screen.getByRole('button', { name: /查询/ });
+ await user.click(queryButton);
+ await user.click(queryButton);
+ await waitFor(() =>
expect(serviceMocks.queryMessages).toHaveBeenCalledTimes(2));
+
+ await act(async () => {
+ firstQuery.resolve([createMessage('stale-first-query')]);
+ });
+
+ expect(screen.queryByText('stale-first-query')).not.toBeInTheDocument();
+ expect(document.querySelector('.ant-table-wrapper
.ant-spin-spinning')).toBeInTheDocument();
+
+ await act(async () => {
+ secondQuery.resolve([createMessage('latest-second-query')]);
+ });
+
+ expect(await screen.findByText('latest-second-query')).toBeInTheDocument();
+ await waitFor(() =>
+ expect(
+ document.querySelector('.ant-table-wrapper .ant-spin-spinning'),
+ ).not.toBeInTheDocument(),
+ );
+ });
+
+ it.each(['resolve', 'reject'] as const)(
+ 'invalidates a trace request when the detail closes before a late %s',
+ async (settlement) => {
+ const trace = createDeferred<TraceRecord | null>();
+
serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
+ serviceMocks.getMessageTrace.mockReturnValue(trace.promise);
+ const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(screen.getByRole('button', { name: /查询/ }));
+ const row = await screen.findByRole('row', { name: /message-a/ });
+ await user.click(within(row).getByRole('button', { name: /轨迹/ }));
+ const dialog = await screen.findByRole('dialog', { name: '消息详情' });
+ expect(within(dialog).getByText('正在加载轨迹数据…')).toBeInTheDocument();
+ await user.click(within(dialog).getByRole('button', { name: /关\s*闭/ }));
+ await waitFor(() =>
+ expect(screen.queryByRole('dialog', { name: '消息详情'
})).not.toBeInTheDocument(),
+ );
+
+ await act(async () => {
+ if (settlement === 'resolve') {
+ trace.resolve(createTrace('late-closed-trace'));
+ } else {
+ trace.reject(new Error('late trace failure'));
+ }
+ });
+
+ expect(screen.queryByText('late-closed-trace')).not.toBeInTheDocument();
+ expect(screen.queryByText('正在加载轨迹数据…')).not.toBeInTheDocument();
+ expect(errorSpy).not.toHaveBeenCalled();
+ },
+ );
+
+ it('keeps the current trace loading when an earlier trace finishes first',
async () => {
+ const firstTrace = createDeferred<TraceRecord | null>();
+ const secondTrace = createDeferred<TraceRecord | null>();
+ serviceMocks.queryMessages.mockResolvedValue([
+ createMessage('message-a'),
+ createMessage('message-b'),
+ ]);
+ serviceMocks.getMessageTrace.mockImplementation((msgId: string) =>
+ msgId === 'message-a' ? firstTrace.promise : secondTrace.promise,
+ );
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(screen.getByRole('button', { name: /查询/ }));
+ const firstRow = await screen.findByRole('row', { name: /message-a/ });
+ await user.click(within(firstRow).getByRole('button', { name: /轨迹/ }));
+ const firstDialog = await screen.findByRole('dialog', { name: '消息详情' });
+ await user.click(within(firstDialog).getByRole('button', { name: /关\s*闭/
}));
+
+ const secondRow = screen.getByRole('row', { name: /message-b/ });
+ await user.click(within(secondRow).getByRole('button', { name: /轨迹/ }));
+ const secondDialog = await screen.findByRole('dialog', { name: '消息详情' });
+ expect(within(secondDialog).getByText('正在加载轨迹数据…')).toBeInTheDocument();
+
+ await act(async () => {
+ firstTrace.resolve(createTrace('message-a stale trace'));
+ });
+
+ expect(within(secondDialog).getByText('正在加载轨迹数据…')).toBeInTheDocument();
+ expect(screen.queryByText('message-a stale
trace')).not.toBeInTheDocument();
+
+ await act(async () => {
+ secondTrace.resolve(createTrace('message-b trace'));
+ });
+
+ expect(await screen.findByText('message-b trace')).toBeInTheDocument();
+ expect(screen.queryByText('正在加载轨迹数据…')).not.toBeInTheDocument();
+ });
+
+ it('does not display a late trace from a previously closed message detail',
async () => {
+ const firstTrace = createDeferred<TraceRecord | null>();
+ const secondTrace = createDeferred<TraceRecord | null>();
+ serviceMocks.queryMessages.mockResolvedValue([
+ createMessage('message-a'),
+ createMessage('message-b'),
+ ]);
+ serviceMocks.getMessageTrace.mockImplementation((msgId: string) =>
+ msgId === 'message-a' ? firstTrace.promise : secondTrace.promise,
+ );
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(screen.getByRole('button', { name: /查询/ }));
+ const firstRow = await screen.findByRole('row', { name: /message-a/ });
+ await user.click(within(firstRow).getByRole('button', { name: /轨迹/ }));
+ const firstDialog = await screen.findByRole('dialog', { name: '消息详情' });
+ await user.click(within(firstDialog).getByRole('button', { name: /关\s*闭/
}));
+
+ const secondRow = screen.getByRole('row', { name: /message-b/ });
+ await user.click(within(secondRow).getByRole('button', { name: /轨迹/ }));
+
+ await act(async () => {
+ secondTrace.resolve(createTrace('message-b trace'));
+ });
+ expect(await screen.findByText('message-b trace')).toBeInTheDocument();
+
+ await act(async () => {
+ firstTrace.resolve(createTrace('message-a stale trace'));
+ });
+
+ expect(screen.getByText('message-b trace')).toBeInTheDocument();
+ expect(screen.queryByText('message-a stale
trace')).not.toBeInTheDocument();
+ });
+});
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index 553a0d10..125f95c8 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import {
Card,
Table,
@@ -129,18 +129,32 @@ const MessagePage = () => {
const [selectedMsg, setSelectedMsg] = useState<MessageRecord | null>(null);
const [traceData, setTraceData] = useState<TraceRecord | null>(null);
const [traceLoading, setTraceLoading] = useState(false);
+ const queryGenerationRef = useRef(0);
+ const traceGenerationRef = useRef(0);
+
+ useEffect(
+ () => () => {
+ queryGenerationRef.current += 1;
+ traceGenerationRef.current += 1;
+ },
+ [],
+ );
/* ─── Handlers ─── */
const handleReset = () => {
+ queryGenerationRef.current += 1;
setSelectedTopic(undefined);
setTagInput('');
setKeyInput('');
setMsgIdInput('');
setDateRange(getDefaultRange());
setMessages([]);
+ setQueryLoading(false);
};
const handleQuery = async () => {
+ const requestGeneration = queryGenerationRef.current + 1;
+ queryGenerationRef.current = requestGeneration;
const params =
queryMode === 'topic'
? {
@@ -156,12 +170,17 @@ const MessagePage = () => {
setQueryLoading(true);
try {
const result = await queryMessages(params);
+ if (queryGenerationRef.current !== requestGeneration) return;
setMessages(result);
message.success(`查询完成,共 ${result.length} 条`);
} catch {
- message.error('消息查询失败,请稍后重试');
+ if (queryGenerationRef.current === requestGeneration) {
+ message.error('消息查询失败,请稍后重试');
+ }
} finally {
- setQueryLoading(false);
+ if (queryGenerationRef.current === requestGeneration) {
+ setQueryLoading(false);
+ }
}
};
@@ -170,20 +189,34 @@ const MessagePage = () => {
};
const openDetail = async (record: MessageRecord, tab = 'content') => {
+ const requestGeneration = traceGenerationRef.current + 1;
+ traceGenerationRef.current = requestGeneration;
setSelectedMsg(record);
setModalTab(tab);
setModalOpen(true);
setTraceData(null);
setTraceLoading(true);
try {
- setTraceData(await getMessageTrace(record.msgId));
+ const result = await getMessageTrace(record.msgId);
+ if (traceGenerationRef.current !== requestGeneration) return;
+ setTraceData(result);
} catch {
- message.error('消息轨迹加载失败,请稍后重试');
+ if (traceGenerationRef.current === requestGeneration) {
+ message.error('消息轨迹加载失败,请稍后重试');
+ }
} finally {
- setTraceLoading(false);
+ if (traceGenerationRef.current === requestGeneration) {
+ setTraceLoading(false);
+ }
}
};
+ const closeDetail = () => {
+ traceGenerationRef.current += 1;
+ setModalOpen(false);
+ setTraceLoading(false);
+ };
+
const handleDownload = (record: MessageRecord) => {
const blob = new Blob([formatBody(record.body)], { type:
'application/json' });
const url = URL.createObjectURL(blob);
@@ -565,11 +598,11 @@ const MessagePage = () => {
title="消息详情"
width={800}
open={modalOpen}
- onCancel={() => setModalOpen(false)}
+ onCancel={closeDetail}
destroyOnClose
footer={
<Flex justify="flex-end" gap={8}>
- <Button onClick={() => setModalOpen(false)}>关闭</Button>
+ <Button onClick={closeDetail}>关闭</Button>
<Button type="primary" icon={<SendOutlined />}
onClick={handleResend}>
重新发送
</Button>