diff --git a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx index 2d8ca80143..1bbe33c583 100644 --- a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx @@ -12,7 +12,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BbHttpError, sdk } from "@/lib/sdk"; import { wsManager } from "@/lib/ws"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { createPerfPhaseLog } from "@/test/perf-phase"; import { + threadDetailBootstrapQueryKey, threadQueuedMessagesQueryKey, threadTimelineQueryKey, } from "../queries/query-keys"; @@ -44,6 +46,8 @@ vi.mock("@/lib/sdk", async (importOriginal) => { }, send: vi.fn(), spawn: vi.fn(), + get: vi.fn(), + timeline: vi.fn(), }, }, }; @@ -167,6 +171,12 @@ beforeEach(() => { delivery: "sent", }); vi.mocked(sdk.threads.spawn).mockResolvedValue(makeThreadResponse()); + vi.mocked(sdk.threads.get).mockResolvedValue({ + ...makeThreadResponse(), + environment: null, + host: null, + }); + vi.mocked(sdk.threads.timeline).mockResolvedValue(makeBannerTimeline()); vi.mocked(sdk.threads.queuedMessages.create).mockResolvedValue( makeQueuedMessage(), ); @@ -205,6 +215,46 @@ describe("thread runtime mutations", () => { ).toEqual([makeQueuedMessage()]); }); + it("starts thread bootstrap before the created thread view mounts", async () => { + const phase = createPerfPhaseLog(); + vi.mocked(sdk.threads.get).mockImplementation(() => { + phase.mark("bootstrap-get"); + return Promise.resolve({ + ...makeThreadResponse(), + environment: null, + host: null, + }); + }); + const { queryClient, wrapper } = createQueryClientTestHarness(); + const { result } = renderHook(() => useCreateThread(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync({ + projectId: "project-1", + environment: { type: "project-default" }, + input: [{ type: "text", text: "Hello", mentions: [] }], + }); + }); + phase.mark("create-returned"); + + await waitFor(() => { + expect(phase.names()).toContain("bootstrap-get"); + }); + phase.expectBefore("bootstrap-get", "create-returned"); + expect(sdk.threads.get).toHaveBeenCalledWith({ + include: "environment,host", + signal: expect.any(AbortSignal), + threadId: "thread-1", + }); + expect( + queryClient.getQueryData(threadDetailBootstrapQueryKey("thread-1")), + ).toEqual({ + ...makeThreadResponse(), + environment: null, + host: null, + }); + }); + it("keeps the existing timeline while an edit is pending and lets connected realtime own success", async () => { const { queryClient, wrapper } = createQueryClientTestHarness(); const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); diff --git a/apps/app/src/hooks/mutations/thread-runtime-mutations.ts b/apps/app/src/hooks/mutations/thread-runtime-mutations.ts index 1e8660539f..7cd45d37d4 100644 --- a/apps/app/src/hooks/mutations/thread-runtime-mutations.ts +++ b/apps/app/src/hooks/mutations/thread-runtime-mutations.ts @@ -9,6 +9,7 @@ import type { } from "@bb/server-contract"; import type { AppCreateThreadRequest } from "@bb/client-core"; import { BbHttpError, sdk } from "@/lib/sdk"; +import { prefetchThreadDetailBootstrap } from "@/hooks/queries/thread-queries"; import { wsManager } from "@/lib/ws"; import type { QueuedMessageReorderRequest } from "@/lib/queued-message-reorder"; import type { @@ -154,6 +155,7 @@ export function useCreateThread() { request: variables, thread, }); + prefetchThreadDetailBootstrap(queryClient, thread.id); }, }); } diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index 611e21948f..c45cb8add5 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -686,6 +686,71 @@ function liftThreadListPlaceholder( }; } +export async function loadThreadDetailBootstrap({ + queryClient, + signal, + threadId, +}: { + queryClient: QueryClient; + signal?: AbortSignal; + threadId: string; +}): Promise { + void queryClient.prefetchQuery({ + queryKey: threadTimelineQueryKey(threadId), + queryFn: () => + fetchThreadTimeline({ + queryClient, + signal, + threadId, + }), + }); + void queryClient.prefetchQuery({ + queryKey: threadQueuedMessagesQueryKey(threadId), + queryFn: () => + sdk.threads.queuedMessages.list({ + threadId, + signal, + }), + }); + void queryClient.prefetchQuery({ + queryKey: threadPendingInteractionsQueryKey(threadId), + queryFn: () => + sdk.threads.interactions.list({ + threadId, + signal, + }), + }); + + const thread = await sdk.threads.get({ + include: "environment,host", + threadId, + signal, + }); + ingestThreadDetailBootstrap({ + queryClient, + thread, + }); + return thread; +} + +export function prefetchThreadDetailBootstrap( + queryClient: QueryClient, + threadId: string, +): void { + void queryClient.prefetchQuery({ + queryKey: threadDetailBootstrapQueryKey(threadId), + queryFn: ({ signal }) => + loadThreadDetailBootstrap({ + queryClient, + signal, + threadId, + }), + staleTime: Infinity, + retry: shouldRetryTransientReadQuery, + retryDelay: TRANSIENT_READ_RETRY_DELAY_MS, + }); +} + export function useThreadDetailBootstrap( id: string, options?: ThreadDetailBootstrapQueryOptions, @@ -696,45 +761,12 @@ export function useThreadDetailBootstrap( return useQuery({ queryKey: threadDetailBootstrapQueryKey(id), - queryFn: async ({ signal }) => { - const threadId = requireThreadId(id, "useThreadDetailBootstrap"); - void queryClient.prefetchQuery({ - queryKey: threadTimelineQueryKey(threadId), - queryFn: () => - fetchThreadTimeline({ - queryClient, - signal, - threadId, - }), - }); - void queryClient.prefetchQuery({ - queryKey: threadQueuedMessagesQueryKey(threadId), - queryFn: () => - sdk.threads.queuedMessages.list({ - threadId, - signal, - }), - }); - void queryClient.prefetchQuery({ - queryKey: threadPendingInteractionsQueryKey(threadId), - queryFn: () => - sdk.threads.interactions.list({ - threadId, - signal, - }), - }); - - const thread = await sdk.threads.get({ - include: "environment,host", - threadId, - signal, - }); - ingestThreadDetailBootstrap({ + queryFn: ({ signal }) => + loadThreadDetailBootstrap({ queryClient, - thread, - }); - return thread; - }, + signal, + threadId: requireThreadId(id, "useThreadDetailBootstrap"), + }), enabled, staleTime: Infinity, retry: shouldRetryTransientReadQuery,