From 91c7ed4cf5e2c8098c8a0c3e004b0e4ec101e763 Mon Sep 17 00:00:00 2001 From: jiaqiang0000 <43921091+jiaqiang000@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:10:54 +0800 Subject: [PATCH] fix(frontend): keep renamed thread titles in sync (#5045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): 同步会话重命名后的标题状态 * chore: 重新触发 PR 自动分流检查 * fix(frontend): address thread title sync review feedback Signed-off-by: 橘猫 <2622045569@qq.com> * fix(frontend): fall back when canonical thread title is empty Signed-off-by: 橘猫 <2622045569@qq.com> * fix(frontend): fence stale metadata after thread rename Signed-off-by: 橘猫 <2622045569@qq.com> * fix(frontend): fence stale thread list responses after rename Signed-off-by: 橘猫 <2622045569@qq.com> --------- Signed-off-by: 橘猫 <2622045569@qq.com> --- .../[agent_name]/chats/[thread_id]/page.tsx | 6 +- .../components/workspace/chats/chat-page.tsx | 6 +- .../src/components/workspace/thread-title.tsx | 30 +-- frontend/src/core/threads/hooks.ts | 174 +++++++--------- frontend/tests/e2e/thread-title-sync.spec.ts | 188 ++++++++++++++++++ frontend/tests/e2e/utils/mock-api.ts | 47 ++++- .../workspace/thread-title.dom.test.tsx | 74 +++++++ .../core/threads/rename-thread.dom.test.tsx | 170 ++++++++++++++++ 8 files changed, 577 insertions(+), 118 deletions(-) create mode 100644 frontend/tests/e2e/thread-title-sync.spec.ts create mode 100644 frontend/tests/unit/components/workspace/thread-title.dom.test.tsx create mode 100644 frontend/tests/unit/core/threads/rename-thread.dom.test.tsx diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 4f90f85205..62e2560261 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -275,7 +275,11 @@ export default function AgentChatPage() {
- +
{!isNewThread && diff --git a/frontend/src/components/workspace/chats/chat-page.tsx b/frontend/src/components/workspace/chats/chat-page.tsx index a6c650dded..2c5b6e2878 100644 --- a/frontend/src/components/workspace/chats/chat-page.tsx +++ b/frontend/src/components/workspace/chats/chat-page.tsx @@ -288,7 +288,11 @@ export default function ChatPage() { > {!isMock && }
- +
{!isNewThread && diff --git a/frontend/src/components/workspace/thread-title.tsx b/frontend/src/components/workspace/thread-title.tsx index bfc469e876..11412f01f6 100644 --- a/frontend/src/components/workspace/thread-title.tsx +++ b/frontend/src/components/workspace/thread-title.tsx @@ -7,21 +7,27 @@ import type { AgentThreadState } from "@/core/threads"; import { useThreadChat } from "./chats"; import { FlipDisplay } from "./flip-display"; -export function ThreadTitle({ - threadId, - thread, -}: { +export type ThreadTitleProps = { className?: string; threadId: string; thread: BaseStream; -}) { + canonicalTitle?: string; +}; + +export function ThreadTitle({ + threadId, + thread, + canonicalTitle, +}: ThreadTitleProps) { const { t } = useI18n(); const { isNewThread } = useThreadChat(); + const title = canonicalTitle?.length ? canonicalTitle : thread.values?.title; + useEffect(() => { let _title = t.pages.untitled; - if (thread.values?.title) { - _title = thread.values.title; + if (title) { + _title = title; } else if (isNewThread) { _title = t.pages.newChat; } @@ -36,15 +42,11 @@ export function ThreadTitle({ t.pages.untitled, t.pages.appName, thread.isThreadLoading, - thread.values, + title, ]); - if (!thread.values?.title) { + if (!title) { return null; } - return ( - - {thread.values.title ?? "Untitled"} - - ); + return {title}; } diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 8a00a98f8a..b3752ed6a9 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -4,6 +4,7 @@ import { useStream } from "@langchain/langgraph-sdk/react"; import { type QueryClient, type InfiniteData, + type QueryFilters, useInfiniteQuery, useMutation, useQuery, @@ -1809,47 +1810,10 @@ export function useThreadStream({ ); for (const update of updates) { if (update && "title" in update && update.title) { - void queryClient.setQueriesData( - { - queryKey: ["threads", "search"], - exact: false, - }, - (oldData: Array | undefined) => { - return oldData?.map((t) => { - if (t.thread_id === threadIdRef.current) { - return { - ...t, - values: { - ...t.values, - title: update.title, - }, - }; - } - return t; - }); - }, - ); - const nextTitle: string = update.title; - void queryClient.setQueriesData( - { - queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX, - exact: false, - }, - (oldData: InfiniteData | undefined) => - mapInfiniteThreadsCache( - oldData, - (t): AgentThread => - t.thread_id === threadIdRef.current - ? { - ...t, - values: { - ...t.values, - title: nextTitle, - }, - } - : t, - ), - ); + const currentThreadId = threadIdRef.current; + if (currentThreadId) { + setThreadTitleInCaches(queryClient, currentThreadId, update.title); + } } } }, @@ -2922,46 +2886,82 @@ function mergeThreadMetadata( }; } -function setThreadMetadataInCaches( - queryClient: QueryClient, - threadId: string, - metadata: ThreadMetadataPatch, -) { - queryClient.setQueriesData( - { +function mergeThreadTitle(thread: AgentThread, title: string): AgentThread { + return { + ...thread, + values: { + ...thread.values, + title, + }, + }; +} + +function getThreadSnapshotCacheFilters(threadId: string) { + return { + search: { queryKey: ["threads", "search"], exact: false, }, + infiniteSearch: { + queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX, + exact: false, + }, + metadata: { + queryKey: ["thread", "metadata", threadId], + exact: false, + }, + } as const; +} + +function setThreadInCaches( + queryClient: QueryClient, + threadId: string, + mapper: (thread: AgentThread) => AgentThread, +): void { + const filters = getThreadSnapshotCacheFilters(threadId); + + queryClient.setQueriesData( + filters.search, (oldData: Array | undefined) => { if (!oldData) { return oldData; } return oldData.map((thread) => - thread.thread_id === threadId - ? mergeThreadMetadata(thread, metadata) - : thread, + thread.thread_id === threadId ? mapper(thread) : thread, ); }, ); queryClient.setQueriesData( - { - queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX, - exact: false, - }, + filters.infiniteSearch, (oldData: InfiniteData | undefined) => mapInfiniteThreadsCache(oldData, (thread) => - thread.thread_id === threadId - ? mergeThreadMetadata(thread, metadata) - : thread, + thread.thread_id === threadId ? mapper(thread) : thread, ), ); queryClient.setQueriesData( - { - queryKey: ["thread", "metadata", threadId], - exact: false, - }, + filters.metadata, (oldData: AgentThread | null | undefined) => - oldData ? mergeThreadMetadata(oldData, metadata) : oldData, + oldData ? mapper(oldData) : oldData, + ); +} + +function setThreadMetadataInCaches( + queryClient: QueryClient, + threadId: string, + metadata: ThreadMetadataPatch, +) { + setThreadInCaches(queryClient, threadId, (thread) => + mergeThreadMetadata(thread, metadata), + ); +} + +export function setThreadTitleInCaches( + queryClient: QueryClient, + threadId: string, + title: string, +): void { + setThreadInCaches(queryClient, threadId, (thread) => + mergeThreadTitle(thread, title), ); } @@ -3320,45 +3320,19 @@ export function useRenameThread() { values: { title }, }); }, - onSuccess(_, { threadId, title }) { - queryClient.setQueriesData( - { - queryKey: ["threads", "search"], - exact: false, - }, - (oldData: Array) => { - return oldData.map((t) => { - if (t.thread_id === threadId) { - return { - ...t, - values: { - ...t.values, - title, - }, - }; - } - return t; - }); - }, + async onSuccess(_, { threadId, title }) { + const filters: QueryFilters[] = Object.values( + getThreadSnapshotCacheFilters(threadId), ); - queryClient.setQueriesData( - { - queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX, - exact: false, - }, - (oldData: InfiniteData | undefined) => - mapInfiniteThreadsCache(oldData, (t) => - t.thread_id === threadId - ? { - ...t, - values: { - ...t.values, - title, - }, - } - : t, - ), + + // Prevent pre-rename snapshot requests from restoring the stale title. + await Promise.all( + filters.map((filter) => queryClient.cancelQueries(filter)), ); + setThreadTitleInCaches(queryClient, threadId, title); + for (const filter of filters) { + void queryClient.invalidateQueries(filter); + } }, }); } diff --git a/frontend/tests/e2e/thread-title-sync.spec.ts b/frontend/tests/e2e/thread-title-sync.spec.ts new file mode 100644 index 0000000000..fde30f4dd4 --- /dev/null +++ b/frontend/tests/e2e/thread-title-sync.spec.ts @@ -0,0 +1,188 @@ +import { expect, test } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +const THREAD_ID = "00000000-0000-0000-0000-000000000321"; +const ORIGINAL_TITLE = "Original title"; +const RENAMED_TITLE = "Renamed title"; + +test("renaming a thread updates the sidebar, header, and document title", async ({ + page, +}) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: THREAD_ID, + title: ORIGINAL_TITLE, + updated_at: "2026-07-05T10:00:00Z", + }, + ], + }); + + await page.goto(`/workspace/chats/${THREAD_ID}`); + await expect(page.getByText(ORIGINAL_TITLE).first()).toBeVisible({ + timeout: 15_000, + }); + await expect(page).toHaveTitle(`${ORIGINAL_TITLE} - DeerFlow`); + + const threadItem = page + .locator( + `a[data-sidebar="menu-button"][href="/workspace/chats/${THREAD_ID}"]`, + ) + .locator("xpath=.."); + await threadItem.hover(); + await threadItem.getByRole("button", { name: "More" }).click(); + await page.getByRole("menuitem", { name: "Rename" }).click(); + + const dialog = page.getByRole("dialog"); + await dialog.getByRole("textbox").fill(RENAMED_TITLE); + await dialog.getByRole("button", { name: "Save" }).click(); + + await expect(dialog).toBeHidden(); + await expect(threadItem).toContainText(RENAMED_TITLE); + await expect(page.locator("header").getByText(RENAMED_TITLE)).toBeVisible(); + await expect(page).toHaveTitle(`${RENAMED_TITLE} - DeerFlow`); + + await page.reload(); + await expect(threadItem).toContainText(RENAMED_TITLE); + await expect(page.locator("header").getByText(RENAMED_TITLE)).toBeVisible({ + timeout: 15_000, + }); + await expect(page).toHaveTitle(`${RENAMED_TITLE} - DeerFlow`); +}); + +test("a stale metadata response cannot restore the old title after rename", async ({ + page, +}) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: THREAD_ID, + title: ORIGINAL_TITLE, + updated_at: "2026-07-05T10:00:00Z", + }, + ], + }); + + let releaseStaleMetadataResponse!: () => void; + const staleMetadataResponseGate = new Promise((resolve) => { + releaseStaleMetadataResponse = resolve; + }); + let markStaleMetadataRequestStarted!: () => void; + const staleMetadataRequestStarted = new Promise((resolve) => { + markStaleMetadataRequestStarted = resolve; + }); + let markStaleMetadataResponseCompleted!: () => void; + const staleMetadataResponseCompleted = new Promise((resolve) => { + markStaleMetadataResponseCompleted = resolve; + }); + let releaseFreshMetadataResponse!: () => void; + const freshMetadataResponseGate = new Promise((resolve) => { + releaseFreshMetadataResponse = resolve; + }); + let markFreshMetadataRequestStarted!: () => void; + const freshMetadataRequestStarted = new Promise((resolve) => { + markFreshMetadataRequestStarted = resolve; + }); + let markFreshMetadataResponseCompleted!: () => void; + const freshMetadataResponseCompleted = new Promise((resolve) => { + markFreshMetadataResponseCompleted = resolve; + }); + let delayMetadataResponses = false; + let metadataRequestCount = 0; + + await page.route("**/api/langgraph/threads/*", async (route) => { + const url = new URL(route.request().url()); + if ( + route.request().method() !== "GET" || + url.pathname !== `/api/langgraph/threads/${THREAD_ID}` + ) { + return route.fallback(); + } + + if (!delayMetadataResponses) { + return route.fallback(); + } + + metadataRequestCount += 1; + if (metadataRequestCount > 1) { + markFreshMetadataRequestStarted(); + await freshMetadataResponseGate; + await route.fallback(); + markFreshMetadataResponseCompleted(); + return; + } + + markStaleMetadataRequestStarted(); + await staleMetadataResponseGate; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + thread_id: THREAD_ID, + created_at: "2026-07-05T10:00:00Z", + updated_at: "2026-07-05T10:00:00Z", + metadata: {}, + status: "idle", + values: { title: ORIGINAL_TITLE, goal: null }, + }), + }); + markStaleMetadataResponseCompleted(); + }); + + await page.goto(`/workspace/chats/${THREAD_ID}`); + await expect(page.locator("header").getByText(ORIGINAL_TITLE)).toBeVisible({ + timeout: 15_000, + }); + + delayMetadataResponses = true; + await page.getByRole("link", { name: "Chats", exact: true }).click(); + await expect(page).toHaveURL(/\/workspace\/chats$/); + await page + .locator( + `a[data-sidebar="menu-button"][href="/workspace/chats/${THREAD_ID}"]`, + ) + .click(); + await staleMetadataRequestStarted; + + const threadItem = page + .locator( + `a[data-sidebar="menu-button"][href="/workspace/chats/${THREAD_ID}"]`, + ) + .locator("xpath=.."); + await expect(threadItem).toContainText(ORIGINAL_TITLE); + await threadItem.hover(); + await threadItem.getByRole("button", { name: "More" }).click(); + await page.getByRole("menuitem", { name: "Rename" }).click(); + + const dialog = page.getByRole("dialog"); + await dialog.getByRole("textbox").fill(RENAMED_TITLE); + await dialog.getByRole("button", { name: "Save" }).click(); + await expect(dialog).toBeHidden(); + await expect(threadItem).toContainText(RENAMED_TITLE); + await expect(page.locator("header").getByText(RENAMED_TITLE)).toBeVisible(); + await expect(page).toHaveTitle(`${RENAMED_TITLE} - DeerFlow`); + + releaseStaleMetadataResponse(); + await staleMetadataResponseCompleted; + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + + // The stale response has completed while the fresh refetch is still blocked. + // The title must remain renamed without relying on the refetch to repair it. + await expect(threadItem).toContainText(RENAMED_TITLE); + await expect(page.locator("header").getByText(RENAMED_TITLE)).toBeVisible(); + await expect(page).toHaveTitle(`${RENAMED_TITLE} - DeerFlow`); + + await freshMetadataRequestStarted; + releaseFreshMetadataResponse(); + await freshMetadataResponseCompleted; + + await expect(threadItem).toContainText(RENAMED_TITLE); + await expect(page.locator("header").getByText(RENAMED_TITLE)).toBeVisible(); + await expect(page).toHaveTitle(`${RENAMED_TITLE} - DeerFlow`); +}); diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index f75f3bb41c..5401dce255 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -375,6 +375,18 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { return updated; }; + const patchThreadTitle = (threadId: string, title: string) => { + let updated: MockThread | undefined; + threads = threads.map((thread) => { + if (thread.thread_id !== threadId) { + return thread; + } + updated = { ...thread, title }; + return updated; + }); + return updated; + }; + // Auth — keep workspace tests independent from a real gateway session. void page.route("**/api/v1/auth/me", (route) => { if (route.request().method() === "GET") { @@ -1054,9 +1066,13 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { // Thread state — getState for individual thread void page.route("**/api/langgraph/threads/*/state", (route) => { + const url = new URL(route.request().url()); + const threadId = decodeURIComponent(url.pathname.split("/").at(-2) ?? ""); + const matchingThread = threads.find( + (thread) => thread.thread_id === threadId, + ); + if (route.request().method() === "GET") { - const url = route.request().url(); - const matchingThread = threads.find((t) => url.includes(t.thread_id)); return route.fulfill({ status: 200, contentType: "application/json", @@ -1086,6 +1102,33 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { }), }); } + if (route.request().method() === "POST") { + const body = route.request().postDataJSON() as { + values?: { title?: unknown }; + }; + const updated = + typeof body.values?.title === "string" + ? patchThreadTitle(threadId, body.values.title) + : matchingThread; + if (!updated) { + return route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ detail: "Thread not found" }), + }); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + configurable: { + thread_id: threadId, + checkpoint_ns: "", + checkpoint_id: "mock-checkpoint", + }, + }), + }); + } return route.fallback(); }); diff --git a/frontend/tests/unit/components/workspace/thread-title.dom.test.tsx b/frontend/tests/unit/components/workspace/thread-title.dom.test.tsx new file mode 100644 index 0000000000..f95df2e63a --- /dev/null +++ b/frontend/tests/unit/components/workspace/thread-title.dom.test.tsx @@ -0,0 +1,74 @@ +import type { BaseStream } from "@langchain/langgraph-sdk"; +import { afterEach, expect, rs, test } from "@rstest/core"; +import { cleanup, render } from "@testing-library/react"; +import type { ReactNode } from "react"; + +import { ThreadTitle } from "@/components/workspace/thread-title"; +import type { AgentThreadState } from "@/core/threads"; + +rs.mock("@/core/i18n/hooks", () => ({ + useI18n: () => ({ + t: { + pages: { + appName: "DeerFlow", + newChat: "New chat", + untitled: "Untitled", + }, + }, + }), +})); + +rs.mock("@/components/workspace/chats", () => ({ + useThreadChat: () => ({ isNewThread: false }), +})); + +rs.mock("@/components/workspace/flip-display", () => ({ + FlipDisplay: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +function makeThread(title: string): BaseStream { + return { + isThreadLoading: false, + values: { title }, + } as unknown as BaseStream; +} + +afterEach(() => { + cleanup(); + document.title = ""; +}); + +test("prefers the canonical metadata title over a stale stream title", () => { + const { container } = render( + , + ); + + expect(container.textContent).toBe("Renamed title"); + expect(document.title).toBe("Renamed title - DeerFlow"); +}); + +test("falls back to the stream title when metadata is unavailable", () => { + const { container } = render( + , + ); + + expect(container.textContent).toBe("Stream title"); + expect(document.title).toBe("Stream title - DeerFlow"); +}); + +test("falls back to the stream title when metadata title is empty", () => { + const { container } = render( + , + ); + + expect(container.textContent).toBe("Stream title"); + expect(document.title).toBe("Stream title - DeerFlow"); +}); diff --git a/frontend/tests/unit/core/threads/rename-thread.dom.test.tsx b/frontend/tests/unit/core/threads/rename-thread.dom.test.tsx new file mode 100644 index 0000000000..d3b2f47421 --- /dev/null +++ b/frontend/tests/unit/core/threads/rename-thread.dom.test.tsx @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, expect, rs, test } from "@rstest/core"; +import { + type InfiniteData, + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; + +const apiMocks = rs.hoisted(() => ({ + updateState: rs.fn(), +})); + +rs.mock("@/core/api", () => ({ + getAPIClient: () => ({ + threads: { + updateState: apiMocks.updateState, + }, + }), +})); + +import { + INFINITE_THREADS_QUERY_KEY_PREFIX, + useRenameThread, +} from "@/core/threads/hooks"; +import { DEFAULT_THREAD_SEARCH_PARAMS } from "@/core/threads/thread-search-query"; +import type { AgentThread } from "@/core/threads/types"; + +const THREAD_ID = "thread-1"; +const ORIGINAL_TITLE = "Original title"; +const RENAMED_TITLE = "Renamed title"; + +function makeThread(title: string): AgentThread { + return { + thread_id: THREAD_ID, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + metadata: {}, + status: "idle", + values: { title }, + } as unknown as AgentThread; +} + +function createDelayedResult(value: T) { + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let markCompleted!: () => void; + const completed = new Promise((resolve) => { + markCompleted = resolve; + }); + + return { + completed, + queryFn: async () => { + markStarted(); + await gate; + markCompleted(); + return value; + }, + release, + started, + }; +} + +function readSearchTitle( + queryClient: QueryClient, + queryKey: readonly unknown[], +) { + return queryClient.getQueryData(queryKey)?.[0]?.values.title; +} + +function readInfiniteSearchTitle( + queryClient: QueryClient, + queryKey: readonly unknown[], +) { + return queryClient.getQueryData>(queryKey) + ?.pages[0]?.[0]?.values.title; +} + +test("stale list responses cannot restore the old title after rename", async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const infiniteParams = { + sortBy: "updated_at", + sortOrder: "desc", + select: ["thread_id", "updated_at", "values", "metadata"], + }; + const searchKey = [ + "threads", + "search", + DEFAULT_THREAD_SEARCH_PARAMS, + ] as const; + const infiniteSearchKey = [ + ...INFINITE_THREADS_QUERY_KEY_PREFIX, + infiniteParams, + ] as const; + const originalThread = makeThread(ORIGINAL_TITLE); + const staleSearch = createDelayedResult([originalThread]); + const staleInfiniteSearch = createDelayedResult([originalThread]); + + queryClient.setQueryData(searchKey, [originalThread]); + queryClient.setQueryData>(infiniteSearchKey, { + pages: [[originalThread]], + pageParams: [0], + }); + + const searchFetch = queryClient + .fetchQuery({ + queryKey: searchKey, + queryFn: staleSearch.queryFn, + }) + .catch(() => undefined); + const infiniteSearchFetch = queryClient + .fetchInfiniteQuery({ + queryKey: infiniteSearchKey, + initialPageParam: 0, + queryFn: staleInfiniteSearch.queryFn, + getNextPageParam: () => undefined, + }) + .catch(() => undefined); + + await Promise.all([staleSearch.started, staleInfiniteSearch.started]); + apiMocks.updateState.mockResolvedValue(undefined); + const { result } = renderHook(() => useRenameThread(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync({ + threadId: THREAD_ID, + title: RENAMED_TITLE, + }); + }); + + expect(readSearchTitle(queryClient, searchKey)).toBe(RENAMED_TITLE); + expect(readInfiniteSearchTitle(queryClient, infiniteSearchKey)).toBe( + RENAMED_TITLE, + ); + + staleSearch.release(); + staleInfiniteSearch.release(); + await Promise.all([staleSearch.completed, staleInfiniteSearch.completed]); + await Promise.all([searchFetch, infiniteSearchFetch]); + + expect(readSearchTitle(queryClient, searchKey)).toBe(RENAMED_TITLE); + expect(readInfiniteSearchTitle(queryClient, infiniteSearchKey)).toBe( + RENAMED_TITLE, + ); + + queryClient.clear(); +}); + +beforeEach(() => { + apiMocks.updateState.mockReset(); +}); + +afterEach(() => { + cleanup(); +});