From ba4fc9b284dbc5d31dc38c26099642f34ca75da7 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Thu, 30 Jul 2026 14:42:29 +0800 Subject: [PATCH] perf(issues): make the URL rewrite to the identifier cost no extra request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/core/issues/canonical-id.test.tsx | 22 ++++++++++++++++++ packages/core/issues/canonical-id.ts | 26 ++++++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/core/issues/canonical-id.test.tsx b/packages/core/issues/canonical-id.test.tsx index 1abe1f1701..71c267152f 100644 --- a/packages/core/issues/canonical-id.test.tsx +++ b/packages/core/issues/canonical-id.test.tsx @@ -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), diff --git a/packages/core/issues/canonical-id.ts b/packages/core/issues/canonical-id.ts index bc52ec3069..33c51d5f25 100644 --- a/packages/core/issues/canonical-id.ts +++ b/packages/core/issues/canonical-id.ts @@ -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( + 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