From e24e99077058793283561ffb471ac34769991812 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 16:36:26 +0000 Subject: [PATCH] fix: scope react-query cache invalidation per mutation Each git mutation was invalidating every cached query on settle, so staging a file refetched the branch list, log, stashes, etc. Each mutation now invalidates only the query groups its git command can actually change, mirroring the scope each server route already emits over SSE (bus.emit('refs' | 'index' | 'worktree' | 'all')). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZi4RzgNGmMDB5W4DycPhq --- src/client/api/queries.ts | 104 +++++++++++++++++++++++++++---- tests/query-invalidation.test.ts | 50 +++++++++++++++ 2 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 tests/query-invalidation.test.ts diff --git a/src/client/api/queries.ts b/src/client/api/queries.ts index 6ea65f2..55ceb43 100644 --- a/src/client/api/queries.ts +++ b/src/client/api/queries.ts @@ -1,6 +1,7 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'; import { api } from './client'; import type { + ChangeScope, CheckoutBody, CommitBody, CreateBranchBody, @@ -69,55 +70,136 @@ export interface MutationCallbacks { onError?: (msg: string) => void; } +/** Mirrors each git route's own bus.emit(scope) call (see server/routes/*.ts) so a + mutation only refetches the query groups its git command could actually change. */ +export function invalidateScope(qc: QueryClient, path: string, scope: ChangeScope, invalidateStashes = false) { + if (scope === 'refs' || scope === 'all') { + qc.invalidateQueries({ queryKey: ['log', path] }); + qc.invalidateQueries({ queryKey: ['refs', path] }); + qc.invalidateQueries({ queryKey: ['repo', path] }); + } + if (scope === 'index' || scope === 'worktree' || scope === 'all') { + qc.invalidateQueries({ queryKey: ['status', path] }); + qc.invalidateQueries({ queryKey: ['worktrees', path] }); + qc.invalidateQueries({ queryKey: ['diff', path, 'working'] }); + } + if (invalidateStashes) qc.invalidateQueries({ queryKey: ['stashes', path] }); +} + function useGitMutation( fn: (args: TArgs) => Promise, successMsg: (args: TArgs) => string, cb: MutationCallbacks, + path: string, + scope: ChangeScope, + invalidateStashes = false, ) { const qc = useQueryClient(); return useMutation({ mutationFn: fn, onSuccess: (_d, args) => cb.onSuccess?.(successMsg(args)), onError: (err) => cb.onError?.(err instanceof Error ? err.message : String(err)), - onSettled: () => qc.invalidateQueries(), + onSettled: () => invalidateScope(qc, path, scope, invalidateStashes), }); } export function useGitActions(path: string, cb: MutationCallbacks) { - const checkout = useGitMutation((b: CheckoutBody) => api.checkout(path, b), (a) => `Checked out ${a.ref}`, cb); + const checkout = useGitMutation( + (b: CheckoutBody) => api.checkout(path, b), + (a) => `Checked out ${a.ref}`, + cb, + path, + 'refs', + ); const createBranch = useGitMutation( (b: CreateBranchBody) => api.createBranch(path, b), (a) => `Created branch ${a.name}${a.checkout ? ' · checked out' : ''}`, cb, + path, + 'refs', + ); + const createTag = useGitMutation( + (b: CreateTagBody) => api.createTag(path, b), + (a) => `Created tag ${a.name}`, + cb, + path, + 'refs', ); - const createTag = useGitMutation((b: CreateTagBody) => api.createTag(path, b), (a) => `Created tag ${a.name}`, cb); const stage = useGitMutation( (b: StagePathsBody) => api.stage(path, b), (a) => (a.paths.length > 1 ? `Staged ${a.paths.length} files` : `Staged ${a.paths[0]}`), cb, + path, + 'index', ); const unstage = useGitMutation( (b: StagePathsBody) => api.unstage(path, b), (a) => (a.paths.length > 1 ? `Unstaged ${a.paths.length} files` : `Unstaged ${a.paths[0]}`), cb, + path, + 'index', ); const commit = useGitMutation( (b: CommitBody) => api.commit(path, b), (a) => (a.amend ? 'Amended last commit' : 'Committed staged changes'), cb, + path, + 'all', + ); + const uncommit = useGitMutation( + (_b: void) => api.uncommit(path), + () => 'Uncommitted — changes kept staged', + cb, + path, + 'all', ); - const uncommit = useGitMutation((_b: void) => api.uncommit(path), () => 'Uncommitted — changes kept staged', cb); const fetchRemote = useGitMutation( (b: FetchBody) => api.fetchRemote(path, b), (a) => `Fetched ${a.remote ?? 'all remotes'}`, cb, + path, + 'refs', + ); + const push = useGitMutation( + (b: PushBody) => api.push(path, b), + (a) => `Pushed ${a.ref ?? 'current branch'}`, + cb, + path, + 'refs', + ); + const pull = useGitMutation((_b: void) => api.pull(path), () => 'Pulled', cb, path, 'all'); + const stashPush = useGitMutation( + (b: StashPushBody) => api.stashPush(path, b), + () => 'Stashed changes', + cb, + path, + 'all', + true, + ); + const stashApply = useGitMutation( + (b: StashRefBody) => api.stashApply(path, b), + () => 'Applied stash', + cb, + path, + 'all', + true, + ); + const stashPop = useGitMutation( + (b: StashRefBody) => api.stashPop(path, b), + () => 'Popped stash', + cb, + path, + 'all', + true, + ); + const stashDrop = useGitMutation( + (b: StashRefBody) => api.stashDrop(path, b), + () => 'Dropped stash', + cb, + path, + 'refs', + true, ); - const push = useGitMutation((b: PushBody) => api.push(path, b), (a) => `Pushed ${a.ref ?? 'current branch'}`, cb); - const pull = useGitMutation((_b: void) => api.pull(path), () => 'Pulled', cb); - const stashPush = useGitMutation((b: StashPushBody) => api.stashPush(path, b), () => 'Stashed changes', cb); - const stashApply = useGitMutation((b: StashRefBody) => api.stashApply(path, b), () => 'Applied stash', cb); - const stashPop = useGitMutation((b: StashRefBody) => api.stashPop(path, b), () => 'Popped stash', cb); - const stashDrop = useGitMutation((b: StashRefBody) => api.stashDrop(path, b), () => 'Dropped stash', cb); return { checkout, createBranch, diff --git a/tests/query-invalidation.test.ts b/tests/query-invalidation.test.ts new file mode 100644 index 0000000..18c15cf --- /dev/null +++ b/tests/query-invalidation.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { invalidateScope } from '../src/client/api/queries.js'; + +function mockQueryClient() { + return { invalidateQueries: vi.fn() }; +} + +function invalidatedKeys(qc: ReturnType): string[] { + return qc.invalidateQueries.mock.calls.map(([{ queryKey }]) => (queryKey as unknown[])[0] as string); +} + +describe('invalidateScope', () => { + it('index scope only touches status/worktrees/diff (e.g. stage/unstage)', () => { + const qc = mockQueryClient(); + invalidateScope(qc as never, '/repo', 'index'); + expect(invalidatedKeys(qc).sort()).toEqual(['diff', 'status', 'worktrees']); + }); + + it('refs scope only touches log/refs/repo (e.g. checkout/branch/tag/fetch/push)', () => { + const qc = mockQueryClient(); + invalidateScope(qc as never, '/repo', 'refs'); + expect(invalidatedKeys(qc).sort()).toEqual(['log', 'refs', 'repo']); + }); + + it('all scope touches every group (e.g. commit/uncommit/pull)', () => { + const qc = mockQueryClient(); + invalidateScope(qc as never, '/repo', 'all'); + expect(invalidatedKeys(qc).sort()).toEqual(['diff', 'log', 'refs', 'repo', 'status', 'worktrees']); + }); + + it('never touches stashes unless explicitly requested', () => { + const qc = mockQueryClient(); + invalidateScope(qc as never, '/repo', 'all'); + expect(invalidatedKeys(qc)).not.toContain('stashes'); + }); + + it('invalidates stashes when a stash mutation asks for it, alongside its scope', () => { + const qc = mockQueryClient(); + invalidateScope(qc as never, '/repo', 'refs', true); + expect(invalidatedKeys(qc).sort()).toEqual(['log', 'refs', 'repo', 'stashes']); + }); + + it('scopes all invalidated keys to the given repo path', () => { + const qc = mockQueryClient(); + invalidateScope(qc as never, '/some/repo', 'all', true); + for (const [{ queryKey }] of qc.invalidateQueries.mock.calls) { + expect((queryKey as unknown[])[1]).toBe('/some/repo'); + } + }); +});