mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
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:
1 parent
50cd76d8a3
commit
f098ed847e
4 files changed
+156
-20
No files matched your search
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user