refactor(web): preload tree and blob queries in route loaders

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

move tree and blob GraphQL queries from component-level useQuery
to route-level preloadQuery so data loads before the transition:

tree route:
- preload TREE_QUERY in loader (params provide ref + path)
- use useReadQuery for tree data, keep useQuery for cascading
  lastCommits and readme queries (depend on tree result)
- add pendingComponent skeleton

blob route:
- preload BLOB_QUERY in loader
- add pendingComponent skeleton

remove loading props from FileTree and FileViewer since data is
always ready when the component renders (suspense handles loading)

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

Change summary

webui2/src/components/code/FileTree.tsx       | 23 -----------
webui2/src/components/code/FileViewer.tsx     |  7 +--
webui2/src/routes/$repo/_code/blob/$ref/$.tsx | 34 +++++++++++++----
webui2/src/routes/$repo/_code/tree/$ref/$.tsx | 39 ++++++++++++++++----
4 files changed, 60 insertions(+), 43 deletions(-)

Detailed changes

webui2/src/components/code/FileTree.tsx 🔗

@@ -3,7 +3,6 @@ import { formatDistanceToNow } from "date-fns";
 import { Folder, File } from "lucide-react";
 
 import { GitObjectType, type GitTreeEntry } from "@/__generated__/graphql";
