Speed up issue-to-issue navigation

This commit is contained in:
Dotta
2026-04-11 11:05:32 -05:00
parent 11de5ae9c9
commit 1729e41179
8 changed files with 347 additions and 32 deletions
+64 -19
View File
@@ -2,11 +2,11 @@ import * as React from "react";
import { useMemo, useState } from "react";
import * as RouterDom from "react-router-dom";
import type { Issue } from "@paperclipai/shared";
import { useQuery } from "@tanstack/react-query";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { timeAgo } from "@/lib/timeAgo";
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb";
import { fetchIssueDetail, getCachedIssueDetail, prefetchIssueDetail } from "@/lib/issueDetailCache";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { StatusIcon } from "@/components/StatusIcon";
@@ -67,47 +67,92 @@ export function IssueQuicklookCard({
export const IssueLinkQuicklook = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<typeof RouterDom.Link> & { issuePathId: string }
React.ComponentProps<typeof RouterDom.Link> & {
issuePathId: string;
disableIssueQuicklook?: boolean;
issuePrefetch?: Issue | null;
}
>(function IssueLinkQuicklookImpl(
{
issuePathId,
to,
children,
className,
state,
disableIssueQuicklook = false,
issuePrefetch = null,
onClick,
onClickCapture,
onMouseEnter,
onFocus,
onTouchStart,
...props
},
ref,
) {
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: () => issuesApi.get(issuePathId),
queryFn: () => fetchIssueDetail(queryClient, issuePathId),
enabled: open,
initialData: () => cachedIssue,
staleTime: 60_000,
});
const detailPath = createIssueDetailPath(issuePathId);
const handlePrefetch = React.useCallback(() => {
void prefetchIssueDetail(queryClient, issuePathId, { issue: issuePrefetch });
}, [issuePathId, issuePrefetch, queryClient]);
const link = (
<RouterDom.Link
ref={ref}
to={to}
state={prefetchedState}
className={className}
onMouseEnter={(event) => {
handlePrefetch();
onMouseEnter?.(event);
}}
onFocus={(event) => {
handlePrefetch();
onFocus?.(event);
}}
onTouchStart={(event) => {
handlePrefetch();
onTouchStart?.(event);
}}
onClickCapture={(event) => {
handlePrefetch();
onClickCapture?.(event);
}}
onClick={(event) => {
setOpen(false);
onClick?.(event);
}}
{...props}
>
{children}
</RouterDom.Link>
);
if (disableIssueQuicklook) {
return link;
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
asChild
onMouseEnter={() => setOpen(true)}
onMouseEnter={() => {
handlePrefetch();
setOpen(true);
}}
onMouseLeave={() => setOpen(false)}
>
<RouterDom.Link
ref={ref}
to={to}
className={className}
onClick={(event) => {
setOpen(false);
onClick?.(event);
}}
{...props}
>
{children}
</RouterDom.Link>
{link}
</PopoverTrigger>
<PopoverContent
className="w-72 p-3"
@@ -118,7 +163,7 @@ export const IssueLinkQuicklook = React.forwardRef<
onOpenAutoFocus={(event) => event.preventDefault()}
>
{data ? (
<IssueQuicklookCard issue={data} linkTo={detailPath} compact />
<IssueQuicklookCard issue={data} linkTo={detailPath} linkState={prefetchedState} compact />
) : (
<div className="space-y-2">
<div className="h-4 w-24 rounded bg-accent/50" />
+23 -1
View File
@@ -7,10 +7,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueRow } from "./IssueRow";
vi.mock("@/lib/router", () => ({
Link: ({ children, className, disableIssueQuicklook: _disableIssueQuicklook, ...props }: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean }) => (
Link: ({
children,
className,
disableIssueQuicklook: _disableIssueQuicklook,
issuePrefetch,
...props
}: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean; issuePrefetch?: Issue | null }) => (
<a
className={className}
data-disable-issue-quicklook={_disableIssueQuicklook ? "true" : undefined}
data-issue-prefetch-id={issuePrefetch?.id}
{...props}
>
{children}
@@ -157,6 +164,21 @@ describe("IssueRow", () => {
});
});
it("passes the visible row issue into the navigation prefetch path", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} />);
});
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(link?.getAttribute("data-issue-prefetch-id")).toBe("issue-1");
act(() => {
root.unmount();
});
});
it("renders titleSuffix inline after the issue title", () => {
const root = createRoot(container);
const issue = createIssue({ title: "Parent task" });
+1
View File
@@ -59,6 +59,7 @@ export function IssueRow({
to={createIssueDetailPath(issuePathId)}
state={detailState}
disableIssueQuicklook
issuePrefetch={issue}
data-inbox-issue-link
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
className={cn(
+116
View 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
View 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,
});
}
+14 -3
View File
@@ -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} />;
+3
View File
@@ -30,6 +30,7 @@ import {
rememberIssueDetailLocationState,
withIssueDetailHeaderSeed,
} from "../lib/issueDetailBreadcrumb";
import { prefetchIssueDetail } from "../lib/issueDetailCache";
import {
hasBlockingShortcutDialog,
isKeyboardShortcutTextInputTarget,
@@ -1578,6 +1579,7 @@ export function Inbox() {
const pathId = issue.identifier ?? issue.id;
const detailState = armIssueDetailInboxQuickArchive(withIssueDetailHeaderSeed(issueLinkState, issue));
rememberIssueDetailLocationState(pathId, detailState);
void prefetchIssueDetail(queryClient, pathId, { issue });
act.navigate(createIssueDetailPath(pathId), { state: detailState });
} else if (item) {
if (item.kind === "issue") {
@@ -1586,6 +1588,7 @@ export function Inbox() {
withIssueDetailHeaderSeed(issueLinkState, item.issue),
);
rememberIssueDetailLocationState(pathId, detailState);
void prefetchIssueDetail(queryClient, pathId, { issue: item.issue });
act.navigate(createIssueDetailPath(pathId), { state: detailState });
} else if (item.kind === "approval") {
act.navigate(`/approvals/${item.approval.id}`);
+23 -9
View File
@@ -27,6 +27,7 @@ import {
rememberIssueDetailLocationState,
} from "../lib/issueDetailBreadcrumb";
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun";
import { fetchIssueDetail, getCachedIssueDetail } from "../lib/issueDetailCache";
import {
hasBlockingShortcutDialog,
resolveIssueDetailGoKeyAction,
@@ -392,6 +393,24 @@ export function IssueDetail() {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
const commentComposerRef = useRef<IssueChatComposerHandle | null>(null);
const resolvedIssueDetailState = useMemo(
() => readIssueDetailLocationState(issueId, location.state, location.search),
[issueId, location.state, location.search],
);
const issueHeaderSeed = useMemo(
() => 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);
@@ -399,8 +418,9 @@ export function IssueDetail() {
const { data: issue, isLoading, error } = useQuery({
queryKey: queryKeys.issues.detail(issueId!),
queryFn: () => issuesApi.get(issueId!),
queryFn: () => fetchIssueDetail(queryClient, issueId!),
enabled: !!issueId,
initialData: () => cachedIssue,
});
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
const commentComposerDisabledReason = useMemo(() => {
@@ -491,14 +511,6 @@ export function IssueDetail() {
),
[activeRun, liveRuns],
);
const resolvedIssueDetailState = useMemo(
() => readIssueDetailLocationState(issueId, location.state, location.search),
[issueId, location.state, location.search],
);
const issueHeaderSeed = useMemo(
() => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState),
[location.state, resolvedIssueDetailState],
);
const sourceBreadcrumb = useMemo(
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
[issueId, location.state, location.search],
@@ -1639,6 +1651,7 @@ export function IssueDetail() {
<Link
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id)}
state={resolvedIssueDetailState ?? location.state}
issuePrefetch={ancestor}
onClickCapture={() =>
rememberIssueDetailLocationState(
ancestor.identifier ?? ancestor.id,
@@ -1883,6 +1896,7 @@ export function IssueDetail() {
key={child.id}
to={createIssueDetailPath(child.identifier ?? child.id)}
state={resolvedIssueDetailState ?? location.state}
issuePrefetch={child}
onClickCapture={() =>
rememberIssueDetailLocationState(
child.identifier ?? child.id,