From f098ed847ef4b33dec0239c9a54b3494a5a65100 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 13 Apr 2026 15:59:20 -0500 Subject: [PATCH] fix: use placeholderData instead of initialData for issue detail queries The issue detail description hydration bug was caused by using React Query's initialData with partial issue seed objects (containing only id/identifier). initialData tells React Query "this IS the data" and suppresses refetching, so the full issue (with description) was never loaded when navigating from a list view that only had partial data cached. Switch to placeholderData which shows the cached partial data temporarily while always fetching the complete issue from the API. Also centralizes the query options into getIssueDetailQueryOptions() for consistency across IssueDetail and IssueLinkQuicklook. Co-Authored-By: Paperclip --- ui/src/components/IssueLinkQuicklook.tsx | 8 +- ui/src/lib/issueDetailCache.ts | 14 +++ ui/src/lib/issueDetailQuery.test.tsx | 129 +++++++++++++++++++++++ ui/src/pages/IssueDetail.tsx | 25 ++--- 4 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 ui/src/lib/issueDetailQuery.test.tsx 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(() => {