perf(issues): make the URL rewrite to the identifier cost no extra request

Opening an issue from an in-app link fetched it twice.

In-app links still carry the UUID, so the route lands on the UUID URL,
loads the issue, then rewrites the address bar to the identifier. That
rewrite is a navigation: the route re-renders with the identifier as its
segment, `useCanonicalIssue` sees a non-UUID, and its resolution query
misses on an identifier-keyed cache entry nothing has filled — so it
re-fetches an issue already in hand. Measured across the rewrite: 2
requests for one issue open.

Mirror the loaded row into its identifier-keyed entry, the reverse of the
`initialData` hop that already covers the identifier-first direction.
Both directions now hold at one request. The `??` keeps a
realtime-patched entry intact, and only `.id` is ever read back out of
that entry, which never changes.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan-J
2026-07-30 14:42:29 +08:00
parent 6be7adcb6b
commit ba4fc9b284
2 changed files with 46 additions and 2 deletions

View File

@@ -91,6 +91,28 @@ describe("useCanonicalIssue", () => {
// The whole point of `initialData` over a post-resolve cache seed: the
// canonical query must never observe an empty cache and start its own fetch.
// A seed from an effect runs after that decision.
// The common path: an in-app link carries the UUID, the route rewrites the
// address bar to the identifier, and that rewrite re-renders this hook with
// the identifier as its segment. Without the identifier-keyed seed the
// resolution query missed and re-fetched an issue already in hand, so one
// issue open cost two requests.
it("costs no extra request when the URL is rewritten to the identifier", async () => {
const { result, rerender } = renderHook(
({ routeId }: { routeId: string }) => useCanonicalIssue("ws-1", routeId),
{ wrapper: createWrapper(qc), initialProps: { routeId: ISSUE_UUID } },
);
await waitFor(() => expect(result.current.issue).toEqual(issue));
expect(getIssue).toHaveBeenCalledTimes(1);
rerender({ routeId: "TRS-134" });
await waitFor(() => expect(result.current.canonicalId).toBe(ISSUE_UUID));
expect(result.current.issue).toEqual(issue);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(getIssue).toHaveBeenCalledTimes(1);
});
it("opens an identifier URL with exactly one request", async () => {
const { result } = renderHook(() => useCanonicalIssue("ws-1", "TRS-134"), {
wrapper: createWrapper(qc),

View File

@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Issue } from "../types";
import { issueDetailOptions } from "./queries";
import { issueDetailOptions, issueKeys } from "./queries";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -51,6 +52,7 @@ export interface CanonicalIssue {
* and leave the single-request property resting on hook ordering.
*/
export function useCanonicalIssue(wsId: string, routeId: string): CanonicalIssue {
const qc = useQueryClient();
const isUuid = isIssueUuid(routeId);
const resolveEnabled = !isUuid && !!wsId && !!routeId;
@@ -69,6 +71,26 @@ export function useCanonicalIssue(wsId: string, routeId: string): CanonicalIssue
initialData: isUuid ? undefined : resolve.data,
});
// Mirror the loaded row into its identifier-keyed entry, the reverse of the
// `initialData` hop above.
//
// In-app links still carry the UUID, so opening one lands on the UUID URL and
// the route then rewrites the address bar to the identifier. That rewrite is
// a navigation: the route re-renders with the identifier as its segment, and
// without this seed the resolution query above would miss on a cache entry
// nothing has filled and re-fetch an issue already in hand — a second request
// for one issue open. The `??` keeps a realtime-patched entry intact, and
// only `.id` is ever read back out, which never changes.
const loadedIdentifier = detail.data?.identifier;
const loadedIssue = detail.data;
useEffect(() => {
if (!loadedIssue || !loadedIdentifier || loadedIdentifier === routeId) return;
qc.setQueryData<Issue>(
issueKeys.detail(wsId, loadedIdentifier),
(old) => old ?? loadedIssue,
);
}, [qc, wsId, loadedIdentifier, loadedIssue, routeId]);
// Read the resolution query's own settled state rather than "pending and no
// data": a failed resolution must present as terminally not-found, never as
// still-resolving, or the caller flips back to a loading frame and the