From 743b5dfac6b14919f4dfa0069e886049e7a08bdf Mon Sep 17 00:00:00 2001 From: Guitaraholic Date: Wed, 2 Sep 2026 17:08:57 +0100 Subject: [PATCH 1/2] Prefetch timeline, queue, and approvals with thread bootstrap. Opening a thread waited for GET /threads/:id before starting the payloads the view needs next. Start those reads in the same turn so React Query joins in-flight work instead of a second serial round. Fixes #1303 --- apps/app/src/components/layout/AppLayout.tsx | 1 - .../src/hooks/queries/thread-queries.test.tsx | 22 ++++------ apps/app/src/hooks/queries/thread-queries.ts | 42 ++++++++++++------- 3 files changed, 33 insertions(+), 32 deletions(-) diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx index 1375802d48..f45b06f0dc 100644 --- a/apps/app/src/components/layout/AppLayout.tsx +++ b/apps/app/src/components/layout/AppLayout.tsx @@ -513,7 +513,6 @@ export function AppLayout({ children }: AppLayoutProps) { ); const threadDetailBootstrapQuery = useThreadDetailBootstrap(threadId ?? "", { enabled: isThreadView && Boolean(threadId), - timelinePrefetch: isThreadView && Boolean(threadId), }); const hasThreadDetailBootstrapSettled = threadDetailBootstrapQuery.isSuccess || threadDetailBootstrapQuery.isError; diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index 20489097c4..8048ab93e1 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -186,7 +186,7 @@ beforeEach(() => { }); describe("useThreadDetailBootstrap", () => { - it("starts the timeline request before the thread bootstrap settles", async () => { + it("starts timeline, queued, and pending-interaction reads before the thread bootstrap settles", async () => { let resolveThread: | ((thread: ThreadWithIncludesResponse) => void) | undefined; @@ -196,17 +196,15 @@ describe("useThreadDetailBootstrap", () => { vi.mocked(sdk.threads.get).mockReturnValue(threadPromise); const { wrapper } = createQueryClientTestHarness(); - const result = renderHook( - () => - useThreadDetailBootstrap("thread-1", { - timelinePrefetch: true, - }), - { wrapper }, - ); + const result = renderHook(() => useThreadDetailBootstrap("thread-1"), { + wrapper, + }); await waitFor(() => { expect(sdk.threads.get).toHaveBeenCalledTimes(1); expect(sdk.threads.timeline).toHaveBeenCalledTimes(1); + expect(sdk.threads.queuedMessages.list).toHaveBeenCalledTimes(1); + expect(sdk.threads.interactions.list).toHaveBeenCalledTimes(1); }); expect(result.result.current.isPending).toBe(true); @@ -263,13 +261,7 @@ describe("useThreadDetailBootstrap", () => { { updatedAt: 1 }, ); - renderHook( - () => - useThreadDetailBootstrap("thread-1", { - timelinePrefetch: true, - }), - { wrapper }, - ); + renderHook(() => useThreadDetailBootstrap("thread-1"), { wrapper }); await waitFor(() => { expect(sdk.threads.timeline).toHaveBeenCalledWith({ diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index d04dc0b9bd..949ca96148 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -100,9 +100,7 @@ const THREAD_SEARCH_DEBOUNCE_MS = 150; export const THREAD_SEARCH_LIMIT_PER_GROUP = 20; const THREAD_SEARCH_MIN_NON_WHITESPACE_CHARS = 2; -interface ThreadDetailBootstrapQueryOptions extends QueryOptions { - timelinePrefetch?: boolean; -} +type ThreadDetailBootstrapQueryOptions = QueryOptions; export function didThreadDetailBootstrapRefreshAfterMount(query: { dataUpdatedAt: number; @@ -663,19 +661,31 @@ export function useThreadDetailBootstrap( queryKey: threadDetailBootstrapQueryKey(id), queryFn: async ({ signal }) => { const threadId = requireThreadId(id, "useThreadDetailBootstrap"); - const timelinePrefetch = options?.timelinePrefetch ?? false; - - if (timelinePrefetch) { - void queryClient.prefetchQuery({ - queryKey: threadTimelineQueryKey(threadId), - queryFn: ({ signal: timelineSignal }) => - fetchThreadTimeline({ - queryClient, - signal: timelineSignal, - threadId, - }), - }); - } + 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", From 78e8883d7915cedace40a847798dc4b232ebf2d9 Mon Sep 17 00:00:00 2001 From: Guitaraholic Date: Wed, 2 Sep 2026 18:32:22 +0100 Subject: [PATCH 2/2] Measure that timeline, queue, and approvals start before bootstrap settles. Phase log records sidecar-reads-started while the include GET is still pending, then requires that mark to precede bootstrap-settled. --- .../src/hooks/queries/thread-queries.test.tsx | 20 ++++++++--- apps/app/src/test/perf-phase.ts | 33 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 apps/app/src/test/perf-phase.ts diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index 8048ab93e1..9fbc9c4425 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -13,6 +13,7 @@ import * as api from "@/lib/api"; import { sdk } from "@/lib/sdk"; import { makeThreadListEntry } from "@/test/fixtures/thread-list-entries"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { createPerfPhaseLog } from "@/test/perf-phase"; import { ARCHIVED_THREADS_PAGE_SIZE } from "./archived-threads-page-size"; import { sidebarNavigationQueryKey, @@ -187,18 +188,26 @@ beforeEach(() => { describe("useThreadDetailBootstrap", () => { it("starts timeline, queued, and pending-interaction reads before the thread bootstrap settles", async () => { + const phase = createPerfPhaseLog(); let resolveThread: | ((thread: ThreadWithIncludesResponse) => void) | undefined; const threadPromise = new Promise((resolve) => { - resolveThread = resolve; + resolveThread = (thread) => { + phase.mark("bootstrap-settled"); + resolve(thread); + }; }); vi.mocked(sdk.threads.get).mockReturnValue(threadPromise); const { wrapper } = createQueryClientTestHarness(); - const result = renderHook(() => useThreadDetailBootstrap("thread-1"), { - wrapper, - }); + const result = renderHook( + () => + useThreadDetailBootstrap("thread-1", { + timelinePrefetch: true, + }), + { wrapper }, + ); await waitFor(() => { expect(sdk.threads.get).toHaveBeenCalledTimes(1); @@ -207,11 +216,14 @@ describe("useThreadDetailBootstrap", () => { expect(sdk.threads.interactions.list).toHaveBeenCalledTimes(1); }); expect(result.result.current.isPending).toBe(true); + phase.mark("sidecar-reads-started"); + expect(phase.names()).not.toContain("bootstrap-settled"); resolveThread?.(THREAD_WITH_INCLUDES); await waitFor(() => { expect(result.result.current.isSuccess).toBe(true); }); + phase.expectBefore("sidecar-reads-started", "bootstrap-settled"); }); it("uses the cached timeline sequence and merges a prefetched delta", async () => { diff --git a/apps/app/src/test/perf-phase.ts b/apps/app/src/test/perf-phase.ts new file mode 100644 index 0000000000..1197089765 --- /dev/null +++ b/apps/app/src/test/perf-phase.ts @@ -0,0 +1,33 @@ +import { expect } from "vitest"; + +interface PerfPhaseEvent { + at: number; + name: string; +} + +export function createPerfPhaseLog() { + const events: PerfPhaseEvent[] = []; + + return { + mark(name: string) { + events.push({ at: performance.now(), name }); + }, + names(): string[] { + return events.map((event) => event.name); + }, + expectBefore(earlier: string, later: string) { + const earlierEvent = events.find((event) => event.name === earlier); + const laterEvent = events.find((event) => event.name === later); + expect(earlierEvent, `missing phase "${earlier}"`).toEqual( + expect.objectContaining({ name: earlier }), + ); + expect(laterEvent, `missing phase "${later}"`).toEqual( + expect.objectContaining({ name: later }), + ); + expect( + earlierEvent!.at, + `expected "${earlier}" before "${later}"`, + ).toBeLessThan(laterEvent!.at); + }, + }; +}