-import { Skeleton } from "@/components/ui/skeleton";
 
 export interface TreeEntryWithCommit extends GitTreeEntry {
   lastCommit?: {
@@ -19,20 +18,17 @@ interface FileTreeProps {
   currentRef: string;
   currentPath: string;
   entries: TreeEntryWithCommit[];
-  loading?: boolean;
 }
 
 // Directory listing table for the code browser. Shows each entry's icon,
 // name, last-commit message (linked to commit detail), and relative date.
-export function FileTree({ repo, currentRef, currentPath, entries, loading }: FileTreeProps) {
+export function FileTree({ repo, currentRef, currentPath, entries }: FileTreeProps) {
   // Directories first, then files — each group alphabetical
   const sorted = entries.toSorted((a, b) => {
     if (a.type !== b.type) return a.type === GitObjectType.Tree ? -1 : 1;
     return a.name.localeCompare(b.name);
   });
 
-  if (loading) return <FileTreeSkeleton />;
-
   return (
     <div className="border-border overflow-hidden rounded-md border">
       <table className="w-full text-sm">
@@ -120,20 +116,3 @@ function FileTreeRow({
     </tr>
   );
 }
-
-function FileTreeSkeleton() {
-  return (
-    <div className="border-border overflow-hidden rounded-md border">
-      <div className="divide-border divide-y">
-        {Array.from({ length: 8 }).map((_, i) => (
-          <div key={i} className="flex items-center gap-3 px-4 py-2">
-            <Skeleton className="size-4 rounded-sm" />
-            <Skeleton className="h-4 w-32" />
-            <Skeleton className="ml-6 hidden h-4 w-64 md:block" />
-            <Skeleton className="ml-auto hidden h-4 w-20 md:block" />
-          </div>
-        ))}
-      </div>
-    </div>
-  );
-}

webui2/src/components/code/FileViewer.tsx 🔗

@@ -10,11 +10,10 @@ import { Skeleton } from "@/components/ui/skeleton";
 
 interface FileViewerProps {
   blob: GitBlob | null;
-  loading?: boolean;
 }
 
-export function FileViewer({ blob, loading = false }: FileViewerProps) {
-  if (loading || !blob) {
+export function FileViewer({ blob }: FileViewerProps) {
+  if (!blob) {
     return (
       <div className="divide-border border-border divide-y rounded-md border">
         <div className="flex items-center gap-2 px-4 py-2">
@@ -51,7 +50,7 @@ export function FileViewer({ blob, loading = false }: FileViewerProps) {
     };
   }, [blob]);
 
-  if (loading || highlighted === null) return <FileViewerSkeleton />;
+  if (highlighted === null) return <FileViewerSkeleton />;
   const { html, lineCount } = highlighted;
 
   function copyToClipboard() {

webui2/src/routes/$repo/_code/blob/$ref/$.tsx 🔗

@@ -1,11 +1,12 @@
 // Blob (file) view: /$repo/blob/$ref/...path
 
 import { gql } from "@apollo/client";
-import { useQuery } from "@apollo/client/react";
+import { useReadQuery } from "@apollo/client/react";
 import { createFileRoute } from "@tanstack/react-router";
 
 import type { GitBlob } from "@/__generated__/graphql";
 import { FileViewer } from "@/components/code/FileViewer";
+import { Skeleton } from "@/components/ui/skeleton";
 
 const BLOB_QUERY = gql`
   query CodePageBlob($repo: String, $ref: String!, $path: String!) {
@@ -26,19 +27,34 @@ interface BlobQueryData {
   repository: { blob: GitBlob | null } | null;
 }
 
+function BlobSkeleton() {
+  return (
+    <div className="border-border overflow-hidden rounded-md border">
+      <div className="flex items-center gap-2 border-b px-4 py-2">
+        <Skeleton className="h-4 w-48" />
+      </div>
+      <div className="p-4">
+        <Skeleton className="h-64 w-full" />
+      </div>
+    </div>
+  );
+}
+
 export const Route = createFileRoute("/$repo/_code/blob/$ref/$")({
   component: BlobView,
+  pendingComponent: BlobSkeleton,
   beforeLoad: () => ({ viewMode: "blob" as const }),
+  loader: async ({ context: { preloadQuery, ref }, params: { ref: gitRef, _splat: path } }) => {
+    const blobRef = preloadQuery<BlobQueryData>(BLOB_QUERY, {
+      variables: { repo: ref, ref: gitRef, path: path || "" },
+    });
+    return { blobRef: await preloadQuery.toPromise(blobRef) };
+  },
 });
 
 function BlobView() {
-  const { ref: currentRef, _splat: currentPath = "" } = Route.useParams();
-  const { ref: repoRef } = Route.useRouteContext();
-
-  const { data, loading } = useQuery<BlobQueryData>(BLOB_QUERY, {
-    variables: { repo: repoRef, ref: currentRef, path: currentPath },
-    skip: !currentPath,
-  });
+  const { blobRef } = Route.useLoaderData();
+  const { data } = useReadQuery(blobRef);
 
-  return <FileViewer blob={data?.repository?.blob ?? null} loading={loading} />;
+  return <FileViewer blob={data?.repository?.blob ?? null} />;
 }

webui2/src/routes/$repo/_code/tree/$ref/$.tsx 🔗

@@ -1,7 +1,7 @@
 // Tree view: /$repo/tree/$ref/...path
 
 import { gql } from "@apollo/client";
-import { useQuery } from "@apollo/client/react";
+import { useQuery, useReadQuery } from "@apollo/client/react";
 import { createFileRoute } from "@tanstack/react-router";
 
 import {
@@ -13,6 +13,7 @@ import {
 import { FileTree } from "@/components/code/FileTree";
 import type { TreeEntryWithCommit } from "@/components/code/FileTree";
 import { Markdown } from "@/components/content/Markdown";
+import { Skeleton } from "@/components/ui/skeleton";
 
 const TREE_QUERY = gql`
   query CodePageTree($repo: String, $ref: String!, $path: String) {
@@ -42,7 +43,7 @@ const LAST_COMMITS_QUERY = gql`
   }
 `;
 
-const BLOB_QUERY = gql`
+const README_QUERY = gql`
   query CodePageReadme($repo: String, $ref: String!, $path: String!) {
     repository(ref: $repo) {
       blob(ref: $ref, path: $path) {
@@ -62,20 +63,43 @@ interface ReadmeQueryData {
   repository: { blob: GitBlob | null } | null;
 }
 
+function TreeSkeleton() {
+  return (
+    <div className="border-border overflow-hidden rounded-md border">
+      <div className="divide-border divide-y">
+        {Array.from({ length: 8 }).map((_, i) => (
+          <div key={i} className="flex items-center gap-3 px-4 py-2">
+            <Skeleton className="size-4 rounded-sm" />
+            <Skeleton className="h-4 w-32" />
+            <Skeleton className="ml-6 hidden h-4 w-64 md:block" />
+            <Skeleton className="ml-auto hidden h-4 w-20 md:block" />
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
+
 export const Route = createFileRoute("/$repo/_code/tree/$ref/$")({
   component: TreeView,
+  pendingComponent: TreeSkeleton,
   beforeLoad: () => ({ viewMode: "tree" as const }),
+  loader: async ({ context: { preloadQuery, ref }, params: { ref: gitRef, _splat: path } }) => {
+    const treeRef = preloadQuery<TreeQueryData>(TREE_QUERY, {
+      variables: { repo: ref, ref: gitRef, path: path || null },
+    });
+    return { treeRef: await preloadQuery.toPromise(treeRef) };
+  },
 });
 
 function TreeView() {
   const { repo, ref: currentRef, _splat: currentPath = "" } = Route.useParams();
   const { ref: repoRef } = Route.useRouteContext();
-
-  const { data: treeData, loading: treeLoading } = useQuery<TreeQueryData>(TREE_QUERY, {
-    variables: { repo: repoRef, ref: currentRef, path: currentPath || null },
-  });
+  const { treeRef } = Route.useLoaderData();
+  const { data: treeData } = useReadQuery(treeRef);
   const entries: GitTreeEntry[] = treeData?.repository?.tree ?? [];
 
+  // Last commits and readme are cascading queries — they depend on the tree result
   const entryNames = entries.map((e) => e.name);
   const { data: lastCommitsData } = useQuery<LastCommitsQueryData>(LAST_COMMITS_QUERY, {
     variables: { repo: repoRef, ref: currentRef, path: currentPath || null, names: entryNames },
@@ -97,7 +121,7 @@ function TreeView() {
       ? `${currentPath}/${readmeEntry.name}`
       : readmeEntry.name
     : null;
-  const { data: readmeBlobData } = useQuery<ReadmeQueryData>(BLOB_QUERY, {
+  const { data: readmeBlobData } = useQuery<ReadmeQueryData>(README_QUERY, {
     variables: { repo: repoRef, ref: currentRef, path: readmePath },
     skip: !readmePath,
   });
@@ -110,7 +134,6 @@ function TreeView() {
         currentRef={currentRef}
         currentPath={currentPath}
         entries={entriesWithCommits}
-        loading={treeLoading}
       />
       {readme && (
         <div className="rounded-md border">