From 76dda1d21b477d9480b2b3fff0c9dce353d182d4 Mon Sep 17 00:00:00 2001 From: Guitaraholic Date: Wed, 2 Sep 2026 18:07:18 +0100 Subject: [PATCH 1/2] Paint the thread view from cache while bootstrap is in flight. Create already writes threadQueryKey, but ThreadDetailView disabled useThread until GET /threads/:id?include=environment,host settled, so the first paint after send was a skeleton. Read the cached thread immediately and keep the duplicate GET suppressed until bootstrap finishes. --- .../src/hooks/queries/thread-queries.test.tsx | 92 ++++++++++++++----- apps/app/src/hooks/queries/thread-queries.ts | 24 +++++ .../views/thread-detail/ThreadDetailView.tsx | 20 ++-- 3 files changed, 104 insertions(+), 32 deletions(-) diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index 20489097c4..9634040065 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { useQueryClient } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PendingInteraction, ThreadListEntry } from "@bb/domain"; import type { @@ -27,6 +28,7 @@ import { COMPACT_THREAD_TIMELINE_SEGMENT_LIMIT, didThreadDetailBootstrapRefreshAfterMount, isPendingInteractionStateUnknown, + resolveThreadDetailQueryMount, useArchivedThreads, useChildThreads, useThread, @@ -319,18 +321,9 @@ describe("useThreadDetailBootstrap", () => { updatedAt, }); - const result = renderHook( - () => { - const bootstrap = useThreadDetailBootstrap("thread-1"); - return useThread("thread-1", { - enabled: bootstrap.isSuccess, - refetchOnMount: didThreadDetailBootstrapRefreshAfterMount(bootstrap) - ? false - : "always", - }); - }, - { wrapper }, - ); + const result = renderHook(() => useMountedThreadQuery("thread-1"), { + wrapper, + }); await waitFor(() => { expect(result.result.current.isSuccess).toBe(true); @@ -349,29 +342,82 @@ describe("useThreadDetailBootstrap", () => { updatedAt: 1, }); - renderHook( - () => { - const bootstrap = useThreadDetailBootstrap("thread-1"); - return useThread("thread-1", { - enabled: bootstrap.isSuccess, - refetchOnMount: didThreadDetailBootstrapRefreshAfterMount(bootstrap) - ? false - : "always", - }); - }, - { wrapper }, + renderHook(() => useMountedThreadQuery("thread-1"), { wrapper }); + + await waitFor(() => { + expect(sdk.threads.get).toHaveBeenCalledTimes(1); + }); + expect(sdk.threads.get).toHaveBeenCalledWith({ + signal: expect.any(AbortSignal), + threadId: "thread-1", + }); + }); + + it("reads a cached thread while bootstrap is still in flight", async () => { + let resolveThread: + | ((thread: ThreadWithIncludesResponse) => void) + | undefined; + vi.mocked(sdk.threads.get).mockReturnValue( + new Promise((resolve) => { + resolveThread = resolve; + }), ); + const { queryClient, wrapper } = createQueryClientTestHarness(); + queryClient.setQueryData(threadQueryKey("thread-1"), THREAD_WITH_INCLUDES); + + const result = renderHook(() => useMountedThreadQuery("thread-1"), { + wrapper, + }); + expect(result.result.current.data).toEqual(THREAD_WITH_INCLUDES); + expect(result.result.current.isSuccess).toBe(true); await waitFor(() => { expect(sdk.threads.get).toHaveBeenCalledTimes(1); }); expect(sdk.threads.get).toHaveBeenCalledWith({ + include: "environment,host", + signal: expect.any(AbortSignal), + threadId: "thread-1", + }); + resolveThread?.(THREAD_WITH_INCLUDES); + }); + + it("does not start a thread read until bootstrap when the thread cache is empty", async () => { + vi.mocked(sdk.threads.get).mockReturnValue( + new Promise(() => {}), + ); + const { wrapper } = createQueryClientTestHarness(); + + const result = renderHook(() => useMountedThreadQuery("thread-1"), { + wrapper, + }); + + expect(result.result.current.data).toBeUndefined(); + expect(result.result.current.fetchStatus).toBe("idle"); + await waitFor(() => { + expect(sdk.threads.get).toHaveBeenCalledTimes(1); + }); + expect(sdk.threads.get).toHaveBeenCalledWith({ + include: "environment,host", signal: expect.any(AbortSignal), threadId: "thread-1", }); }); }); +function useMountedThreadQuery(threadId: string) { + const queryClient = useQueryClient(); + const bootstrap = useThreadDetailBootstrap(threadId); + return useThread( + threadId, + resolveThreadDetailQueryMount({ + bootstrap, + queryClient, + threadId, + }), + ); +} + describe("useArchivedThreads", () => { it("loads archived threads across all projects when no scope is selected", async () => { const { wrapper } = createQueryClientTestHarness(); diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index d04dc0b9bd..3437c4e790 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -117,6 +117,30 @@ export function didThreadDetailBootstrapRefreshAfterMount(query: { ); } +export function resolveThreadDetailQueryMount(args: { + bootstrap: { + dataUpdatedAt: number; + isError: boolean; + isFetchedAfterMount: boolean; + isSuccess: boolean; + }; + queryClient: QueryClient; + threadId: string; +}): { enabled: boolean; refetchOnMount: boolean | "always" } { + const bootstrapSettled = args.bootstrap.isSuccess || args.bootstrap.isError; + const hasCachedThread = + Boolean(args.threadId) && + args.queryClient.getQueryData(threadQueryKey(args.threadId)) !== undefined; + return { + enabled: hasCachedThread || bootstrapSettled, + refetchOnMount: didThreadDetailBootstrapRefreshAfterMount(args.bootstrap) + ? false + : bootstrapSettled + ? "always" + : false, + }; +} + type ThreadTimelineQueryOptions = QueryOptions; type ThreadTimelineTurnSummaryDetailsQueryOptions = QueryOptions; diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index d3f9b2435c..3c42eafdef 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -6,6 +6,7 @@ import { useState, type ReactNode, } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { nanoid } from "nanoid"; import { useSystemProviderInfo } from "@/hooks/queries/system-queries"; import { useNavigate } from "react-router-dom"; @@ -66,9 +67,9 @@ import { type ChildThreadPendingAttentionSource, } from "../../hooks/queries/child-thread-pending-interactions"; import { - didThreadDetailBootstrapRefreshAfterMount, getLatestPendingInteraction, isPendingInteractionStateUnknown, + resolveThreadDetailQueryMount, useChildThreads, useProjectThreadSubset, useThread, @@ -514,6 +515,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { const { isFocused, navigateInPane, onRequestClose, isBoundedPane } = usePaneContext(); const navigate = useNavigate(); + const queryClient = useQueryClient(); useFixedPanelTabsStorageMaintenance(); const systemConfigQuery = useSystemConfig(); const threadDetailBootstrapQuery = useThreadDetailBootstrap(threadId); @@ -524,14 +526,14 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { isFetching, isLoadingError, error, - } = useThread(threadId, { - enabled: hasThreadDetailBootstrapSettled, - refetchOnMount: didThreadDetailBootstrapRefreshAfterMount( - threadDetailBootstrapQuery, - ) - ? false - : "always", - }); + } = useThread( + threadId, + resolveThreadDetailQueryMount({ + bootstrap: threadDetailBootstrapQuery, + queryClient, + threadId, + }), + ); const environmentQuery = useEnvironment(thread?.environmentId, { enabled: hasThreadDetailBootstrapSettled, staleTime: 5_000, From 2a03ab75983745603b09c2b51c0bd8250967221b Mon Sep 17 00:00:00 2001 From: Guitaraholic Date: Wed, 2 Sep 2026 18:32:22 +0100 Subject: [PATCH 2/2] Measure that cached thread chrome is ready before bootstrap settles. Phase log records thread-chrome-ready while GET /threads/:id is still in flight, then requires that mark to precede bootstrap-settled. --- .../src/hooks/queries/thread-queries.test.tsx | 13 +++++++- apps/app/src/test/perf-phase.ts | 33 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) 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 9634040065..7b8399ccfd 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -14,6 +14,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, @@ -354,12 +355,16 @@ describe("useThreadDetailBootstrap", () => { }); it("reads a cached thread while bootstrap is still in flight", async () => { + const phase = createPerfPhaseLog(); let resolveThread: | ((thread: ThreadWithIncludesResponse) => void) | undefined; vi.mocked(sdk.threads.get).mockReturnValue( new Promise((resolve) => { - resolveThread = resolve; + resolveThread = (thread) => { + phase.mark("bootstrap-settled"); + resolve(thread); + }; }), ); const { queryClient, wrapper } = createQueryClientTestHarness(); @@ -371,6 +376,8 @@ describe("useThreadDetailBootstrap", () => { expect(result.result.current.data).toEqual(THREAD_WITH_INCLUDES); expect(result.result.current.isSuccess).toBe(true); + phase.mark("thread-chrome-ready"); + expect(phase.names()).not.toContain("bootstrap-settled"); await waitFor(() => { expect(sdk.threads.get).toHaveBeenCalledTimes(1); }); @@ -380,6 +387,10 @@ describe("useThreadDetailBootstrap", () => { threadId: "thread-1", }); resolveThread?.(THREAD_WITH_INCLUDES); + await waitFor(() => { + expect(phase.names()).toContain("bootstrap-settled"); + }); + phase.expectBefore("thread-chrome-ready", "bootstrap-settled"); }); it("does not start a thread read until bootstrap when the thread cache is empty", 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); + }, + }; +}