From 2fbbe0a091028c7fc5de23d2bedcd526bc05000e Mon Sep 17 00:00:00 2001 From: Guitaraholic Date: Wed, 2 Sep 2026 18:17:49 +0100 Subject: [PATCH 1/2] Start thread bootstrap as soon as create succeeds. Create already writes threadQueryKey, then navigates. Bootstrap (GET /threads/:id?include=environment,host, and timeline when the shell asked for it) waited until ThreadDetailView mounted. Prefetch the same query in useCreateThread onSuccess so navigate joins work already in flight. --- .../thread-runtime-mutations.test.tsx | 37 ++++++ .../mutations/thread-runtime-mutations.ts | 2 + apps/app/src/hooks/queries/thread-queries.ts | 108 ++++++++++++------ 3 files changed, 109 insertions(+), 38 deletions(-) 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..5b0489c8aa 100644 --- a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx @@ -13,6 +13,7 @@ import { BbHttpError, sdk } from "@/lib/sdk"; import { wsManager } from "@/lib/ws"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { + threadDetailBootstrapQueryKey, threadQueuedMessagesQueryKey, threadTimelineQueryKey, } from "../queries/query-keys"; @@ -44,6 +45,8 @@ vi.mock("@/lib/sdk", async (importOriginal) => { }, send: vi.fn(), spawn: vi.fn(), + get: vi.fn(), + timeline: vi.fn(), }, }, }; @@ -167,6 +170,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 +214,34 @@ describe("thread runtime mutations", () => { ).toEqual([makeQueuedMessage()]); }); + it("starts thread bootstrap before the created thread view mounts", async () => { + 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: [] }], + }); + }); + + await waitFor(() => { + 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, From 725dd71bcda92f44531f61037710cd97d9654e51 Mon Sep 17 00:00:00 2001 From: Guitaraholic Date: Wed, 2 Sep 2026 18:32:22 +0100 Subject: [PATCH 2/2] Measure that create starts bootstrap before the thread view can mount. Phase log records bootstrap-get during useCreateThread onSuccess and requires it to land before mutateAsync returns. --- .../thread-runtime-mutations.test.tsx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) 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 5b0489c8aa..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,6 +12,7 @@ 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, @@ -215,6 +216,15 @@ describe("thread runtime mutations", () => { }); 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 }); @@ -225,13 +235,16 @@ describe("thread runtime mutations", () => { input: [{ type: "text", text: "Hello", mentions: [] }], }); }); + phase.mark("create-returned"); await waitFor(() => { - expect(sdk.threads.get).toHaveBeenCalledWith({ - include: "environment,host", - signal: expect.any(AbortSignal), - threadId: "thread-1", - }); + 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")),