refactor(web): preload bug list query, pass labels/identities as props

Quentin Gliech and Claude Opus 4.6 (1M context) created

preload the bug list, valid labels, and all identities queries in
the /$repo/issues route loader using loaderDeps for search params:
- bugListRef depends on q/after search params (re-fetched on change)
- labelsRef and identitiesRef are independent (stable across filters)

pass labels/identities data down to IssueFilters and QueryInput as
props instead of each component fetching its own copy:
- add labels/identities props to IssueFilters and QueryInput
- export LabelItem/IdentityItem types from IssueFilters
- remove useValidLabelsQuery/useAllIdentitiesQuery from both components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

webui2/src/components/bugs/IssueFilters.tsx | 38 +++++++----
webui2/src/components/bugs/QueryInput.tsx   | 29 ++++----
webui2/src/routes/$repo/issues/index.tsx    | 74 ++++++++++++++--------
3 files changed, 84 insertions(+), 57 deletions(-)

Detailed changes

webui2/src/components/bugs/IssueFilters.tsx 🔗

@@ -1,11 +1,9 @@
 import { ArrowUpDown, ChevronDown, Tag, User, X, Search, Check } from "lucide-react";
 import { useMemo, useState } from "react";
 
-import { useValidLabelsQuery, useAllIdentitiesQuery } from "@/__generated__/graphql";
 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
 import { useAuth } from "@/lib/auth";
-import { useRepo } from "@/lib/repo";
 import { cn } from "@/lib/utils";
 
 import { LabelBadge } from "./LabelBadge";
@@ -41,7 +39,24 @@ const SORT_OPTIONS: { value: SortValue; label: string }[] = [
   { value: "edit-asc", label: "Least recently updated" },
 ];
 
