mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +09:00
Speed up issue-to-issue navigation
This commit is contained in:
parent
11de5ae9c9
commit
1729e41179
8 changed files with 347 additions and 32 deletions
116
ui/src/lib/issueDetailCache.test.ts
Normal file
116
ui/src/lib/issueDetailCache.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import {
|
||||
fetchIssueDetail,
|
||||
getCachedIssueDetail,
|
||||
prefetchIssueDetail,
|
||||
seedIssueDetailCache,
|
||||
} from "./issueDetailCache";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
vi.mock("@/api/issues", () => ({
|
||||
issuesApi: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title: "Fast link target",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 1,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
myLastTouchAt: null,
|
||||
lastExternalCommentAt: null,
|
||||
isUnreadForMe: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("issueDetailCache", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("seeds and resolves issue detail by both identifier and id", () => {
|
||||
const issue = createIssue();
|
||||
|
||||
seedIssueDetailCache(queryClient, issue, { issueRef: issue.identifier });
|
||||
|
||||
expect(getCachedIssueDetail(queryClient, issue.identifier)).toEqual(issue);
|
||||
expect(getCachedIssueDetail(queryClient, issue.id)).toEqual(issue);
|
||||
expect(queryClient.getQueryData(queryKeys.issues.detail(issue.identifier!))).toEqual(issue);
|
||||
expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toEqual(issue);
|
||||
});
|
||||
|
||||
it("prefetches with the provided issue snapshot before the network result lands", async () => {
|
||||
const issue = createIssue();
|
||||
let releaseFetch: (() => void) | null = null;
|
||||
vi.mocked(issuesApi.get).mockImplementation(
|
||||
() =>
|
||||
new Promise<Issue>((resolve) => {
|
||||
releaseFetch = () => resolve(issue);
|
||||
}),
|
||||
);
|
||||
|
||||
const prefetchPromise = prefetchIssueDetail(queryClient, issue.identifier!, { issue });
|
||||
|
||||
expect(getCachedIssueDetail(queryClient, issue.identifier)).toEqual(issue);
|
||||
expect(getCachedIssueDetail(queryClient, issue.id)).toEqual(issue);
|
||||
|
||||
releaseFetch?.();
|
||||
await prefetchPromise;
|
||||
});
|
||||
|
||||
it("hydrates both cache aliases from a fetched issue detail response", async () => {
|
||||
const issue = createIssue();
|
||||
vi.mocked(issuesApi.get).mockResolvedValue(issue);
|
||||
|
||||
const result = await fetchIssueDetail(queryClient, issue.identifier!);
|
||||
|
||||
expect(result).toEqual(issue);
|
||||
expect(queryClient.getQueryData(queryKeys.issues.detail(issue.identifier!))).toEqual(issue);
|
||||
expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toEqual(issue);
|
||||
});
|
||||
});
|
||||
103
ui/src/lib/issueDetailCache.ts
Normal file
103
ui/src/lib/issueDetailCache.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const ISSUE_DETAIL_QUERY_PREFIX = ["issues", "detail"] as const;
|
||||
const ISSUE_DETAIL_STALE_TIME_MS = 60_000;
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function collectIssueRefs(
|
||||
issueRef: string | null | undefined,
|
||||
issue?: Pick<Issue, "id" | "identifier"> | null,
|
||||
): string[] {
|
||||
const refs = new Set<string>();
|
||||
if (isNonEmptyString(issueRef)) refs.add(issueRef);
|
||||
if (isNonEmptyString(issue?.id)) refs.add(issue.id);
|
||||
if (isNonEmptyString(issue?.identifier)) refs.add(issue.identifier);
|
||||
return Array.from(refs);
|
||||
}
|
||||
|
||||
function matchesIssueRef(issue: Pick<Issue, "id" | "identifier">, refs: Iterable<string>) {
|
||||
const refSet = refs instanceof Set ? refs : new Set(refs);
|
||||
return refSet.has(issue.id) || (!!issue.identifier && refSet.has(issue.identifier));
|
||||
}
|
||||
|
||||
function mergeIssueSnapshots(existing: Issue | undefined, incoming: Issue): Issue {
|
||||
if (!existing) return incoming;
|
||||
return {
|
||||
...existing,
|
||||
...incoming,
|
||||
};
|
||||
}
|
||||
|
||||
export function getIssueDetailCacheRefs(issue: Pick<Issue, "id" | "identifier">): string[] {
|
||||
return collectIssueRefs(null, issue);
|
||||
}
|
||||
|
||||
export function getCachedIssueDetail(
|
||||
queryClient: QueryClient,
|
||||
issueRef: string | null | undefined,
|
||||
issue?: Pick<Issue, "id" | "identifier"> | null,
|
||||
): Issue | undefined {
|
||||
const refs = collectIssueRefs(issueRef, issue);
|
||||
|
||||
for (const ref of refs) {
|
||||
const cached = queryClient.getQueryData<Issue>(queryKeys.issues.detail(ref));
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const cachedEntries = queryClient.getQueriesData<Issue>({ queryKey: ISSUE_DETAIL_QUERY_PREFIX });
|
||||
return cachedEntries
|
||||
.map(([, cachedIssue]) => cachedIssue)
|
||||
.find((cachedIssue): cachedIssue is Issue => !!cachedIssue && matchesIssueRef(cachedIssue, refs));
|
||||
}
|
||||
|
||||
export function seedIssueDetailCache(
|
||||
queryClient: QueryClient,
|
||||
issue: Issue,
|
||||
options?: {
|
||||
issueRef?: string | null;
|
||||
},
|
||||
): Issue {
|
||||
const refs = collectIssueRefs(options?.issueRef, issue);
|
||||
const merged = mergeIssueSnapshots(getCachedIssueDetail(queryClient, options?.issueRef, issue), issue);
|
||||
|
||||
for (const ref of refs) {
|
||||
queryClient.setQueryData<Issue>(
|
||||
queryKeys.issues.detail(ref),
|
||||
(existing) => mergeIssueSnapshots(existing, merged),
|
||||
);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function fetchIssueDetail(
|
||||
queryClient: QueryClient,
|
||||
issueRef: string,
|
||||
): Promise<Issue> {
|
||||
const issue = await issuesApi.get(issueRef);
|
||||
return seedIssueDetailCache(queryClient, issue, { issueRef });
|
||||
}
|
||||
|
||||
export function prefetchIssueDetail(
|
||||
queryClient: QueryClient,
|
||||
issueRef: string,
|
||||
options?: {
|
||||
issue?: Issue | null;
|
||||
},
|
||||
) {
|
||||
if (options?.issue) {
|
||||
seedIssueDetailCache(queryClient, options.issue, { issueRef });
|
||||
}
|
||||
|
||||
return queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.issues.detail(issueRef),
|
||||
queryFn: () => fetchIssueDetail(queryClient, issueRef),
|
||||
staleTime: ISSUE_DETAIL_STALE_TIME_MS,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import * as React from "react";
|
||||
import * as RouterDom from "react-router-dom";
|
||||
import type { NavigateOptions, To } from "react-router-dom";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { IssueLinkQuicklook } from "@/components/IssueLinkQuicklook";
|
||||
import {
|
||||
|
|
@ -49,18 +50,28 @@ export * from "react-router-dom";
|
|||
|
||||
type CompanyLinkProps = React.ComponentProps<typeof RouterDom.Link> & {
|
||||
disableIssueQuicklook?: boolean;
|
||||
issuePrefetch?: Issue | null;
|
||||
};
|
||||
|
||||
export const Link = React.forwardRef<HTMLAnchorElement, CompanyLinkProps>(
|
||||
function CompanyLink({ to, disableIssueQuicklook = false, ...props }, ref) {
|
||||
function CompanyLink({ to, disableIssueQuicklook = false, issuePrefetch = null, ...props }, ref) {
|
||||
const companyPrefix = useActiveCompanyPrefix();
|
||||
const resolvedTo = resolveTo(to, companyPrefix);
|
||||
const issuePathId = disableIssueQuicklook
|
||||
? null
|
||||
? parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname)
|
||||
: parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname);
|
||||
|
||||
if (issuePathId) {
|
||||
return <IssueLinkQuicklook ref={ref} to={resolvedTo} issuePathId={issuePathId} {...props} />;
|
||||
return (
|
||||
<IssueLinkQuicklook
|
||||
ref={ref}
|
||||
to={resolvedTo}
|
||||
issuePathId={issuePathId}
|
||||
disableIssueQuicklook={disableIssueQuicklook}
|
||||
issuePrefetch={issuePrefetch}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <RouterDom.Link ref={ref} to={resolvedTo} {...props} />;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue