diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx index b6311eff1af..1d66b2d0f79 100644 --- a/ui/src/components/IssueLinkQuicklook.tsx +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -6,8 +6,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { timeAgo } from "@/lib/timeAgo"; import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb"; import { - fetchIssueDetail, - getCachedIssueDetail, + getIssueDetailQueryOptions, ISSUE_DETAIL_STALE_TIME_MS, prefetchIssueDetail, } from "@/lib/issueDetailCache"; @@ -98,12 +97,9 @@ export const IssueLinkQuicklook = React.forwardRef< const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const prefetchedState = issuePrefetch ? withIssueDetailHeaderSeed(state, issuePrefetch) : state; - const cachedIssue = getCachedIssueDetail(queryClient, issuePathId, issuePrefetch ?? undefined); const { data, isLoading } = useQuery({ - queryKey: queryKeys.issues.detail(issuePathId), - queryFn: () => fetchIssueDetail(queryClient, issuePathId), + ...getIssueDetailQueryOptions(queryClient, issuePathId, { placeholderIssue: issuePrefetch ?? undefined }), enabled: open, - initialData: () => cachedIssue, staleTime: ISSUE_DETAIL_STALE_TIME_MS, }); diff --git a/ui/src/lib/issueDetailCache.ts b/ui/src/lib/issueDetailCache.ts index 8e02e46eb81..a2770b3118d 100644 --- a/ui/src/lib/issueDetailCache.ts +++ b/ui/src/lib/issueDetailCache.ts @@ -84,6 +84,20 @@ export async function fetchIssueDetail( return seedIssueDetailCache(queryClient, issue, { issueRef }); } +export function getIssueDetailQueryOptions( + queryClient: QueryClient, + issueRef: string, + options?: { + placeholderIssue?: Pick | null; + }, +) { + return { + queryKey: queryKeys.issues.detail(issueRef), + queryFn: () => fetchIssueDetail(queryClient, issueRef), + placeholderData: getCachedIssueDetail(queryClient, issueRef, options?.placeholderIssue ?? undefined), + }; +} + export function prefetchIssueDetail( queryClient: QueryClient, issueRef: string, diff --git a/ui/src/lib/issueDetailQuery.test.tsx b/ui/src/lib/issueDetailQuery.test.tsx new file mode 100644 index 00000000000..83be05ddb11 --- /dev/null +++ b/ui/src/lib/issueDetailQuery.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Issue } from "@paperclipai/shared"; +import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { issuesApi } from "@/api/issues"; +import { queryKeys } from "@/lib/queryKeys"; +import { getIssueDetailQueryOptions } from "./issueDetailCache"; + +vi.mock("@/api/issues", () => ({ + issuesApi: { + get: vi.fn(), + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function makeIssue(overrides: Partial = {}): Issue { + const now = new Date("2026-04-13T20:00:00.000Z"); + return { + id: "issue-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Issue title", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1442, + identifier: "PAP-1442", + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function IssueDetailQueryHarness({ + issueRef, + placeholderIssue, +}: { + issueRef: string; + placeholderIssue?: Pick | null; +}) { + const queryClient = useQueryClient(); + const query = useQuery({ + ...getIssueDetailQueryOptions(queryClient, issueRef, { placeholderIssue }), + }); + + return
{query.data?.description ?? "EMPTY"}
; +} + +async function flush() { + // Multiple act cycles to allow React Query to process the async queryFn + for (let i = 0; i < 5; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +describe("getIssueDetailQueryOptions", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("treats cached issue data as placeholder and still fetches full detail", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + const partialIssue = makeIssue({ description: null }); + const fullIssue = makeIssue({ description: "GitHub Security Advisory body" }); + + queryClient.setQueryData(queryKeys.issues.detail("issue-1"), partialIssue); + queryClient.setQueryData(queryKeys.issues.detail("PAP-1442"), partialIssue); + vi.mocked(issuesApi.get).mockResolvedValue(fullIssue); + + await act(async () => { + root.render( + + + , + ); + }); + + await flush(); + + expect(issuesApi.get).toHaveBeenCalledWith("PAP-1442"); + expect(container.textContent).toContain("GitHub Security Advisory body"); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + container.remove(); + }); +}); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 0b2912b828e..fd0e0abc5a9 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -28,7 +28,8 @@ import { readIssueDetailHeaderSeed, rememberIssueDetailLocationState, } from "../lib/issueDetailBreadcrumb"; -import { fetchIssueDetail, getCachedIssueDetail } from "../lib/issueDetailCache"; +import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun"; +import { getIssueDetailQueryOptions } from "../lib/issueDetailCache"; import { hasBlockingShortcutDialog, resolveIssueDetailGoKeyAction, @@ -882,22 +883,18 @@ export function IssueDetail() { () => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState), [location.state, resolvedIssueDetailState], ); - const cachedIssue = useMemo( - () => - issueId - ? getCachedIssueDetail(queryClient, issueId, issueHeaderSeed ? { - id: issueHeaderSeed.id, - identifier: issueHeaderSeed.identifier, - } : null) - : undefined, - [issueHeaderSeed, issueId, queryClient], - ); + useEffect(() => { + setIssueChatInitialTranscriptReady(false); + }, [issueId]); const { data: issue, isLoading, error } = useQuery({ - queryKey: queryKeys.issues.detail(issueId!), - queryFn: () => fetchIssueDetail(queryClient, issueId!), + ...getIssueDetailQueryOptions(queryClient, issueId!, { + placeholderIssue: issueHeaderSeed ? { + id: issueHeaderSeed.id, + identifier: issueHeaderSeed.identifier, + } : null, + }), enabled: !!issueId, - initialData: () => cachedIssue, }); const resolvedCompanyId = issue?.companyId ?? selectedCompanyId; const commentComposerDisabledReason = useMemo(() => {