diff --git a/apps/app/src/hooks/queries/environment-queries.test.tsx b/apps/app/src/hooks/queries/environment-queries.test.tsx index 119fd7b655..5a65ac9a26 100644 --- a/apps/app/src/hooks/queries/environment-queries.test.tsx +++ b/apps/app/src/hooks/queries/environment-queries.test.tsx @@ -1,18 +1,24 @@ // @vitest-environment jsdom import { cleanup, renderHook, waitFor } from "@testing-library/react"; -import type { ThreadPullRequest } from "@bb/domain"; +import { QueryClient } from "@tanstack/react-query"; +import type { Environment, ThreadPullRequest } from "@bb/domain"; import type { EnvironmentPullRequestResponse } from "@bb/server-contract"; import { sdk } from "@/lib/sdk"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { environmentPullRequestQueryKey } from "./query-keys"; +import { + environmentPullRequestQueryKey, + environmentQueryKey, +} from "./query-keys"; import { buildEnvironmentFilePreview, getEnvironmentPullRequestRefetchInterval, getEnvironmentPullRequestStaleTime, + resolveEnvironmentQueryMount, useEnvironmentPullRequest, } from "./environment-queries"; +import { resolveGitDiffTabStatus } from "@/components/secondary-panel/gitDiffTabEligibility"; vi.mock("@/lib/sdk", () => ({ sdk: { environments: { pullRequest: vi.fn() } }, @@ -232,3 +238,64 @@ describe("buildEnvironmentFilePreview", () => { expect(preview.url).toBe(`data:image/png;base64,${pngBase64}`); }); }); + +const CACHED_ENVIRONMENT: Environment = { + id: "env-1", + name: "Workspace", + projectId: "project-1", + hostId: "host-1", + path: "/tmp/workspace", + managed: true, + isGitRepo: true, + isWorktree: false, + workspaceProvisionType: "unmanaged", + branchName: "main", + baseBranch: "main", + defaultBranch: "main", + mergeBaseBranch: null, + status: "ready", + createdAt: 1, + updatedAt: 1, +}; + +describe("resolveEnvironmentQueryMount", () => { + it("enables a cached environment while bootstrap is still in flight and does not refetch", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(environmentQueryKey("env-1"), CACHED_ENVIRONMENT); + expect( + resolveEnvironmentQueryMount({ + bootstrapSettled: false, + environmentId: "env-1", + queryClient, + }), + ).toEqual({ enabled: true, refetchOnMount: false }); + expect( + resolveGitDiffTabStatus({ + environmentId: "env-1", + environmentIsGitRepo: CACHED_ENVIRONMENT.isGitRepo, + environmentLoadFailed: false, + hasResolvedThread: true, + }), + ).toBe("eligible"); + }); + + it("does not start an environment read until bootstrap when the cache is empty", () => { + expect( + resolveEnvironmentQueryMount({ + bootstrapSettled: false, + environmentId: "env-1", + queryClient: new QueryClient(), + }), + ).toEqual({ enabled: false, refetchOnMount: false }); + }); + + it("enables after bootstrap settles so a cold open can fetch", () => { + expect( + resolveEnvironmentQueryMount({ + bootstrapSettled: true, + environmentId: "env-1", + queryClient: new QueryClient(), + }), + ).toEqual({ enabled: true, refetchOnMount: true }); + }); +}); diff --git a/apps/app/src/hooks/queries/environment-queries.ts b/apps/app/src/hooks/queries/environment-queries.ts index 740fed7ad9..2d5044df47 100644 --- a/apps/app/src/hooks/queries/environment-queries.ts +++ b/apps/app/src/hooks/queries/environment-queries.ts @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, type QueryClient } from "@tanstack/react-query"; import type { Environment, ThreadPullRequest, @@ -49,9 +49,27 @@ import { } from "./query-policies"; interface EnvironmentQueryOptions extends QueryOptions { + refetchOnMount?: boolean | "always"; staleTime?: number; } +export function resolveEnvironmentQueryMount(args: { + bootstrapSettled: boolean; + environmentId: string | null | undefined; + queryClient: QueryClient; +}): { enabled: boolean; refetchOnMount: boolean } { + const hasCachedEnvironment = + Boolean(args.environmentId) && + args.queryClient.getQueryData(environmentQueryKey(args.environmentId)) !== + undefined; + return { + enabled: + Boolean(args.environmentId) && + (hasCachedEnvironment || args.bootstrapSettled), + refetchOnMount: args.bootstrapSettled, + }; +} + interface BranchQueryOptions extends QueryOptions { limit?: number; query?: string; @@ -95,6 +113,7 @@ export function useEnvironment( signal, }), enabled, + refetchOnMount: options?.refetchOnMount, staleTime: options?.staleTime, }); } diff --git a/apps/app/src/hooks/queries/host-queries.test.ts b/apps/app/src/hooks/queries/host-queries.test.ts index 52ac7f839c..cf0feac469 100644 --- a/apps/app/src/hooks/queries/host-queries.test.ts +++ b/apps/app/src/hooks/queries/host-queries.test.ts @@ -1,6 +1,8 @@ +import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { Host } from "@bb/domain"; -import { selectPrimaryHost } from "./host-queries"; +import { resolveHostsQueryMount, selectPrimaryHost } from "./host-queries"; +import { hostsQueryKey } from "./query-keys"; function host(overrides: Partial & Pick): Host { return { @@ -41,3 +43,27 @@ describe("selectPrimaryHost", () => { expect(selectPrimaryHost([], null)).toBeNull(); }); }); + +describe("resolveHostsQueryMount", () => { + it("enables a cached host list while bootstrap is still in flight and does not refetch", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(hostsQueryKey(), [host({ id: "host-1" })]); + expect( + resolveHostsQueryMount({ + bootstrapSettled: false, + environmentId: "env-1", + queryClient, + }), + ).toEqual({ enabled: true, refetchOnMount: false }); + }); + + it("does not start a hosts read until bootstrap when the cache is empty", () => { + expect( + resolveHostsQueryMount({ + bootstrapSettled: false, + environmentId: "env-1", + queryClient: new QueryClient(), + }), + ).toEqual({ enabled: false, refetchOnMount: false }); + }); +}); diff --git a/apps/app/src/hooks/queries/host-queries.ts b/apps/app/src/hooks/queries/host-queries.ts index 0908ae93e1..244de0c2ad 100644 --- a/apps/app/src/hooks/queries/host-queries.ts +++ b/apps/app/src/hooks/queries/host-queries.ts @@ -1,5 +1,10 @@ import { useMemo } from "react"; -import { keepPreviousData, skipToken, useQuery } from "@tanstack/react-query"; +import { + keepPreviousData, + skipToken, + useQuery, + type QueryClient, +} from "@tanstack/react-query"; import type { Host } from "@bb/domain"; import type { HostDirectoryListing } from "@bb/server-contract"; import { sdk } from "@/lib/sdk"; @@ -12,7 +17,26 @@ import { } from "./query-keys"; import type { QueryOptions } from "./query-helpers"; -export function useHosts(options?: QueryOptions) { +interface HostListQueryOptions extends QueryOptions { + refetchOnMount?: boolean | "always"; +} + +export function resolveHostsQueryMount(args: { + bootstrapSettled: boolean; + environmentId: string | null | undefined; + queryClient: QueryClient; +}): { enabled: boolean; refetchOnMount: boolean } { + const hasEnvironment = + args.environmentId !== null && args.environmentId !== undefined; + const hasCachedHosts = + args.queryClient.getQueryData(hostsQueryKey()) !== undefined; + return { + enabled: hasEnvironment && (hasCachedHosts || args.bootstrapSettled), + refetchOnMount: args.bootstrapSettled, + }; +} + +export function useHosts(options?: HostListQueryOptions) { const enabled = options?.enabled ?? true; useHostListRealtimeSubscription({ enabled }); @@ -20,6 +44,7 @@ export function useHosts(options?: QueryOptions) { queryKey: hostsQueryKey(), queryFn: ({ signal }) => sdk.hosts.list({ signal }), enabled, + refetchOnMount: options?.refetchOnMount, staleTime: 60_000, }); } diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index ac508d09c8..34b04a1ff6 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"; @@ -58,6 +59,7 @@ import { useUpdateEnvironment } from "../../hooks/mutations/environment-mutation import { useEnvironment, getEnvironmentPullRequestFromResponse, + resolveEnvironmentQueryMount, useEnvironmentPullRequest, useEnvironmentWorkStatus, } from "../../hooks/queries/environment-queries"; @@ -97,7 +99,7 @@ import { assertNever } from "@bb/thread-view"; import { useCreateThreadInWorktree } from "@/hooks/useCreateThreadInWorktree"; import { useHostDaemon } from "@/hooks/useHostDaemon"; import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets"; -import { useHosts } from "@/hooks/queries/host-queries"; +import { resolveHostsQueryMount, useHosts } from "@/hooks/queries/host-queries"; import { useSystemConfig } from "@/hooks/queries/system-queries"; import { useConnectionAwareQueryState } from "@/hooks/queries/connection-aware-query-state"; import { @@ -513,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); @@ -526,8 +529,14 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { } = useThread(threadId, { bootstrap: threadDetailBootstrapQuery, }); + const environmentQueryMount = resolveEnvironmentQueryMount({ + bootstrapSettled: hasThreadDetailBootstrapSettled, + environmentId: thread?.environmentId, + queryClient, + }); const environmentQuery = useEnvironment(thread?.environmentId, { - enabled: hasThreadDetailBootstrapSettled, + enabled: environmentQueryMount.enabled, + refetchOnMount: environmentQueryMount.refetchOnMount, staleTime: 5_000, }); const environment = environmentQuery.data; @@ -903,11 +912,14 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { terminalsListQuery.data, updateFixedPanelTabsState, ]); + const hostsQueryMount = resolveHostsQueryMount({ + bootstrapSettled: hasThreadDetailBootstrapSettled, + environmentId: thread?.environmentId, + queryClient, + }); const hostsQuery = useHosts({ - enabled: - hasThreadDetailBootstrapSettled && - thread?.environmentId !== null && - thread?.environmentId !== undefined, + enabled: hostsQueryMount.enabled, + refetchOnMount: hostsQueryMount.refetchOnMount, }); const connectedHostIds = useMemo( () =>