From eb3e415de7b13606fdff2efe80ecd427074a8e7e Mon Sep 17 00:00:00 2001 From: Stephen Jennings Date: Wed, 24 Jun 2026 16:32:13 -0700 Subject: [PATCH 01/10] feat(query): set up React Query infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TanStack Query (v5) to replace the hand-rolled RequestCache for mutable data over time. This commit is plumbing only — no other call sites are migrated. Changes: - Install @tanstack/react-query, react-query-devtools, react-query-persist-client, query-async-storage-persister - src/browser/lib/query-client.ts — single QueryClient (staleTime=30s, gcTime=5min, no refetch-on-focus) + raw IDB persister in a dedicated 'pulldash-rq/data' store, separate from PersistentCache ('pulldash/responses'), using the same raw IDB pattern already in persistent-cache.ts (no idb-keyval) - src/browser/lib/github-client.ts — module-level setOctokit/getOctokit so query functions can reach the authenticated Octokit without being inside the GitHub context; set by GitHubProvider on auth, cleared on sign-out - src/browser/lib/queries.ts — queryOptions factory registry with conventions documented in the header comment; currentUser query as smoke test (meta: { persist: true }, staleTime: 5 min) - src/browser/index.tsx — wrap app root with PersistQueryClientProvider; dehydrateOptions filters to meta.persist===true; ReactQueryDevtools mounted only in dev builds (__DEV__ define) - scripts/build-browser.ts — add __DEV__ define (true when --watch) - src/types/assets.d.ts — declare const __DEV__: boolean - Migrate user:current from RequestCache to React Query — remove fetchCurrentUser() and currentUser from GitHubState; useCurrentUser() now uses useQuery(queries.currentUser()) enabled by ready; useCurrentUserLoader updated to use the hook Resolves #260 --- bun.lock | 18 ++++ package.json | 4 + scripts/build-browser.ts | 1 + src/browser/contexts/github.tsx | 87 ++----------------- .../pr-review/useCurrentUserLoader.ts | 7 +- src/browser/index.tsx | 60 ++++++++----- src/browser/lib/github-client.ts | 12 +++ src/browser/lib/queries.ts | 57 ++++++++++++ src/browser/lib/query-client.ts | 75 ++++++++++++++++ src/types/assets.d.ts | 1 + 10 files changed, 217 insertions(+), 105 deletions(-) create mode 100644 src/browser/lib/github-client.ts create mode 100644 src/browser/lib/queries.ts create mode 100644 src/browser/lib/query-client.ts diff --git a/bun.lock b/bun.lock index 1aa91d5..192bd4a 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,10 @@ "name": "pulldash", "dependencies": { "@hono/node-server": "^2.0.2", + "@tanstack/query-async-storage-persister": "^5.101.1", + "@tanstack/react-query": "^5.101.1", + "@tanstack/react-query-devtools": "^5.101.1", + "@tanstack/react-query-persist-client": "^5.101.1", "hono": "^4.10.7", }, "devDependencies": { @@ -259,6 +263,20 @@ "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@tanstack/query-async-storage-persister": ["@tanstack/query-async-storage-persister@5.101.1", "", { "dependencies": { "@tanstack/query-core": "5.101.1", "@tanstack/query-persist-client-core": "5.101.1" } }, "sha512-QuHhCTLyQjiVFUYRW5xtgeuiP1NEmqL5wnuFgPGeO6SjTLKeN9VnAhAKsKzde3AcEZEe4PL7FehfKbVhM/9kLA=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.101.1", "", {}, "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g=="], + + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.101.1", "", {}, "sha512-37RQ9U2PxlXQiv1era2t+uHgVhmiyvxqTMu30+KoVf0rufiucu6rpGRKFJk61Wh5OAZFKqCQd6lxTzFWfLZiuQ=="], + + "@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.101.1", "", { "dependencies": { "@tanstack/query-core": "5.101.1" } }, "sha512-rR5Er6jmdI3Oo8o6Wc0ceM6glDU4umgePu2IxM3Gy2UvPqcQONduxxxSzU1+F17mpS09XHqHKmj0Irhfb2cGYg=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.101.1", "", { "dependencies": { "@tanstack/query-core": "5.101.1" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew=="], + + "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.101.1", "", { "dependencies": { "@tanstack/query-devtools": "5.101.1" }, "peerDependencies": { "@tanstack/react-query": "^5.101.1", "react": "^18 || ^19" } }, "sha512-OXFR9XKdEslraq3cpl3kCUeNvTIq/xGWEZiFZdn2bLB/q4WxSALMEDKYZ5yYjMQytsfnQxwQYqV4qtVEf0nuog=="], + + "@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.101.1", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.101.1" }, "peerDependencies": { "@tanstack/react-query": "^5.101.1", "react": "^18 || ^19" } }, "sha512-bBSne+3+28EZ/Ch5a06J0YQi6d5yaT2G+vyHAyhzwzYA1FAEAFe6Ou6TsNVQaT6ef6pFShU73FL/NB4guTWNng=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.3", "", { "dependencies": { "@tanstack/virtual-core": "3.17.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ=="], "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.1", "", {}, "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA=="], diff --git a/package.json b/package.json index 4473963..2132682 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,10 @@ }, "dependencies": { "@hono/node-server": "^2.0.2", + "@tanstack/query-async-storage-persister": "^5.101.1", + "@tanstack/react-query": "^5.101.1", + "@tanstack/react-query-devtools": "^5.101.1", + "@tanstack/react-query-persist-client": "^5.101.1", "hono": "^4.10.7" }, "devDependencies": { diff --git a/scripts/build-browser.ts b/scripts/build-browser.ts index 1d9fe59..37fc969 100644 --- a/scripts/build-browser.ts +++ b/scripts/build-browser.ts @@ -8,6 +8,7 @@ const isWatch = process.argv.includes("--watch"); const REPO_URL = process.env.REPO_URL ?? "https://github.com/jennings/pulldash"; const define = { __REPO_URL__: JSON.stringify(REPO_URL), + __DEV__: JSON.stringify(isWatch), }; async function build() { diff --git a/src/browser/contexts/github.tsx b/src/browser/contexts/github.tsx index c1de314..589199b 100644 --- a/src/browser/contexts/github.tsx +++ b/src/browser/contexts/github.tsx @@ -7,10 +7,13 @@ import { useSyncExternalStore, type ReactNode, } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Octokit } from "@octokit/core"; import type { components } from "@octokit/openapi-types"; import { useAuth } from "./auth"; import * as PersistentCache from "../lib/persistent-cache"; +import { setOctokit } from "../lib/github-client"; +import { queries } from "../lib/queries"; export type UserTeam = { org: string; slug: string }; let userTeamsCache: UserTeam[] | null = null; @@ -518,7 +521,6 @@ export interface CurrentUserData { interface GitHubState { ready: boolean; error: string | null; - currentUser: CurrentUserData | null; prList: PRListState; prListQueries: string[]; prListPage: number; @@ -535,7 +537,6 @@ function createGitHubStore() { let state: GitHubState = { ready: false, error: null, - currentUser: null, prList: { items: [], totalCount: 0, @@ -620,49 +621,26 @@ function createGitHubStore() { // Initialization // --------------------------------------------------------------------------- - function extractUserData( - user: components["schemas"]["private-user"] - ): CurrentUserData { - return { - id: user.id, - login: user.login, - name: user.name ?? null, - email: user.email ?? null, - avatar_url: user.avatar_url, - html_url: user.html_url, - bio: user.bio ?? null, - company: user.company ?? null, - location: user.location ?? null, - }; - } - function initialize(token: string) { - // Load cached user immediately for instant UI - const cachedUser = - cache.getStale("user:current"); - if (cachedUser) { - setState({ currentUser: extractUserData(cachedUser.data) }); - } - octokit = new Octokit({ auth: token }); wrapOctokitWithHooks(octokit); batcher = new GraphQLBatcher(octokit); + setOctokit(octokit); setState({ ready: true, error: null }); - // Revalidate current user and teams in background - fetchCurrentUser(); + // Revalidate teams in background fetchUserTeams(); } function reset() { octokit = null; batcher = null; + setOctokit(null); cache.invalidate(); setState({ ready: false, error: null, - currentUser: null, prList: { items: [], totalCount: 0, @@ -676,55 +654,6 @@ function createGitHubStore() { }); } - // --------------------------------------------------------------------------- - // Current User (with SWR) - // --------------------------------------------------------------------------- - - async function fetchCurrentUser() { - if (!octokit) return; - - const cacheKey = "user:current"; - const FRESH_TTL = 300_000; // 5 minutes - - // Check for stale data - return immediately if we have any - const stale = cache.getStale( - cacheKey, - FRESH_TTL - ); - if (stale) { - setState({ currentUser: extractUserData(stale.data) }); - // If fresh, don't revalidate - if (!stale.isStale) return; - } - - // Check for pending request - const pending = - cache.getPending(cacheKey); - if (pending) { - const user = await pending; - setState({ currentUser: extractUserData(user) }); - return; - } - - // Fetch fresh data (in background if we had stale data) - const promise = octokit.request("GET /user").then((r) => { - cache.set(cacheKey, r.data, true); // persist to localStorage - return r.data; - }); - cache.setPending(cacheKey, promise); - - try { - const user = await promise; - setState({ - currentUser: extractUserData( - user as components["schemas"]["private-user"] - ), - }); - } catch { - // Ignore - we may have stale data to show - } - } - // --------------------------------------------------------------------------- // PR List // --------------------------------------------------------------------------- @@ -3786,7 +3715,9 @@ export function useGitHubReady() { } export function useCurrentUser(): CurrentUserData | null { - return useGitHubSelector((s) => s.currentUser); + const { ready } = useGitHubReady(); + const { data } = useQuery({ ...queries.currentUser(), enabled: ready }); + return data ?? null; } export function usePRList() { diff --git a/src/browser/contexts/pr-review/useCurrentUserLoader.ts b/src/browser/contexts/pr-review/useCurrentUserLoader.ts index 47fd4c2..3dd90a6 100644 --- a/src/browser/contexts/pr-review/useCurrentUserLoader.ts +++ b/src/browser/contexts/pr-review/useCurrentUserLoader.ts @@ -1,16 +1,15 @@ import { useEffect } from "react"; -import { useGitHubStore, useGitHubSelector } from "@/browser/contexts/github"; +import { useGitHubSelector, useCurrentUser } from "@/browser/contexts/github"; import { usePRReviewStore } from "."; export function useCurrentUserLoader() { const store = usePRReviewStore(); - const github = useGitHubStore(); const ready = useGitHubSelector((s) => s.ready); - const currentUser = github.getState().currentUser?.login ?? null; + const currentUser = useCurrentUser(); useEffect(() => { if (ready && currentUser) { - store.setCurrentUser(currentUser); + store.setCurrentUser(currentUser.login); } }, [ready, currentUser, store]); } diff --git a/src/browser/index.tsx b/src/browser/index.tsx index b32ba97..4e389ac 100644 --- a/src/browser/index.tsx +++ b/src/browser/index.tsx @@ -8,6 +8,9 @@ import { CommandPaletteProvider } from "./components/command-palette"; import { AppShell } from "./components/app-shell"; import { WelcomeDialog } from "./components/welcome-dialog"; import { initTheme } from "./theme"; +import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { queryClient, persister } from "./lib/query-client"; import "./index.css"; // Initialize theme before rendering to avoid flash @@ -19,27 +22,38 @@ if ("serviceWorker" in navigator) { } createRoot(document.getElementById("app")!).render( - - - - - - - - {/* Home */} - } /> - {/* PR review - URL like /:owner/:repo/pull/:number */} - } - /> - - {/* Auth dialog - shown when not authenticated */} - - - - - - - + query.meta?.persist === true, + }, + }} + > + + + + + + + + {/* Home */} + } /> + {/* PR review - URL like /:owner/:repo/pull/:number */} + } + /> + + {/* Auth dialog - shown when not authenticated */} + + + + + + + + {__DEV__ && } + ); diff --git a/src/browser/lib/github-client.ts b/src/browser/lib/github-client.ts new file mode 100644 index 0000000..5944ab4 --- /dev/null +++ b/src/browser/lib/github-client.ts @@ -0,0 +1,12 @@ +import { Octokit } from "@octokit/core"; + +let _octokit: Octokit | null = null; + +export function setOctokit(instance: Octokit | null) { + _octokit = instance; +} + +export function getOctokit(): Octokit { + if (!_octokit) throw new Error("Not authenticated"); + return _octokit; +} diff --git a/src/browser/lib/queries.ts b/src/browser/lib/queries.ts new file mode 100644 index 0000000..887d83f --- /dev/null +++ b/src/browser/lib/queries.ts @@ -0,0 +1,57 @@ +// Query factory conventions: +// +// - Each factory returns `queryOptions({ queryKey, queryFn, ... })` for use with +// `useQuery`, `prefetchQuery`, `queryClient.getQueryData`, etc. +// +// - Derived / filtered views use `select` on the same query rather than a +// separate query key, e.g.: +// useQuery({ ...queries.currentUser(), select: (u) => u.login }) +// +// - Queries with `meta: { persist: true }` are persisted to IndexedDB across +// sessions via PersistQueryClientProvider. Use for slow-changing data (user +// profile, collaborators, labels) but NOT for fast-moving data (PR list). +// +// - Mutations follow this pattern: +// useMutation({ +// mutationFn: myMutationFn, +// onSuccess: (result, variables) => { +// queryClient.setQueryData(queries.foo(id).queryKey, (old) => update(old, result)); +// }, +// onSettled: () => queryClient.invalidateQueries({ queryKey: queries.foo(id).queryKey }), +// }) + +import { queryOptions } from "@tanstack/react-query"; +import { getOctokit } from "./github-client"; +import type { CurrentUserData } from "../contexts/github"; +import type { components } from "@octokit/openapi-types"; + +type AnyUser = + | components["schemas"]["private-user"] + | components["schemas"]["public-user"]; + +function toCurrentUserData(user: AnyUser): CurrentUserData { + return { + id: user.id, + login: user.login, + name: user.name ?? null, + email: user.email ?? null, + avatar_url: user.avatar_url, + html_url: user.html_url, + bio: user.bio ?? null, + company: user.company ?? null, + location: user.location ?? null, + }; +} + +export const queries = { + currentUser: () => + queryOptions({ + queryKey: ["user", "current"], + queryFn: async () => { + const r = await getOctokit().request("GET /user"); + return toCurrentUserData(r.data); + }, + staleTime: 5 * 60_000, + meta: { persist: true }, + }), +}; diff --git a/src/browser/lib/query-client.ts b/src/browser/lib/query-client.ts new file mode 100644 index 0000000..e934f1c --- /dev/null +++ b/src/browser/lib/query-client.ts @@ -0,0 +1,75 @@ +import { QueryClient } from "@tanstack/react-query"; +import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; + +// Separate DB from PersistentCache (pulldash/responses) to avoid collisions +const DB_NAME = "pulldash-rq"; +const STORE_NAME = "data"; +const DB_VERSION = 1; + +let dbPromise: Promise | null = null; + +function openDB(): Promise { + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + if (!req.result.objectStoreNames.contains(STORE_NAME)) { + req.result.createObjectStore(STORE_NAME); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => { + dbPromise = null; + reject(req.error); + }; + }); + return dbPromise; +} + +const idbStorage = { + getItem: async (key: string): Promise => { + const db = await openDB(); + return new Promise((resolve, reject) => { + const req = db.transaction(STORE_NAME).objectStore(STORE_NAME).get(key); + req.onsuccess = () => resolve((req.result as string | undefined) ?? null); + req.onerror = () => reject(req.error); + }); + }, + setItem: async (key: string, value: string): Promise => { + const db = await openDB(); + return new Promise((resolve, reject) => { + const req = db + .transaction(STORE_NAME, "readwrite") + .objectStore(STORE_NAME) + .put(value, key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + }, + removeItem: async (key: string): Promise => { + const db = await openDB(); + return new Promise((resolve, reject) => { + const req = db + .transaction(STORE_NAME, "readwrite") + .objectStore(STORE_NAME) + .delete(key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + }, +}; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + gcTime: 5 * 60_000, + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}); + +export const persister = createAsyncStoragePersister({ + storage: idbStorage, +}); diff --git a/src/types/assets.d.ts b/src/types/assets.d.ts index a01d535..ff751c5 100644 --- a/src/types/assets.d.ts +++ b/src/types/assets.d.ts @@ -1,6 +1,7 @@ // Type declarations for asset imports declare const __REPO_URL__: string; +declare const __DEV__: boolean; declare module "*.svg" { const content: string; From 120416cdc25f1e4cb94ecacdf5ff990039886b05 Mon Sep 17 00:00:00 2001 From: Stephen Jennings Date: Wed, 24 Jun 2026 17:05:52 -0700 Subject: [PATCH 02/10] feat(query): move SHA-keyed immutable cache entries to PersistentCache Six functions in github.tsx that cache content-addressed (SHA-keyed) data were using RequestCache (in-memory + localStorage) as their primary store, falling back to PersistentCache only when prKey was provided. Since these responses are immutable, they belong in IndexedDB permanently. Changes: - getCommitFiles, getSingleCommit, getRawGitCommit, getMergeCommitFiles, getRawCompareDiff, getPRFilesForRange, getCommitsForHeadSha: - Remove cache.get/set/getPending/setPending calls - Check PersistentCache.get first (always, not just when prKey given) - Use a per-store Map (immutablePending) for in-flight request deduplication only; cleared on settle - Write to PersistentCache when prKey is available - pr-review/index.tsx: four getPRFilesForRange callers that omitted prKey now pass `${owner}/${repo}/${pr.number}` so results are persisted Existing cache keys are preserved so on-disk entries from previous sessions remain valid without migration. Resolves #261 --- src/browser/contexts/github.tsx | 171 +++++++++-------------- src/browser/contexts/pr-review/index.tsx | 15 +- 2 files changed, 74 insertions(+), 112 deletions(-) diff --git a/src/browser/contexts/github.tsx b/src/browser/contexts/github.tsx index 589199b..3791a56 100644 --- a/src/browser/contexts/github.tsx +++ b/src/browser/contexts/github.tsx @@ -551,6 +551,8 @@ function createGitHubStore() { const listeners = new Set(); const cache = new RequestCache(); + // In-flight dedup for immutable (SHA-keyed) fetches that go directly to PersistentCache + const immutablePending = new Map>(); let octokit: Octokit | null = null; let batcher: GraphQLBatcher | null = null; let onUnauthorized: (() => void) | null = null; @@ -1210,18 +1212,12 @@ function createGitHubStore() { const cacheKey = `commit:${owner}/${repo}/${sha}:files`; - const cached = cache.get(cacheKey); - if (cached) return cached; + const persistent = await PersistentCache.get(cacheKey); + if (persistent) return persistent; - if (prKey) { - const persistent = await PersistentCache.get(cacheKey); - if (persistent) { - cache.set(cacheKey, persistent); - return persistent; - } - } - - const pending = cache.getPending(cacheKey); + const pending = immutablePending.get(cacheKey) as + | Promise + | undefined; if (pending) return pending; const promise = octokit @@ -1232,12 +1228,12 @@ function createGitHubStore() { }) .then((res) => { const files = (res.data.files ?? []) as PullRequestFile[]; - cache.set(cacheKey, files); if (prKey) PersistentCache.put(cacheKey, files, prKey); return files; - }); + }) + .finally(() => immutablePending.delete(cacheKey)); - cache.setPending(cacheKey, promise); + immutablePending.set(cacheKey, promise); return promise; } @@ -1251,16 +1247,13 @@ function createGitHubStore() { const cacheKey = `commit:${owner}/${repo}:${ref}`; - const cached = cache.get(cacheKey); - if (cached) return cached; + const persistent = await PersistentCache.get(cacheKey); + if (persistent) return persistent; - if (prKey) { - const persistent = await PersistentCache.get(cacheKey); - if (persistent) { - cache.set(cacheKey, persistent); - return persistent; - } - } + const pending = immutablePending.get(cacheKey) as + | Promise + | undefined; + if (pending) return pending; const promise = octokit .request("GET /repos/{owner}/{repo}/commits/{ref}", { @@ -1270,12 +1263,12 @@ function createGitHubStore() { }) .then((res) => { const data = res.data as PRCommit; - cache.set(cacheKey, data); if (prKey) PersistentCache.put(cacheKey, data, prKey); return data; - }); + }) + .finally(() => immutablePending.delete(cacheKey)); - cache.setPending(cacheKey, promise); + immutablePending.set(cacheKey, promise); return promise; } @@ -1288,25 +1281,14 @@ function createGitHubStore() { if (!octokit) throw new Error("Not initialized"); const cacheKey = `git-commit:${owner}/${repo}:${ref}`; + type RawCommit = { verification: { payload: string } | null }; - const cached = cache.get<{ verification: { payload: string } | null }>( - cacheKey - ); - if (cached) return cached; + const persistent = await PersistentCache.get(cacheKey); + if (persistent) return persistent; - if (prKey) { - const persistent = await PersistentCache.get<{ - verification: { payload: string } | null; - }>(cacheKey); - if (persistent) { - cache.set(cacheKey, persistent); - return persistent; - } - } - - const pending = cache.getPending<{ - verification: { payload: string } | null; - }>(cacheKey); + const pending = immutablePending.get(cacheKey) as + | Promise + | undefined; if (pending) return pending; const promise = octokit @@ -1316,15 +1298,13 @@ function createGitHubStore() { ref, }) .then((res) => { - const data = res.data as { - verification: { payload: string } | null; - }; - cache.set(cacheKey, data); + const data = res.data as RawCommit; if (prKey) PersistentCache.put(cacheKey, data, prKey); return data; - }); + }) + .finally(() => immutablePending.delete(cacheKey)); - cache.setPending(cacheKey, promise); + immutablePending.set(cacheKey, promise); return promise; } @@ -1339,18 +1319,12 @@ function createGitHubStore() { const cacheKey = `merge:${owner}/${repo}/${mergeSha}:${parentSha}:files`; - const cached = cache.get(cacheKey); - if (cached) return cached; + const persistent = await PersistentCache.get(cacheKey); + if (persistent) return persistent; - if (prKey) { - const persistent = await PersistentCache.get(cacheKey); - if (persistent) { - cache.set(cacheKey, persistent); - return persistent; - } - } - - const pending = cache.getPending(cacheKey); + const pending = immutablePending.get(cacheKey) as + | Promise + | undefined; if (pending) return pending; const promise = octokit @@ -1361,12 +1335,12 @@ function createGitHubStore() { }) .then((res) => { const files = (res.data.files ?? []) as PullRequestFile[]; - cache.set(cacheKey, files); if (prKey) PersistentCache.put(cacheKey, files, prKey); return files; - }); + }) + .finally(() => immutablePending.delete(cacheKey)); - cache.setPending(cacheKey, promise); + immutablePending.set(cacheKey, promise); return promise; } @@ -1381,18 +1355,12 @@ function createGitHubStore() { const cacheKey = `rawdiff:${owner}/${repo}/${baseSha}...${headSha}`; - const cached = cache.get(cacheKey); - if (cached) return cached; + const persistent = await PersistentCache.get(cacheKey); + if (persistent) return persistent; - if (prKey) { - const persistent = await PersistentCache.get(cacheKey); - if (persistent) { - cache.set(cacheKey, persistent); - return persistent; - } - } - - const pending = cache.getPending(cacheKey); + const pending = immutablePending.get(cacheKey) as + | Promise + | undefined; if (pending) return pending; const promise = octokit @@ -1404,12 +1372,12 @@ function createGitHubStore() { }) .then((res) => { const text = res.data as unknown as string; - cache.set(cacheKey, text); if (prKey) PersistentCache.put(cacheKey, text, prKey); return text; - }); + }) + .finally(() => immutablePending.delete(cacheKey)); - cache.setPending(cacheKey, promise); + immutablePending.set(cacheKey, promise); return promise; } @@ -1424,18 +1392,12 @@ function createGitHubStore() { const cacheKey = `compare:${owner}/${repo}/${baseSha}...${headSha}:files`; - const cached = cache.get(cacheKey); - if (cached) return cached; - - if (prKey) { - const persistent = await PersistentCache.get(cacheKey); - if (persistent) { - cache.set(cacheKey, persistent); - return persistent; - } - } + const persistent = await PersistentCache.get(cacheKey); + if (persistent) return persistent; - const pending = cache.getPending(cacheKey); + const pending = immutablePending.get(cacheKey) as + | Promise + | undefined; if (pending) return pending; const promise = octokit @@ -1447,12 +1409,12 @@ function createGitHubStore() { }) .then((res) => { const files = (res.data.files ?? []) as PullRequestFile[]; - cache.set(cacheKey, files); if (prKey) PersistentCache.put(cacheKey, files, prKey); return files; - }); + }) + .finally(() => immutablePending.delete(cacheKey)); - cache.setPending(cacheKey, promise); + immutablePending.set(cacheKey, promise); return promise; } @@ -1865,21 +1827,14 @@ function createGitHubStore() { if (!octokit) throw new Error("Not initialized"); const cacheKey = `compare:${owner}/${repo}/${baseSha}...${headSha}:commits`; + type CommitList = components["schemas"]["commit"][]; - const cached = cache.get(cacheKey); - if (cached) return cached; + const persistent = await PersistentCache.get(cacheKey); + if (persistent) return persistent; - if (prKey) { - const persistent = - await PersistentCache.get(cacheKey); - if (persistent) { - cache.set(cacheKey, persistent); - return persistent; - } - } - - const pending = - cache.getPending(cacheKey); + const pending = immutablePending.get(cacheKey) as + | Promise + | undefined; if (pending) return pending; const promise = octokit @@ -1890,13 +1845,13 @@ function createGitHubStore() { per_page: 100, }) .then((res) => { - const commits = res.data.commits as components["schemas"]["commit"][]; - cache.set(cacheKey, commits); + const commits = res.data.commits as CommitList; if (prKey) PersistentCache.put(cacheKey, commits, prKey); return commits; - }); + }) + .finally(() => immutablePending.delete(cacheKey)); - cache.setPending(cacheKey, promise); + immutablePending.set(cacheKey, promise); return promise; } diff --git a/src/browser/contexts/pr-review/index.tsx b/src/browser/contexts/pr-review/index.tsx index 7642b2b..de018f0 100644 --- a/src/browser/contexts/pr-review/index.tsx +++ b/src/browser/contexts/pr-review/index.tsx @@ -717,6 +717,7 @@ export class PRReviewStore { private async refreshFiles(): Promise { const { owner, repo, pr, selectedHeadSha, compareToSha } = this.state; + const prKey = `${owner}/${repo}/${pr.number}`; const resetBase = { loadedDiffs: {}, @@ -730,10 +731,10 @@ export class PRReviewStore { const [prevFiles, currFiles] = await Promise.all([ this.github - .getPRFilesForRange(owner, repo, pr.base.sha, compareToSha) + .getPRFilesForRange(owner, repo, pr.base.sha, compareToSha, prKey) .catch(() => [] as PullRequestFile[]), this.github - .getPRFilesForRange(owner, repo, pr.base.sha, headSha) + .getPRFilesForRange(owner, repo, pr.base.sha, headSha, prKey) .catch(() => [] as PullRequestFile[]), ]); @@ -770,7 +771,7 @@ export class PRReviewStore { }); } else if (selectedHeadSha) { const files = await this.github - .getPRFilesForRange(owner, repo, pr.base.sha, selectedHeadSha) + .getPRFilesForRange(owner, repo, pr.base.sha, selectedHeadSha, prKey) .catch(() => [] as PullRequestFile[]); this.set({ ...resetBase, @@ -3497,7 +3498,13 @@ export class PRReviewStore { const filesByVersion = await Promise.all( mergedVersions.map(async (pv) => { const files = await this.github - .getPRFilesForRange(owner, repo, pr.base.sha, pv.sha) + .getPRFilesForRange( + owner, + repo, + pr.base.sha, + pv.sha, + `${owner}/${repo}/${pr.number}` + ) .catch(() => [] as PullRequestFile[]); return { version: pv.version, From 14a5a576a4a609c9ac0751edb99c15ca652223a7 Mon Sep 17 00:00:00 2001 From: Stephen Jennings Date: Wed, 24 Jun 2026 17:11:17 -0700 Subject: [PATCH 03/10] feat(query): migrate status checks and workflow runs to React Query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPRChecksForSha and getWorkflowRunsForSha were using RequestCache (in-memory + localStorage) with a 15s TTL. They're mutable (status transitions pending→success/failure), so they go through React Query. Changes: - queries.ts: add checksByCommit(owner, repo, sha) and workflowRunsByCommit(owner, repo, sha) factories, staleTime: 15_000, no meta.persist - github.tsx: replace the RequestCache-based implementations of getPRChecksForSha / getWorkflowRunsForSha with one-line wrappers around queryClient.fetchQuery(); all 8 callers (fetchPRChecks plus 7 direct calls in pr-review/index.tsx) transparently go through React Query now - approveWorkflowRun: replace cache.invalidate('workflow-runs:...') with queryClient.invalidateQueries({ queryKey: ['workflow-runs', owner, repo] }); partial key match invalidates all SHAs for this repo, preserving the previous prefix-match behavior Resolves #262 --- src/browser/contexts/github.tsx | 86 +++------------------------------ src/browser/lib/queries.ts | 46 ++++++++++++++++++ 2 files changed, 54 insertions(+), 78 deletions(-) diff --git a/src/browser/contexts/github.tsx b/src/browser/contexts/github.tsx index 3791a56..f6dcd7a 100644 --- a/src/browser/contexts/github.tsx +++ b/src/browser/contexts/github.tsx @@ -13,6 +13,7 @@ import type { components } from "@octokit/openapi-types"; import { useAuth } from "./auth"; import * as PersistentCache from "../lib/persistent-cache"; import { setOctokit } from "../lib/github-client"; +import { queryClient } from "../lib/query-client"; import { queries } from "../lib/queries"; export type UserTeam = { org: string; slug: string }; @@ -1631,86 +1632,16 @@ function createGitHubStore() { cache.invalidate(`pr:${owner}/${repo}/${number}`); } - async function getPRChecksForSha(owner: string, repo: string, sha: string) { + function getPRChecksForSha(owner: string, repo: string, sha: string) { if (!octokit) throw new Error("Not initialized"); - - const cacheKey = `checks:${owner}/${repo}/${sha}`; - - type ChecksResult = { checkRuns: CheckRun[]; status: CombinedStatus }; - - const cached = cache.get(cacheKey, 15_000); - if (cached) return cached; - - const pending = cache.getPending(cacheKey); - if (pending) return pending; - - const promise = Promise.all([ - octokit.request("GET /repos/{owner}/{repo}/commits/{ref}/check-runs", { - owner, - repo, - ref: sha, - }), - octokit.request("GET /repos/{owner}/{repo}/commits/{ref}/status", { - owner, - repo, - ref: sha, - }), - ]).then(([checkRunsRes, statusRes]) => { - const result = { - checkRuns: checkRunsRes.data.check_runs, - status: statusRes.data, - }; - cache.set(cacheKey, result); - return result; - }); - - cache.setPending(cacheKey, promise); - return promise; + return queryClient.fetchQuery(queries.checksByCommit(owner, repo, sha)); } - async function getWorkflowRunsForSha( - owner: string, - repo: string, - sha: string - ) { + function getWorkflowRunsForSha(owner: string, repo: string, sha: string) { if (!octokit) throw new Error("Not initialized"); - - const cacheKey = `workflow-runs:${owner}/${repo}/${sha}`; - - type WorkflowRunsResult = { - workflow_runs: Array<{ - id: number; - name: string; - status: string; - conclusion: string | null; - html_url: string; - head_sha: string; - }>; - }; - - const cached = cache.get(cacheKey, 15_000); - if (cached) return cached; - - const pending = cache.getPending(cacheKey); - if (pending) return pending; - - const promise = octokit - .request("GET /repos/{owner}/{repo}/actions/runs", { - owner, - repo, - head_sha: sha, - per_page: 50, - }) - .then((res) => { - const result = { - workflow_runs: res.data.workflow_runs, - }; - cache.set(cacheKey, result); - return result; - }); - - cache.setPending(cacheKey, promise); - return promise; + return queryClient.fetchQuery( + queries.workflowRunsByCommit(owner, repo, sha) + ); } async function approveWorkflowRun( @@ -1729,8 +1660,7 @@ function createGitHubStore() { } ); - // Invalidate workflow runs cache for this repo - cache.invalidate(`workflow-runs:${owner}/${repo}`); + queryClient.invalidateQueries({ queryKey: ["workflow-runs", owner, repo] }); } async function mergePR( diff --git a/src/browser/lib/queries.ts b/src/browser/lib/queries.ts index 887d83f..e9dbac1 100644 --- a/src/browser/lib/queries.ts +++ b/src/browser/lib/queries.ts @@ -54,4 +54,50 @@ export const queries = { staleTime: 5 * 60_000, meta: { persist: true }, }), + + checksByCommit: (owner: string, repo: string, sha: string) => + queryOptions({ + queryKey: ["checks", owner, repo, sha], + queryFn: async () => { + const [checkRunsRes, statusRes] = await Promise.all([ + getOctokit().request( + "GET /repos/{owner}/{repo}/commits/{ref}/check-runs", + { owner, repo, ref: sha } + ), + getOctokit().request( + "GET /repos/{owner}/{repo}/commits/{ref}/status", + { owner, repo, ref: sha } + ), + ]); + return { + checkRuns: checkRunsRes.data + .check_runs as components["schemas"]["check-run"][], + status: + statusRes.data as components["schemas"]["combined-commit-status"], + }; + }, + staleTime: 15_000, + }), + + workflowRunsByCommit: (owner: string, repo: string, sha: string) => + queryOptions({ + queryKey: ["workflow-runs", owner, repo, sha], + queryFn: async () => { + const res = await getOctokit().request( + "GET /repos/{owner}/{repo}/actions/runs", + { owner, repo, head_sha: sha, per_page: 50 } + ); + return { + workflow_runs: res.data.workflow_runs as Array<{ + id: number; + name: string | null; + status: string | null; + conclusion: string | null; + html_url: string; + head_sha: string; + }>, + }; + }, + staleTime: 15_000, + }), }; From bba6baa8ca8fec4e1d91dd66d1b147721e94a10e Mon Sep 17 00:00:00 2001 From: Stephen Jennings Date: Wed, 24 Jun 2026 17:15:11 -0700 Subject: [PATCH 04/10] feat(query): migrate collaborators and labels to React Query Both are slow-changing, read-only from the app, and good first uses of the meta.persist path (cross-session IndexedDB persistence). Changes: - queries.ts: add collaborators(owner, repo) and labels(owner, repo) factories, staleTime: 5 min, meta: { persist: true } - github.tsx: getRepoCollaborators and getRepoLabels become one-line wrappers around queryClient.fetchQuery() - pr-overview.tsx: - PROverview: replace useState + fetchCollaborators with useQuery(queries.collaborators), drop the manual fetch-on-picker-open pattern; collaborator data is now pre-fetched and cached eagerly - LabelsSection: replace useState + fetchLabels with useQuery(queries.labels), same pattern Resolves #263 --- src/browser/components/pr-overview.tsx | 65 ++++++--------------- src/browser/contexts/github.tsx | 81 ++------------------------ src/browser/lib/queries.ts | 45 ++++++++++++++ 3 files changed, 67 insertions(+), 124 deletions(-) diff --git a/src/browser/components/pr-overview.tsx b/src/browser/components/pr-overview.tsx index ffd5275..d1adbb2 100644 --- a/src/browser/components/pr-overview.tsx +++ b/src/browser/components/pr-overview.tsx @@ -59,8 +59,11 @@ import { usePRReviewSelector, usePRReviewStore } from "../contexts/pr-review"; import { getTimeAgo, formatDateTime } from "../lib/dates"; import { parseDiffCached, type ParsedDiff } from "../lib/diff"; import type { ReviewComment } from "@/api/types"; +import { useQuery } from "@tanstack/react-query"; +import { queries } from "../lib/queries"; import { useGitHub, + useGitHubReady, useCurrentUser, type Review as GitHubReview, type IssueComment as GitHubIssueComment, @@ -204,9 +207,13 @@ export const PROverview = memo(function PROverview() { const canResolveThread = canWrite && hasWritePermission; // Reviewers and Assignees state - const [collaborators, setCollaborators] = useState< - Array<{ login: string; avatar_url: string }> - >([]); + const { ready } = useGitHubReady(); + const { data: collaboratorsRaw = [], isLoading: loadingCollaborators } = + useQuery({ ...queries.collaborators(owner, repo), enabled: ready }); + const collaborators = collaboratorsRaw.map((c) => ({ + login: c.login || "", + avatar_url: c.avatar_url || "", + })); const [showReviewersPicker, setShowReviewersPicker] = useState(false); const [showAssigneesPicker, setShowAssigneesPicker] = useState(false); const [reviewersPickerPosition, setReviewersPickerPosition] = useState({ @@ -217,7 +224,6 @@ export const PROverview = memo(function PROverview() { top: 0, left: 0, }); - const [loadingCollaborators, setLoadingCollaborators] = useState(false); const [reviewerSearchQuery, setReviewerSearchQuery] = useState(""); const [assigneeSearchQuery, setAssigneeSearchQuery] = useState(""); const reviewersButtonRef = useRef(null); @@ -366,25 +372,6 @@ export const PROverview = memo(function PROverview() { await store.updateBranch(); }, [store]); - // Fetch collaborators when picker is opened - const fetchCollaborators = useCallback(async () => { - if (collaborators.length > 0) return; - setLoadingCollaborators(true); - try { - const data = await github.getRepoCollaborators(owner, repo); - setCollaborators( - data.map((c) => ({ - login: c.login || "", - avatar_url: c.avatar_url || "", - })) - ); - } catch (error) { - console.error("Failed to fetch collaborators:", error); - } finally { - setLoadingCollaborators(false); - } - }, [github, owner, repo, collaborators.length]); - const handleToggleReviewersPicker = useCallback(() => { if (!showReviewersPicker && reviewersButtonRef.current) { const rect = reviewersButtonRef.current.getBoundingClientRect(); @@ -392,14 +379,13 @@ export const PROverview = memo(function PROverview() { top: rect.bottom + 4, left: Math.min(rect.left, window.innerWidth - 280), }); - fetchCollaborators(); setReviewerSearchQuery(""); // Focus the search input after a short delay to allow the picker to render setTimeout(() => reviewerSearchInputRef.current?.focus(), 50); } setShowReviewersPicker(!showReviewersPicker); setShowAssigneesPicker(false); - }, [showReviewersPicker, fetchCollaborators]); + }, [showReviewersPicker]); const handleToggleAssigneesPicker = useCallback(() => { if (!showAssigneesPicker && assigneesButtonRef.current) { @@ -408,14 +394,13 @@ export const PROverview = memo(function PROverview() { top: rect.bottom + 4, left: Math.min(rect.left, window.innerWidth - 280), }); - fetchCollaborators(); setAssigneeSearchQuery(""); // Focus the search input after a short delay to allow the picker to render setTimeout(() => assigneeSearchInputRef.current?.focus(), 50); } setShowAssigneesPicker(!showAssigneesPicker); setShowReviewersPicker(false); - }, [showAssigneesPicker, fetchCollaborators]); + }, [showAssigneesPicker]); // Helper to refetch PR and update store const refetchPR = useCallback(async () => { @@ -2647,28 +2632,15 @@ function LabelsSection({ onLabelToggle, canWrite = true, }: LabelsSectionProps) { - const github = useGitHub(); + const { ready } = useGitHubReady(); + const { data: repoLabels = [], isLoading: loadingLabels } = useQuery({ + ...queries.labels(owner, repo), + enabled: ready, + }); const [showPicker, setShowPicker] = useState(false); - const [repoLabels, setRepoLabels] = useState< - Array<{ name: string; color: string; description?: string | null }> - >([]); - const [loadingLabels, setLoadingLabels] = useState(false); const [pickerPosition, setPickerPosition] = useState({ top: 0, left: 0 }); const buttonRef = useRef(null); - const fetchLabels = useCallback(async () => { - if (repoLabels.length > 0) return; - setLoadingLabels(true); - try { - const labels = await github.getRepoLabels(owner, repo); - setRepoLabels(labels); - } catch (error) { - console.error("Failed to fetch labels:", error); - } finally { - setLoadingLabels(false); - } - }, [github, owner, repo, repoLabels.length]); - const handleTogglePicker = useCallback(() => { if (!showPicker && buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect(); @@ -2676,10 +2648,9 @@ function LabelsSection({ top: rect.bottom + 4, left: Math.min(rect.left, window.innerWidth - 280), }); - fetchLabels(); } setShowPicker(!showPicker); - }, [showPicker, fetchLabels]); + }, [showPicker]); const handleToggleLabel = useCallback( async (labelName: string, labelColor: string) => { diff --git a/src/browser/contexts/github.tsx b/src/browser/contexts/github.tsx index f6dcd7a..4e2f1d7 100644 --- a/src/browser/contexts/github.tsx +++ b/src/browser/contexts/github.tsx @@ -1828,34 +1828,9 @@ function createGitHubStore() { cache.invalidate(`pr:${owner}/${repo}/${number}`); } - async function getRepoCollaborators(owner: string, repo: string) { + function getRepoCollaborators(owner: string, repo: string) { if (!octokit) throw new Error("Not initialized"); - - const cacheKey = `repo:${owner}/${repo}:collaborators`; - - const cached = cache.get( - cacheKey, - 300_000 - ); - if (cached) return cached; - - const pending = - cache.getPending(cacheKey); - if (pending) return pending; - - const promise = octokit - .request("GET /repos/{owner}/{repo}/collaborators", { - owner, - repo, - per_page: 100, - }) - .then((res) => { - cache.set(cacheKey, res.data); - return res.data; - }); - - cache.setPending(cacheKey, promise); - return promise; + return queryClient.fetchQuery(queries.collaborators(owner, repo)); } async function addAssignees( @@ -1901,57 +1876,9 @@ function createGitHubStore() { cache.invalidate(`pr:${owner}/${repo}/${issueNumber}`); } - async function getRepoLabels(owner: string, repo: string) { + function getRepoLabels(owner: string, repo: string) { if (!octokit) throw new Error("Not initialized"); - - const cacheKey = `repo:${owner}/${repo}:labels`; - - const cached = cache.get< - Array<{ name: string; color: string; description: string | null }> - >(cacheKey, 300_000); - if (cached) return cached; - - const pending = - cache.getPending< - Array<{ name: string; color: string; description: string | null }> - >(cacheKey); - if (pending) return pending; - - const promise = (async () => { - // Manually paginate to get all labels - const allLabels: Array<{ - name: string; - color: string; - description: string | null; - }> = []; - let page = 1; - while (true) { - const { data: labels } = await octokit.request( - "GET /repos/{owner}/{repo}/labels", - { - owner, - repo, - per_page: 100, - page, - } - ); - for (const l of labels) { - allLabels.push({ - name: l.name, - color: l.color, - description: l.description ?? null, - }); - } - if (labels.length < 100) break; - page++; - } - cache.set(cacheKey, allLabels); - cache.clearPending(cacheKey); - return allLabels; - })(); - - cache.setPending(cacheKey, promise); - return promise; + return queryClient.fetchQuery(queries.labels(owner, repo)); } async function addLabels( diff --git a/src/browser/lib/queries.ts b/src/browser/lib/queries.ts index e9dbac1..a922e7d 100644 --- a/src/browser/lib/queries.ts +++ b/src/browser/lib/queries.ts @@ -100,4 +100,49 @@ export const queries = { }, staleTime: 15_000, }), + + collaborators: (owner: string, repo: string) => + queryOptions({ + queryKey: ["collaborators", owner, repo], + queryFn: async () => { + const res = await getOctokit().request( + "GET /repos/{owner}/{repo}/collaborators", + { owner, repo, per_page: 100 } + ); + return res.data as components["schemas"]["collaborator"][]; + }, + staleTime: 5 * 60_000, + meta: { persist: true }, + }), + + labels: (owner: string, repo: string) => + queryOptions({ + queryKey: ["labels", owner, repo], + queryFn: async () => { + const allLabels: Array<{ + name: string; + color: string; + description: string | null; + }> = []; + let page = 1; + while (true) { + const { data } = await getOctokit().request( + "GET /repos/{owner}/{repo}/labels", + { owner, repo, per_page: 100, page } + ); + for (const l of data) { + allLabels.push({ + name: l.name, + color: l.color, + description: l.description ?? null, + }); + } + if (data.length < 100) break; + page++; + } + return allLabels; + }, + staleTime: 5 * 60_000, + meta: { persist: true }, + }), }; From d7d2cf04ad0ff09fb9ba2e5c5cd73d177e4b2882 Mon Sep 17 00:00:00 2001 From: Stephen Jennings Date: Wed, 24 Jun 2026 17:50:55 -0700 Subject: [PATCH 05/10] feat(query): migrate PR list and search to React Query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled fetchPRList / searchPRs / searchRepos / searchUsers functions (which all used RequestCache internally) with four new queryOptions factories in queries.ts: - queries.prList(queryStrings, page, perPage) — compound query that runs all search strings in parallel, deduplicates, sorts, and enriches via a single GraphQL call. placeholderData: keepPreviousData prevents pagination flicker. - queries.searchPRs(query, page, perPage) — thin wrapper used by the store for the fetchInvolvedPRs helper. - queries.searchRepos(query) — used imperatively in the home repo-search dropdown. - queries.searchUsers(query) — used in the markdown @-mention autocomplete. The prList enrichment logic (GraphQL PR metadata batch) is inlined into the queryFn in queries.ts, calling getOctokit().graphql() directly rather than routing through the store's GraphQLBatcher (the batcher was only a 5ms micro-optimisation, unnecessary inside a queryFn). home.tsx now derives its data from useQuery instead of useSyncExternalStore: const { data, isFetching, isPending, dataUpdatedAt } = useQuery({ ...queries.prList(searchQueries, page, perPage), enabled: ready }); RefreshCountdown receives dataUpdatedAt (a React Query timestamp) instead of the old PRListState.lastFetchedAt. The manual 60-second setInterval auto- refresh is removed; React Query's staleTime (30 s) + refetchOnWindowFocus covers the same job. invalidatePRCaches() in pr-review/index.tsx now also invalidates ["pr-list"] and ["search", "prs"] so the home page reflects PR state changes (close / reopen / merge / enqueue) immediately. GitHubState loses prList, prListQueries, and prListPage; the reset() function calls queryClient.removeQueries(["pr-list"]) instead. fetchPRList, refreshPRList, usePRList, and usePRListActions are removed. Resolves #264 --- src/browser/components/home.tsx | 41 +-- src/browser/contexts/github.tsx | 337 +-------------------- src/browser/contexts/pr-review/index.tsx | 4 + src/browser/lib/queries.ts | 356 ++++++++++++++++++++++- 4 files changed, 391 insertions(+), 347 deletions(-) diff --git a/src/browser/components/home.tsx b/src/browser/components/home.tsx index de1e25d..aa65790 100644 --- a/src/browser/components/home.tsx +++ b/src/browser/components/home.tsx @@ -1,4 +1,6 @@ import { useState, useEffect, useCallback, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { queries } from "../lib/queries"; import { Search, GitPullRequest, @@ -43,8 +45,6 @@ import { import { useGitHubStore, useGitHubReady, - usePRList, - usePRListActions, getCachedTeams, type PRSearchResult, } from "../contexts/github"; @@ -326,9 +326,7 @@ export function Home() { const github = useGitHubStore(); const { isAuthenticated } = useAuth(); - // Data store - const prList = usePRList(); - const { fetchPRList, refreshPRList } = usePRListActions(); + const queryClient = useQueryClient(); // Filter config const [config, setConfig] = useState(getFilterConfig); @@ -361,21 +359,29 @@ export function Home() { saveFilterConfig(config); }, [config]); - useEffect(() => { - if (githubReady) { - fetchPRList(searchQueries, page, perPage); - } - }, [fetchPRList, searchQueries, page, perPage, githubReady]); - // Reset page when config changes useEffect(() => { setPage(1); }, [config.repos, config.state]); + // PR list via React Query + const { + data: prListData, + isFetching: loadingPrs, + isPending: prListPending, + dataUpdatedAt, + } = useQuery({ + ...queries.prList(searchQueries, page, perPage), + enabled: githubReady, + }); + + const refreshPRList = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ["pr-list"] }); + }, [queryClient]); + // Convenience accessors - const prs = prList.items; - const loadingPrs = prList.loading; - const totalCount = prList.totalCount; + const prs = prListData?.items ?? []; + const totalCount = prListData?.totalCount ?? 0; // Client-side filter for UPDATED PRs const filteredPrs = useMemo(() => { @@ -1045,8 +1051,8 @@ export function Home() { )}
- {prList.lastFetchedAt && !loadingPrs && ( - + {dataUpdatedAt > 0 && !loadingPrs && ( + )}