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 cc9a7cd36 fix(ai): close three run-lifecycle leaks around unmount,
conversation switch and clipboard (#5782)
cc9a7cd36 is described below
commit cc9a7cd36c0bcd449b12812b728fa8544cb2169e
Author: Chstercc <[email protected]>
AuthorDate: Sat Oct 10 12:01:52 2026 +0800
fix(ai): close three run-lifecycle leaks around unmount, conversation
switch and clipboard (#5782)
Three independent AI-page defects from the same author, in the same
hook/component family.
- #5782 `fix(ai): invalidate pending runtime loads on unmount`
- #5786 `fix(ai): scope timeline snapshots to the current conversation`
- #5799 `fix(web): recover from assistant clipboard write failures`
4 suites, 67 tests green; prettier and eslint clean.
Folded in #5786, #5799 (same author). Merged through #5782.
---
web/src/pages/ai/__tests__/AiPage.test.tsx | 48 ++++++-
web/src/pages/ai/components/AssistantBubble.tsx | 26 ++--
.../components/__tests__/AssistantBubble.test.tsx | 127 +++++++++++++++++-
.../pages/ai/hooks/useConversationTimeline.test.ts | 145 ++++++++++++++++++++-
web/src/pages/ai/hooks/useConversationTimeline.ts | 26 +++-
web/src/pages/ai/hooks/useLlmRuntime.test.ts | 78 ++++++++++-
web/src/pages/ai/hooks/useLlmRuntime.ts | 7 +-
7 files changed, 431 insertions(+), 26 deletions(-)
diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx
b/web/src/pages/ai/__tests__/AiPage.test.tsx
index 9c2ca4de1..1a963570c 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -37,7 +37,7 @@ import type {
} from '../../../api/aiEvents';
import { listClusters, type ClusterInfo } from '../../../api/cluster';
import type { Instance } from '../../../api/instance';
-import { getLlmConfig, getLlmModels } from '../../../api/llm';
+import { getLlmConfig, getLlmModels, type LlmConfig } from '../../../api/llm';
import { listInstances } from '../../../services/instanceService';
import useAuthStore from '../../../stores/authStore';
import { useEngineStore } from '../../../stores/engineStore';
@@ -123,6 +123,9 @@ const NavProbe = () => {
<button type="button" onClick={() => navigate('/ai/c/7')}>
probe-conversation
</button>
+ <button type="button" onClick={() => navigate('/')}>
+ probe-home
+ </button>
</div>
);
};
@@ -148,6 +151,14 @@ const renderRouted = (path: string, state?: unknown) =>
<NavProbe />
<LocationProbe />
<Routes>
+ <Route
+ path="/"
+ element={
+ <button type="button" onClick={() =>
useEngineStore.getState().setEngine('qoder')}>
+ choose-qoder
+ </button>
+ }
+ />
<Route path="/ai" element={<AiPage />} />
<Route path="/ai/c/:conversationId" element={<AiPage />} />
</Routes>
@@ -509,6 +520,41 @@ describe('AiPage', () => {
expect(openRunStream).not.toHaveBeenCalled();
});
+ it('preservesTheNewEnginePreferenceWhenAnUnmountedRuntimeLoadCompletesTest',
async () => {
+ let resolveConfig!: (value: LlmConfig) => void;
+ vi.mocked(getLlmConfig).mockReturnValueOnce(
+ new Promise<LlmConfig>((resolve) => {
+ resolveConfig = resolve;
+ }),
+ );
+ const user = userEvent.setup();
+ renderPage();
+ expect(getLlmConfig).toHaveBeenCalledTimes(1);
+
+ await user.click(screen.getByRole('button', { name: 'probe-home' }));
+ expect(readProbeLocation().pathname).toBe('/');
+ await user.click(screen.getByRole('button', { name: 'choose-qoder' }));
+
+ await act(async () => {
+ resolveConfig({
+ provider: 'openai',
+ engine: 'http',
+ apiBase: 'https://example.invalid',
+ model: 'gpt-test',
+ maxTokens: 1024,
+ temperature: 0,
+ enabled: true,
+ ready: true,
+ });
+ });
+
+ expect(useEngineStore.getState().engine).toBe('qoder');
+
expect(JSON.parse(localStorage.getItem('rocketmq-studio-agent-engine')!)).toMatchObject({
+ state: { engine: 'qoder' },
+ });
+ expect(getLlmModels).not.toHaveBeenCalled();
+ });
+
it('doesNotLoadTheLlmRuntimeInMockModeAndDisablesTheComposerTest', async ()
=> {
dataModeMocks.useMock = true;
const user = userEvent.setup();
diff --git a/web/src/pages/ai/components/AssistantBubble.tsx
b/web/src/pages/ai/components/AssistantBubble.tsx
index adb43685d..9e149b7c2 100644
--- a/web/src/pages/ai/components/AssistantBubble.tsx
+++ b/web/src/pages/ai/components/AssistantBubble.tsx
@@ -107,28 +107,32 @@ const AssistantBubble = ({
// The standard per-message action: copy the answer as markdown. Clipboard
API first, the
// hidden-textarea fallback second (non-secure contexts), silent no-op third.
- const handleCopy = () => {
+ const handleCopy = async () => {
const done = () => {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
};
- if (navigator.clipboard?.writeText) {
- navigator.clipboard
- .writeText(answerText)
- .then(done)
- .catch(() => undefined);
- return;
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(answerText);
+ done();
+ return;
+ }
+ } catch {
+ // A denied Clipboard API write can still succeed through the textarea
fallback.
}
const textarea = document.createElement('textarea');
textarea.value = answerText;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
- document.body.appendChild(textarea);
- textarea.select();
try {
- if (document.execCommand('copy')) done();
+ document.body.appendChild(textarea);
+ textarea.select();
+ if (document.execCommand?.('copy')) done();
+ } catch {
+ // Unsupported copying degrades silently without reporting success.
} finally {
- document.body.removeChild(textarea);
+ textarea.remove();
}
};
diff --git a/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx
b/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx
index db691c9ad..c334de76b 100644
--- a/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx
+++ b/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { beforeEach, describe, expect, it } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LangProvider } from '../../../../i18n/LangContext';
@@ -97,6 +97,131 @@ describe('AssistantBubble', () => {
localStorage.clear();
});
+ describe('copy response', () => {
+ const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator,
'clipboard');
+ const execCommandDescriptor = Object.getOwnPropertyDescriptor(document,
'execCommand');
+ const answer = '**First answer**\n\nSecond answer';
+ let execCommand: ReturnType<typeof vi.fn>;
+
+ beforeEach(() => {
+ Object.defineProperty(navigator, 'clipboard', { configurable: true,
value: undefined });
+ execCommand = vi.fn(() => true);
+ Object.defineProperty(document, 'execCommand', { configurable: true,
value: execCommand });
+ });
+
+ afterEach(() => {
+ if (clipboardDescriptor) {
+ Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
+ } else {
+ Reflect.deleteProperty(navigator, 'clipboard');
+ }
+ if (execCommandDescriptor) {
+ Object.defineProperty(document, 'execCommand', execCommandDescriptor);
+ } else {
+ Reflect.deleteProperty(document, 'execCommand');
+ }
+ vi.restoreAllMocks();
+ });
+
+ async function copyResponse() {
+ renderBubble({
+ blocks: [
+ ...appendText([], '**First answer**'),
+ ...appendThinking([], 'Private reasoning', 'model'),
+ ...appendText([], 'Second answer'),
+ ],
+ });
+ const button = screen.getByRole('button', { name: '复制回复' });
+ fireEvent.click(button);
+ fireEvent.mouseEnter(button);
+ return screen.findByRole('tooltip');
+ }
+
+ function expectFallbackText() {
+ const textarea = document.querySelector('textarea');
+ expect(textarea).toHaveValue(answer);
+ expect(textarea?.selectionStart).toBe(0);
+ expect(textarea?.selectionEnd).toBe(answer.length);
+ }
+
+ it('copiesOnlyAnswerMarkdownWithTheClipboardApiTest', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', { configurable: true,
value: { writeText } });
+
+ expect(await copyResponse()).toHaveTextContent('已复制');
+
+ expect(writeText).toHaveBeenCalledExactlyOnceWith(answer);
+ expect(execCommand).not.toHaveBeenCalled();
+ expect(document.querySelector('textarea')).toBeNull();
+ });
+
+ it.each([
+ { label: 'rejected', writeText: () => Promise.reject(new
Error('Permission denied')) },
+ {
+ label: 'throwing',
+ writeText: () => {
+ throw new Error('Clipboard unavailable');
+ },
+ },
+ ])('fallsBackAfterThe$labelClipboardWriteTest', async ({ writeText }) => {
+ Object.defineProperty(navigator, 'clipboard', { configurable: true,
value: { writeText } });
+ execCommand.mockImplementation(() => {
+ expectFallbackText();
+ return true;
+ });
+
+ expect(await copyResponse()).toHaveTextContent('已复制');
+
+ expect(execCommand).toHaveBeenCalledExactlyOnceWith('copy');
+ expect(document.querySelector('textarea')).toBeNull();
+ });
+
+ it('fallsBackWhenTheClipboardApiIsUnavailableTest', async () => {
+ execCommand.mockImplementation(() => {
+ expectFallbackText();
+ return true;
+ });
+
+ expect(await copyResponse()).toHaveTextContent('已复制');
+
+ expect(execCommand).toHaveBeenCalledExactlyOnceWith('copy');
+ expect(document.querySelector('textarea')).toBeNull();
+ });
+
+ it.each(['false', 'throwing', 'unavailable'] as const)(
+ 'doesNotReportCopiedWhenTheFallbackIs%sTest',
+ async (failure) => {
+ if (failure === 'unavailable') {
+ Object.defineProperty(document, 'execCommand', { configurable: true,
value: undefined });
+ } else {
+ execCommand.mockImplementation(() => {
+ expectFallbackText();
+ if (failure === 'throwing') throw new Error('Copy unsupported');
+ return false;
+ });
+ }
+
+ expect(await copyResponse()).toHaveTextContent('复制回复');
+
+ if (failure !== 'unavailable') {
+ expect(execCommand).toHaveBeenCalledExactlyOnceWith('copy');
+ }
+ expect(document.querySelector('textarea')).toBeNull();
+ },
+ );
+
+ it('cleansUpWhenSelectingTheFallbackTextThrowsTest', async () => {
+ vi.spyOn(HTMLTextAreaElement.prototype, 'select').mockImplementation(()
=> {
+ throw new Error('Selection unavailable');
+ });
+
+ expect(await copyResponse()).toHaveTextContent('复制回复');
+
+ expect(execCommand).not.toHaveBeenCalled();
+ expect(document.querySelector('textarea')).toBeNull();
+ });
+ });
+
it('rendersEveryBlockKindTest', () => {
const blocks: RenderBlock[] = [
...appendThinking([], '先确认实例能力,再列 Topic。', 'model'),
diff --git a/web/src/pages/ai/hooks/useConversationTimeline.test.ts
b/web/src/pages/ai/hooks/useConversationTimeline.test.ts
index eed5e2156..108850b7c 100644
--- a/web/src/pages/ai/hooks/useConversationTimeline.test.ts
+++ b/web/src/pages/ai/hooks/useConversationTimeline.test.ts
@@ -48,10 +48,12 @@ function page(
function deferred<T>() {
let resolve: (value: T) => void = () => undefined;
- const promise = new Promise<T>((release) => {
+ let reject: (reason: unknown) => void = () => undefined;
+ const promise = new Promise<T>((release, fail) => {
resolve = release;
+ reject = fail;
});
- return { promise, resolve };
+ return { promise, resolve, reject };
}
function render(conversationId: number | null = 7, limit?: number, maxPages?:
number) {
@@ -181,6 +183,145 @@ describe('useConversationTimeline', () => {
expect(result.current.loading).toBe(false);
});
+ it.each([{ label: 'pending' }, { label: 'failed' }])(
+ 'hidesThePreviousTranscriptWhenTheNewSnapshotIs($label)Test',
+ async ({ label }) => {
+ const second = deferred<AiTimelineVO>();
+ timelineMock
+ .mockResolvedValueOnce(page([item(5, { type: 'user', text:
'conversation 7' })], 5))
+ .mockReturnValueOnce(second.promise);
+
+ const { result, rerender } = render(7, 1, 1);
+ await waitFor(() => expect(result.current.lastSeq).toBe(5));
+ expect(result.current.bubbles).toHaveLength(1);
+ expect(result.current.hasMore).toBe(true);
+
+ rerender({ id: 8 });
+ if (label === 'failed') {
+ await act(async () => {
+ second.reject(new Error('conversation 8 unavailable'));
+ });
+ expect(result.current.error).toBe('conversation 8 unavailable');
+ expect(result.current.loading).toBe(false);
+ } else {
+ expect(result.current.loading).toBe(true);
+ }
+
+ expect(result.current.items).toEqual([]);
+ expect(result.current.bubbles).toEqual([]);
+ expect(result.current.lastSeq).toBe(0);
+ expect(result.current.hasMore).toBe(false);
+ expect(result.current.activeRun).toBeNull();
+
+ if (label === 'pending') {
+ await act(async () => {
+ second.resolve(page([], null));
+ });
+ }
+ },
+ );
+
+ it.each([{ label: 'pending' }, { label: 'failed' }])(
+ 'blocksLoadMoreWhenTheNewSnapshotIs($label)Test',
+ async ({ label }) => {
+ const second = deferred<AiTimelineVO>();
+ timelineMock
+ .mockResolvedValueOnce(page([item(5, { type: 'text', text:
'conversation 7' })], 5))
+ .mockReturnValueOnce(second.promise)
+ .mockResolvedValue(page([item(6, { type: 'text', text: 'conversation
8' })], null));
+
+ const { result, rerender } = render(7, 1, 1);
+ await waitFor(() => expect(result.current.lastSeq).toBe(5));
+ rerender({ id: 8 });
+ if (label === 'failed') {
+ await act(async () => {
+ second.reject(new Error('conversation 8 unavailable'));
+ });
+ }
+
+ await act(async () => {
+ await result.current.loadMore();
+ });
+ expect(timelineMock).toHaveBeenCalledTimes(2);
+ expect(timelineMock).toHaveBeenLastCalledWith(8, { after: 0, limit: 1 });
+
+ if (label === 'pending') {
+ await act(async () => {
+ second.resolve(page([], null));
+ });
+ } else {
+ expect(result.current.error).toBe('conversation 8 unavailable');
+ }
+ },
+ );
+
+ it('loadsMoreUsingOnlyTheNewConversationsSnapshotAndCursorTest', async () =>
{
+ const second = deferred<AiTimelineVO>();
+ timelineMock
+ .mockResolvedValueOnce(page([item(5, { type: 'text', text: 'conversation
7' })], 5))
+ .mockReturnValueOnce(second.promise)
+ .mockResolvedValueOnce(page([item(12, { type: 'text', text: 'more
conversation 8' })], null));
+
+ const { result, rerender } = render(7, 1, 1);
+ await waitFor(() => expect(result.current.lastSeq).toBe(5));
+ rerender({ id: 8 });
+
+ await act(async () => {
+ second.resolve(page([item(11, { type: 'text', text: 'conversation 8'
})], 11));
+ });
+ expect(result.current.items.map((row) => row.seq)).toEqual([11]);
+ expect(result.current.hasMore).toBe(true);
+
+ await act(async () => {
+ await result.current.loadMore();
+ });
+ expect(timelineMock).toHaveBeenLastCalledWith(8, { after: 11, limit: 1 });
+ expect(result.current.items.map((row) => row.seq)).toEqual([11, 12]);
+ expect(result.current.lastSeq).toBe(12);
+ expect(result.current.hasMore).toBe(false);
+ });
+
+ it('retainsTheCurrentTranscriptAndCursorWhenItsRefreshFailsTest', async ()
=> {
+ const refreshed = deferred<AiTimelineVO>();
+ timelineMock
+ .mockResolvedValueOnce(page([item(5, { type: 'user', text: 'conversation
7' })], 5))
+ .mockReturnValueOnce(refreshed.promise)
+ .mockResolvedValueOnce(page([item(6, { type: 'text', text: 'more
conversation 7' })], null));
+
+ const { result } = render(7, 1, 1);
+ await waitFor(() => expect(result.current.lastSeq).toBe(5));
+ const bubbles = result.current.bubbles;
+
+ let refetchPromise: Promise<void> = Promise.resolve();
+ act(() => {
+ refetchPromise = result.current.refetch();
+ });
+ expect(result.current.items.map((row) => row.seq)).toEqual([5]);
+ expect(result.current.bubbles).toEqual(bubbles);
+ expect(result.current.lastSeq).toBe(5);
+ expect(result.current.hasMore).toBe(true);
+ expect(result.current.loading).toBe(true);
+
+ await act(async () => {
+ const rejection = expect(refetchPromise).rejects.toThrow('refresh
unavailable');
+ refreshed.reject(new Error('refresh unavailable'));
+ await rejection;
+ });
+ expect(result.current.items.map((row) => row.seq)).toEqual([5]);
+ expect(result.current.bubbles).toEqual(bubbles);
+ expect(result.current.lastSeq).toBe(5);
+ expect(result.current.hasMore).toBe(true);
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBe('refresh unavailable');
+
+ await act(async () => {
+ await result.current.loadMore();
+ });
+ expect(timelineMock).toHaveBeenLastCalledWith(7, { after: 5, limit: 1 });
+ expect(result.current.items.map((row) => row.seq)).toEqual([5, 6]);
+ expect(result.current.hasMore).toBe(false);
+ });
+
it('refetchReloadsTheWholeTranscriptAndIsAwaitableTest', async () => {
timelineMock
.mockResolvedValueOnce(page([item(1, { type: 'text', text: 'before' })],
null))
diff --git a/web/src/pages/ai/hooks/useConversationTimeline.ts
b/web/src/pages/ai/hooks/useConversationTimeline.ts
index fc34091df..b119fa786 100644
--- a/web/src/pages/ai/hooks/useConversationTimeline.ts
+++ b/web/src/pages/ai/hooks/useConversationTimeline.ts
@@ -38,6 +38,7 @@ import { describeThrownMessage } from
'../../../utils/apiError';
export const TIMELINE_PAGE_LIMIT = 200;
/** 25 pages x 200 events: far past any real conversation, small enough to
stay a bounded walk. */
export const TIMELINE_MAX_PAGES = 25;
+const EMPTY_TIMELINE_ITEMS: TimelineItem[] = [];
/** Fold the envelope's per-run stats into the speed map; null entries never
overwrite. */
function collectRunSpeeds(
@@ -63,6 +64,8 @@ export interface UseConversationTimelineOptions {
* state below still holds the previous one's run for at least a commit — and
a run id is the only
* thing the attach endpoint needs, so acting on it would stream the previous
conversation's frames
* into the transcript on screen. See {@link
UseConversationTimelineResult.activeRun}.
+ * This also identifies the owner of the rows and cursor committed with the
run, even when the run
+ * is null. Keep that snapshot during a same-conversation refresh, but hide it
after a switch.
*/
interface LoadedActiveRun {
conversationId: number;
@@ -105,6 +108,7 @@ export function useConversationTimeline(
const [runSpeeds, setRunSpeeds] = useState<Map<number, number>>(new Map());
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
+ const hasCurrentSnapshot = loadedActiveRun?.conversationId ===
conversationId;
const refetchRequestId = useRef(0);
const activeRefetchRef = useRef<number | null>(null);
const loadMoreRequestId = useRef(0);
@@ -176,7 +180,13 @@ export function useConversationTimeline(
}, [conversationId, limit, maxPages]);
const loadMore = useCallback(async (): Promise<void> => {
- if (conversationId === null || nextAfter === null ||
loadingMoreRef.current !== null) return;
+ if (
+ conversationId === null ||
+ !hasCurrentSnapshot ||
+ nextAfter === null ||
+ loadingMoreRef.current !== null
+ )
+ return;
const after = nextAfter;
const id = ++loadMoreRequestId.current;
@@ -204,7 +214,7 @@ export function useConversationTimeline(
if (activeRefetchRef.current === null) setLoading(false);
}
}
- }, [conversationId, limit, nextAfter]);
+ }, [conversationId, hasCurrentSnapshot, limit, nextAfter]);
useEffect(() => {
// Loading is asynchronous; state updates happen after the timeline API
resolves.
@@ -218,8 +228,12 @@ export function useConversationTimeline(
};
}, [refetch]);
- const bubbles = useMemo(() => groupIntoBubbles(items, runSpeeds), [items,
runSpeeds]);
- const lastSeq = items.length ? items[items.length - 1].seq : 0;
+ const visibleItems = hasCurrentSnapshot ? items : EMPTY_TIMELINE_ITEMS;
+ const bubbles = useMemo(
+ () => groupIntoBubbles(visibleItems, runSpeeds),
+ [visibleItems, runSpeeds],
+ );
+ const lastSeq = visibleItems.length ? visibleItems[visibleItems.length -
1].seq : 0;
// Only the run of the conversation on screen: a run loaded for another one
is not this
// conversation's to attach, and it is not this conversation's to render
either.
const activeRun =
@@ -228,13 +242,13 @@ export function useConversationTimeline(
: null;
return {
- items,
+ items: visibleItems,
bubbles,
activeRun,
lastSeq,
loading,
error,
- hasMore: nextAfter !== null,
+ hasMore: hasCurrentSnapshot && nextAfter !== null,
refetch,
loadMore,
};
diff --git a/web/src/pages/ai/hooks/useLlmRuntime.test.ts
b/web/src/pages/ai/hooks/useLlmRuntime.test.ts
index 0713db18a..f41ca07b8 100644
--- a/web/src/pages/ai/hooks/useLlmRuntime.test.ts
+++ b/web/src/pages/ai/hooks/useLlmRuntime.test.ts
@@ -16,8 +16,9 @@
*/
import { act, renderHook } from '@testing-library/react';
+import { StrictMode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import type { LlmConfig } from '../../../api/llm';
+import type { LlmConfig, LlmModelsResult } from '../../../api/llm';
import { useLlmRuntime } from './useLlmRuntime';
vi.mock('../../../api/llm', () => ({
@@ -32,10 +33,12 @@ const modelsMock = vi.mocked(getLlmModels);
function deferred<T>() {
let resolve!: (value: T) => void;
- const promise = new Promise<T>((res) => {
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise<T>((res, rej) => {
resolve = res;
+ reject = rej;
});
- return { promise, resolve };
+ return { promise, resolve, reject };
}
const config: LlmConfig = {
@@ -65,6 +68,75 @@ describe('useLlmRuntime', () => {
vi.clearAllMocks();
});
+ it('ignoresConfigCompletionAndDoesNotLoadModelsAfterUnmountTest', async ()
=> {
+ const pending = deferred<LlmConfig>();
+ configMock.mockReturnValue(pending.promise);
+ modelsMock.mockResolvedValue({ status: 0, data: [] });
+ const onEngine = vi.fn();
+ const onError = vi.fn();
+ const { unmount } = renderHook(() => useLlmRuntime({ enabled: true,
onEngine, onError }));
+ expect(configMock).toHaveBeenCalledTimes(1);
+
+ unmount();
+ await act(async () => pending.resolve(config));
+
+ expect(onEngine).not.toHaveBeenCalled();
+ expect(onError).not.toHaveBeenCalled();
+ expect(modelsMock).not.toHaveBeenCalled();
+ });
+
+ it('ignoresConfigFailureAfterUnmountTest', async () => {
+ const pending = deferred<LlmConfig>();
+ configMock.mockReturnValue(pending.promise);
+ const onError = vi.fn();
+ const { unmount } = renderHook(() => useLlmRuntime({ enabled: true,
onError }));
+
+ unmount();
+ await act(async () => pending.reject(new Error('late config failure')));
+
+ expect(onError).not.toHaveBeenCalled();
+ expect(modelsMock).not.toHaveBeenCalled();
+ });
+
+ it('ignoresModelCatalogFailureAfterUnmountTest', async () => {
+ const pending = deferred<LlmModelsResult>();
+ configMock.mockResolvedValue(config);
+ modelsMock.mockReturnValue(pending.promise);
+ const onEngine = vi.fn();
+ const onError = vi.fn();
+ const { unmount } = renderHook(() => useLlmRuntime({ enabled: true,
onEngine, onError }));
+ await act(async () => {});
+ expect(onEngine).toHaveBeenCalledWith('http');
+ expect(modelsMock).toHaveBeenCalledTimes(1);
+
+ unmount();
+ await act(async () => pending.reject(new Error('late models failure')));
+
+ expect(onError).not.toHaveBeenCalled();
+ });
+
+ it('keepsTheActiveStrictModeLoadAfterEffectCleanupTest', async () => {
+ const superseded = deferred<LlmConfig>();
+ const active = deferred<LlmConfig>();
+
configMock.mockReturnValueOnce(superseded.promise).mockReturnValueOnce(active.promise);
+ modelsMock.mockResolvedValue({ status: 0, data: [{ id: 'active-model' }]
});
+ const onEngine = vi.fn();
+ const { result } = renderHook(() => useLlmRuntime({ enabled: true,
onEngine }), {
+ wrapper: StrictMode,
+ });
+ expect(configMock).toHaveBeenCalledTimes(2);
+
+ await act(async () => active.resolve({ ...config, engine: 'qoder', model:
'active-model' }));
+ expect(result.current.selectedModel).toBe('active-model');
+ expect(result.current.modelsLoading).toBe(false);
+ expect(result.current.llmReady).toBe(true);
+
+ await act(async () => superseded.resolve(config));
+ expect(onEngine).toHaveBeenCalledExactlyOnceWith('qoder');
+ expect(modelsMock).toHaveBeenCalledTimes(1);
+ expect(result.current.config?.engine).toBe('qoder');
+ });
+
it('a late response for a disabled runtime never repopulates the state',
async () => {
const configDeferred = deferred<LlmConfig>();
const modelsDeferred = deferred<unknown>();
diff --git a/web/src/pages/ai/hooks/useLlmRuntime.ts
b/web/src/pages/ai/hooks/useLlmRuntime.ts
index 655fe54c7..8de7e28da 100644
--- a/web/src/pages/ai/hooks/useLlmRuntime.ts
+++ b/web/src/pages/ai/hooks/useLlmRuntime.ts
@@ -87,8 +87,8 @@ export function useLlmRuntime(options: UseLlmRuntimeOptions):
UseLlmRuntimeResul
);
}, []);
- // Monotonic id per load: a response from a superseded load (an enabled flip
or an overlapping
- // reload) must not repopulate the state, or a disabled runtime could come
back "ready".
+ // Monotonic id per load: a response superseded by an enabled flip,
overlapping reload or
+ // unmount must not publish state or callbacks belonging to an obsolete
runtime.
const loadSeqRef = useRef(0);
const load = useCallback(async () => {
@@ -131,6 +131,9 @@ export function useLlmRuntime(options:
UseLlmRuntimeOptions): UseLlmRuntimeResul
// Loading is asynchronous; state updates happen after the runtime APIs
resolve.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
+ return () => {
+ loadSeqRef.current += 1;
+ };
}, [load]);
const llmReady = Boolean((config?.ready ?? config?.enabled) &&
selectedModel);