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 e5f72388f fix(ai): isolate timeline pagination requests (#5589)
e5f72388f is described below
commit e5f72388f2829a646cac22200a7e026cd3e6e85e
Author: aias00 <[email protected]>
AuthorDate: Thu Oct 8 20:26:38 2026 -0700
fix(ai): isolate timeline pagination requests (#5589)
A single `requestId` ref was shared by `refetch` and `loadMore`, so one
"load more" silently
cancelled the full refresh that `useAgentRun` awaits in its finally block,
with nothing surfaced to
the user. Give the two paths their own ownership counters, let a complete
snapshot invalidate an
incremental page that started from the old cursor, and clear `loading` only
once both have settled.
---
.../pages/ai/hooks/useConversationTimeline.test.ts | 94 ++++++++++++++++++++++
web/src/pages/ai/hooks/useConversationTimeline.ts | 45 ++++++++---
2 files changed, 128 insertions(+), 11 deletions(-)
diff --git a/web/src/pages/ai/hooks/useConversationTimeline.test.ts
b/web/src/pages/ai/hooks/useConversationTimeline.test.ts
index c43dd09e0..eed5e2156 100644
--- a/web/src/pages/ai/hooks/useConversationTimeline.test.ts
+++ b/web/src/pages/ai/hooks/useConversationTimeline.test.ts
@@ -46,6 +46,14 @@ function page(
return { items, nextAfter, activeRun };
}
+function deferred<T>() {
+ let resolve: (value: T) => void = () => undefined;
+ const promise = new Promise<T>((release) => {
+ resolve = release;
+ });
+ return { promise, resolve };
+}
+
function render(conversationId: number | null = 7, limit?: number, maxPages?:
number) {
return renderHook(
({ id }: { id: number | null }) => useConversationTimeline(id, { limit,
maxPages }),
@@ -195,6 +203,92 @@ describe('useConversationTimeline', () => {
expect(result.current.items).toHaveLength(2);
});
+ it('loadMoreDoesNotCancelAnInFlightRefetchTest', async () => {
+ const refreshed = deferred<AiTimelineVO>();
+ const nextPage = deferred<AiTimelineVO>();
+ timelineMock
+ .mockResolvedValueOnce(page([item(1, { type: 'text', text: 'before' })],
1))
+ .mockReturnValueOnce(refreshed.promise)
+ .mockReturnValueOnce(nextPage.promise);
+
+ const { result } = render(7, 1, 1);
+ await waitFor(() => expect(result.current.hasMore).toBe(true));
+
+ let refetchPromise: Promise<void> = Promise.resolve();
+ act(() => {
+ refetchPromise = result.current.refetch();
+ });
+ await waitFor(() => expect(timelineMock).toHaveBeenCalledTimes(2));
+
+ let loadMorePromise: Promise<void> = Promise.resolve();
+ act(() => {
+ loadMorePromise = result.current.loadMore();
+ });
+ await waitFor(() => expect(timelineMock).toHaveBeenCalledTimes(3));
+
+ await act(async () => {
+ nextPage.resolve(page([item(2, { type: 'text', text: 'incremental' })],
null));
+ await loadMorePromise;
+ });
+ expect(result.current.items.map((row) => row.seq)).toEqual([1, 2]);
+ expect(result.current.loading).toBe(true);
+
+ await act(async () => {
+ refreshed.resolve(
+ page(
+ [item(1, { type: 'text', text: 'before' }), item(3, { type: 'text',
text: 'persisted' })],
+ null,
+ ),
+ );
+ await refetchPromise;
+ });
+
+ expect(result.current.items.map((row) => row.seq)).toEqual([1, 3]);
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('refetchSupersedesAnOlderInFlightLoadMoreTest', async () => {
+ const nextPage = deferred<AiTimelineVO>();
+ const refreshed = deferred<AiTimelineVO>();
+ timelineMock
+ .mockResolvedValueOnce(page([item(1, { type: 'text', text: 'before' })],
1))
+ .mockReturnValueOnce(nextPage.promise)
+ .mockReturnValueOnce(refreshed.promise);
+
+ const { result } = render(7, 1, 1);
+ await waitFor(() => expect(result.current.hasMore).toBe(true));
+
+ let loadMorePromise: Promise<void> = Promise.resolve();
+ act(() => {
+ loadMorePromise = result.current.loadMore();
+ });
+ await waitFor(() => expect(timelineMock).toHaveBeenCalledTimes(2));
+
+ let refetchPromise: Promise<void> = Promise.resolve();
+ act(() => {
+ refetchPromise = result.current.refetch();
+ });
+ await waitFor(() => expect(timelineMock).toHaveBeenCalledTimes(3));
+
+ await act(async () => {
+ refreshed.resolve(
+ page(
+ [item(1, { type: 'text', text: 'before' }), item(3, { type: 'text',
text: 'persisted' })],
+ null,
+ ),
+ );
+ await refetchPromise;
+ });
+
+ await act(async () => {
+ nextPage.resolve(page([item(2, { type: 'text', text: 'stale incremental'
})], null));
+ await loadMorePromise;
+ });
+
+ expect(result.current.items.map((row) => row.seq)).toEqual([1, 3]);
+ expect(result.current.loading).toBe(false);
+ });
+
it('reportsTheServerMessageOnFailureTest', async () => {
timelineMock.mockRejectedValue(new Error('会话不存在'));
diff --git a/web/src/pages/ai/hooks/useConversationTimeline.ts
b/web/src/pages/ai/hooks/useConversationTimeline.ts
index 33ae3adb0..fc34091df 100644
--- a/web/src/pages/ai/hooks/useConversationTimeline.ts
+++ b/web/src/pages/ai/hooks/useConversationTimeline.ts
@@ -105,7 +105,9 @@ export function useConversationTimeline(
const [runSpeeds, setRunSpeeds] = useState<Map<number, number>>(new Map());
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
- const requestId = useRef(0);
+ const refetchRequestId = useRef(0);
+ const activeRefetchRef = useRef<number | null>(null);
+ const loadMoreRequestId = useRef(0);
/**
* Request id of the in-flight `loadMore`, or null. Guards `loadMore` only —
`refetch` must stay
* callable while another load is in flight, because `useAgentRun` awaits it
in a finally block.
@@ -116,6 +118,10 @@ export function useConversationTimeline(
const refetch = useCallback(async (): Promise<void> => {
if (conversationId === null) {
+ refetchRequestId.current += 1;
+ activeRefetchRef.current = null;
+ loadMoreRequestId.current += 1;
+ loadingMoreRef.current = null;
setItems([]);
setLoadedActiveRun(null);
setNextAfter(null);
@@ -124,7 +130,11 @@ export function useConversationTimeline(
return;
}
- const id = ++requestId.current;
+ const id = ++refetchRequestId.current;
+ activeRefetchRef.current = id;
+ // A newer full snapshot supersedes an incremental page that started from
the old cursor.
+ loadMoreRequestId.current += 1;
+ loadingMoreRef.current = null;
setLoading(true);
setError('');
try {
@@ -136,7 +146,7 @@ export function useConversationTimeline(
for (let page = 0; page < maxPages; page += 1) {
const result = await getConversationTimeline(conversationId, { after,
limit });
- if (id !== requestId.current) return;
+ if (id !== refetchRequestId.current) return;
collected = collected.concat(result.items);
run = result.activeRun;
cursor = result.nextAfter;
@@ -145,16 +155,23 @@ export function useConversationTimeline(
after = cursor;
}
+ // A load-more call may have started while this request was in flight.
The complete snapshot is
+ // authoritative, so its commit also invalidates that incremental write.
+ loadMoreRequestId.current += 1;
+ loadingMoreRef.current = null;
setItems(collected);
setLoadedActiveRun({ conversationId, run });
setNextAfter(cursor);
setRunSpeeds(speeds);
} catch (loadError) {
- if (id !== requestId.current) return;
+ if (id !== refetchRequestId.current) return;
setError(describeThrownMessage(loadError));
throw loadError;
} finally {
- if (id === requestId.current) setLoading(false);
+ if (activeRefetchRef.current === id) {
+ activeRefetchRef.current = null;
+ if (loadingMoreRef.current === null) setLoading(false);
+ }
}
}, [conversationId, limit, maxPages]);
@@ -162,13 +179,14 @@ export function useConversationTimeline(
if (conversationId === null || nextAfter === null ||
loadingMoreRef.current !== null) return;
const after = nextAfter;
- const id = ++requestId.current;
+ const id = ++loadMoreRequestId.current;
+ const refetchId = refetchRequestId.current;
loadingMoreRef.current = id;
setLoading(true);
setError('');
try {
const result = await getConversationTimeline(conversationId, { after,
limit });
- if (id !== requestId.current) return;
+ if (id !== loadMoreRequestId.current || refetchId !==
refetchRequestId.current) return;
setItems((previous) => previous.concat(result.items));
setLoadedActiveRun({ conversationId, run: result.activeRun });
setNextAfter(result.nextAfter);
@@ -178,11 +196,13 @@ export function useConversationTimeline(
return merged;
});
} catch (loadError) {
- if (id !== requestId.current) return;
+ if (id !== loadMoreRequestId.current || refetchId !==
refetchRequestId.current) return;
setError(describeThrownMessage(loadError));
} finally {
- if (loadingMoreRef.current === id) loadingMoreRef.current = null;
- if (id === requestId.current) setLoading(false);
+ if (loadingMoreRef.current === id) {
+ loadingMoreRef.current = null;
+ if (activeRefetchRef.current === null) setLoading(false);
+ }
}
}, [conversationId, limit, nextAfter]);
@@ -191,7 +211,10 @@ export function useConversationTimeline(
// eslint-disable-next-line react-hooks/set-state-in-effect
void refetch().catch(() => undefined);
return () => {
- requestId.current += 1;
+ refetchRequestId.current += 1;
+ activeRefetchRef.current = null;
+ loadMoreRequestId.current += 1;
+ loadingMoreRef.current = null;
};
}, [refetch]);