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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-15 07:21:40 -05:00
1 parent 50cd76d8a3
commit f098ed847e
4 files changed
+156 -20

No files matched your search

+2 -6
View File
@@ -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,
});
+14
View File
@@ -84,6 +84,20 @@ export async function fetchIssueDetail(
return seedIssueDetailCache(queryClient, issue, { issueRef });
}
export function getIssueDetailQueryOptions(
queryClient: QueryClient,
issueRef: string,
options?: {
placeholderIssue?: Pick<Issue, "id" | "identifier"> | 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,
+129
View File
@@ -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> = {}): 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<Issue, "id" | "identifier"> | null;
}) {
const queryClient = useQueryClient();
const query = useQuery({
...getIssueDetailQueryOptions(queryClient, issueRef, { placeholderIssue }),
});
return <div>{query.data?.description ?? "EMPTY"}</div>;
}
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(
<QueryClientProvider client={queryClient}>
<IssueDetailQueryHarness
issueRef="PAP-1442"
placeholderIssue={{ id: partialIssue.id, identifier: partialIssue.identifier }}
/>
</QueryClientProvider>,
);
});
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();
});
});
+11 -14
View File
@@ -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(() => {