+export interface LabelItem {
+  name: string;
+  color: { R: number; G: number; B: number };
+}
+
+export interface IdentityItem {
+  id: string;
+  humanId: string;
+  name?: string | null;
+  email?: string | null;
+  login?: string | null;
+  displayName: string;
+  avatarUrl?: string | null;
+}
+
 interface IssueFiltersProps {
+  labels: readonly LabelItem[];
+  identities: readonly IdentityItem[];
   selectedLabels: string[];
   onLabelsChange: (labels: string[]) => void;
   selectedAuthorId: string | null;
@@ -64,6 +79,8 @@ interface IssueFiltersProps {
 // queryValue (login/name for the query string). They're kept separate because
 // two identities can share the same display name, but humanId is always unique.
 export function IssueFilters({
+  labels,
+  identities,
   selectedLabels,
   onLabelsChange,
   selectedAuthorId,
@@ -73,26 +90,17 @@ export function IssueFilters({
   onSortChange,
 }: IssueFiltersProps) {
   const { user } = useAuth();
-  const repo = useRepo();
-  const { data: labelsData } = useValidLabelsQuery({ variables: { ref: repo } });
-  const { data: authorsData } = useAllIdentitiesQuery({ variables: { ref: repo } });
   const [labelSearch, setLabelSearch] = useState("");
   const [authorSearch, setAuthorSearch] = useState("");
 
   const validLabels = useMemo(
-    () =>
-      (labelsData?.repository?.validLabels.nodes ?? []).toSorted((a, b) =>
-        a.name.localeCompare(b.name),
-      ),
-    [labelsData],
+    () => labels.toSorted((a, b) => a.name.localeCompare(b.name)),
+    [labels],
   );
 
   const allIdentities = useMemo(
-    () =>
-      (authorsData?.repository?.allIdentities.nodes ?? []).toSorted((a, b) =>
-        a.displayName.localeCompare(b.displayName),
-      ),
-    [authorsData],
+    () => identities.toSorted((a, b) => a.displayName.localeCompare(b.displayName)),
+    [identities],
   );
 
   const filteredLabels = labelSearch.trim()

webui2/src/components/bugs/QueryInput.tsx 🔗

@@ -12,8 +12,7 @@
 import { Search } from "lucide-react";
 import { useState, useRef, useMemo, type ChangeEvent } from "react";
 
-import { useValidLabelsQuery, useAllIdentitiesQuery } from "@/__generated__/graphql";
-import { useRepo } from "@/lib/repo";
+import type { LabelItem, IdentityItem } from "@/components/bugs/IssueFilters";
 import { cn } from "@/lib/utils";
 
 // ── Segment parsing (for the syntax-highlight backdrop) ───────────────────────
@@ -152,28 +151,28 @@ interface QueryInputProps {
   onSubmit: () => void;
   placeholder?: string;
   className?: string;
+  labels: readonly LabelItem[];
+  identities: readonly IdentityItem[];
 }
 
-export function QueryInput({ value, onChange, onSubmit, placeholder, className }: QueryInputProps) {
+export function QueryInput({
+  value,
+  onChange,
+  onSubmit,
+  placeholder,
+  className,
+  labels,
+  identities,
+}: QueryInputProps) {
   const inputRef = useRef<HTMLInputElement>(null);
-  const repo = useRepo();
 
   // Autocomplete state: null when the dropdown is hidden.
   const [completion, setCompletion] = useState<CompletionInfo | null>(null);
   // Keyboard-highlighted index within the visible suggestions list.
   const [acIndex, setAcIndex] = useState(0);
 
-  // Fetch all labels and identities for autocomplete suggestions.
-  // These queries are cheap (cached by Apollo) and already used by IssueFilters,
-  // so there is no extra network cost.
-  const { data: labelsData } = useValidLabelsQuery({ variables: { ref: repo } });
-  const { data: authorsData } = useAllIdentitiesQuery({ variables: { ref: repo } });
-
-  const allLabels = useMemo(() => labelsData?.repository?.validLabels.nodes ?? [], [labelsData]);
-  const allAuthors = useMemo(
-    () => authorsData?.repository?.allIdentities.nodes ?? [],
-    [authorsData],
-  );
+  const allLabels = labels;
+  const allAuthors = identities;
 
   // Compute the filtered suggestion list whenever completion info changes.
   const suggestions = useMemo(() => {

webui2/src/routes/$repo/issues/index.tsx 🔗

@@ -1,9 +1,17 @@
+import { useReadQuery } from "@apollo/client/react";
 import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
 import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react";
 import { useState } from "react";
 import * as v from "valibot";
 
-import { useBugListQuery } from "@/__generated__/graphql";
+import {
+  type BugListQuery,
+  BugListDocument,
+  type ValidLabelsQuery,
+  ValidLabelsDocument,
+  type AllIdentitiesQuery,
+  AllIdentitiesDocument,
+} from "@/__generated__/graphql";
 import { BugRow } from "@/components/bugs/BugRow";
 import { IssueFilters } from "@/components/bugs/IssueFilters";
 import type { SortValue } from "@/components/bugs/IssueFilters";
@@ -11,6 +19,7 @@ import { QueryInput } from "@/components/bugs/QueryInput";
 import { Button } from "@/components/ui/button";
 import { ButtonLink } from "@/components/ui/button-link";
 import { Skeleton } from "@/components/ui/skeleton";
+import { preloadQuery } from "@/lib/apollo";
 import { useRepo } from "@/lib/repo";
 import { cn } from "@/lib/utils";
 
@@ -21,7 +30,32 @@ const issuesSearchSchema = v.object({
 
 export const Route = createFileRoute("/$repo/issues/")({
   component: RouteComponent,
+  pendingComponent: BugListSkeleton,
   validateSearch: (search) => v.parse(issuesSearchSchema, search),
+  loaderDeps: ({ search: { q, after } }) => ({ q, after }),
+  loader: ({ params: { repo }, deps: { q, after } }) => {
+    const ref = repo === "_" ? null : repo;
+    const parsed = parseQueryString(q);
+    const baseQuery = buildBaseQuery(parsed.labels, parsed.author, parsed.freeText);
+    return {
+      bugListRef: preloadQuery<BugListQuery>(BugListDocument, {
+        variables: {
+          ref,
+          openQuery: `status:open ${baseQuery}`.trim(),
+          closedQuery: `status:closed ${baseQuery}`.trim(),
+          listQuery: q,
+          first: PAGE_SIZE,
+          after: after || undefined,
+        },
+      }),
+      labelsRef: preloadQuery<ValidLabelsQuery>(ValidLabelsDocument, {
+        variables: { ref },
+      }),
+      identitiesRef: preloadQuery<AllIdentitiesQuery>(AllIdentitiesDocument, {
+        variables: { ref },
+      }),
+    };
+  },
 });
 
 const PAGE_SIZE = 25;
@@ -47,26 +81,16 @@ function RouteComponent() {
   // Draft is the text input value — starts from URL, only committed on submit
   const [draft, setDraft] = useState(q);
 
-  // Build the three query variants from the URL state
-  const baseQuery = buildBaseQuery(selectedLabels, selectedAuthorQuery, parsed.freeText);
-  const openQuery = `status:open ${baseQuery}`.trim();
-  const closedQuery = `status:closed ${baseQuery}`.trim();
-  const listQuery = q;
-
-  const { data, loading, error } = useBugListQuery({
-    variables: {
-      ref: repo,
-      openQuery,
-      closedQuery,
-      listQuery,
-      first: PAGE_SIZE,
-      after: after || undefined,
-    },
-  });
+  const { bugListRef, labelsRef, identitiesRef } = Route.useLoaderData();
+  const { data } = useReadQuery(bugListRef);
+  const { data: labelsData } = useReadQuery(labelsRef);
+  const { data: identitiesData } = useReadQuery(identitiesRef);
 
   const openCount = data?.repository?.openCount.totalCount ?? 0;
   const closedCount = data?.repository?.closedCount.totalCount ?? 0;
   const bugs = data?.repository?.bugs;
+  const validLabels = labelsData?.repository?.validLabels.nodes ?? [];
+  const allIdentities = identitiesData?.repository?.allIdentities.nodes ?? [];
   const totalCount = bugs?.totalCount ?? 0;
   const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
   const hasNext = bugs?.pageInfo.hasNextPage ?? false;
@@ -109,6 +133,8 @@ function RouteComponent() {
           onChange={setDraft}
           onSubmit={handleSearch}
           placeholder="status:open author:… label:…"
+          labels={validLabels}
+          identities={allIdentities}
         />
         <Button type="submit">Search</Button>
       </form>
@@ -167,6 +193,8 @@ function RouteComponent() {
 
           <div className="ml-auto">
             <IssueFilters
+              labels={validLabels}
+              identities={allIdentities}
               selectedLabels={selectedLabels}
               onLabelsChange={(labels) =>
                 applyFilters(statusFilter, labels, selectedAuthorQuery, parsed.freeText)
@@ -185,14 +213,6 @@ function RouteComponent() {
         </div>
 
         {/* Bug rows */}
-        {error && (
-          <p className="text-destructive px-4 py-8 text-center text-sm">
-            Failed to load issues: {error.message}
-          </p>
-        )}
-
-        {loading && !data && <BugListSkeleton />}
-
         {bugs?.nodes.length === 0 && (
           <p className="text-muted-foreground px-4 py-8 text-center text-sm">
             No {statusFilter} issues found.
@@ -232,7 +252,7 @@ function RouteComponent() {
               search={{ q, after: "" }}
               variant="ghost"
               size="sm"
-              disabled={!hasPrev || loading}
+              disabled={!hasPrev}
               className="text-muted-foreground gap-1"
             >
               <ChevronLeft className="size-4" />
@@ -247,7 +267,7 @@ function RouteComponent() {
               search={{ q, after: bugs?.pageInfo.endCursor ?? "" }}
               variant="ghost"
               size="sm"
-              disabled={!hasNext || loading}
+              disabled={!hasNext}
               className="text-muted-foreground gap-1"
             >
               Next