@@ -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()
@@ -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(() => {
@@ -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