From 535fdaa63de89600f8dc72926e948b6c88dd53bc Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Fri, 24 Jul 2026 23:22:26 +0800 Subject: [PATCH] perf(repos): serialize repository list refreshes --- .../__tests__/repoListCoordinator.test.ts | 99 +++++++++++++++++ src/api/tauri/repo/index.ts | 104 +++++++++++++++++- .../git/useRepoSelection/useRepoLoader.ts | 11 +- 3 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 src/api/tauri/repo/__tests__/repoListCoordinator.test.ts diff --git a/src/api/tauri/repo/__tests__/repoListCoordinator.test.ts b/src/api/tauri/repo/__tests__/repoListCoordinator.test.ts new file mode 100644 index 000000000..44aafec59 --- /dev/null +++ b/src/api/tauri/repo/__tests__/repoListCoordinator.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { __TESTS_ONLY, deleteRepo, getRepos } from "@src/api/tauri/repo"; + +const { invokeMock } = vi.hoisted(() => ({ + invokeMock: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: invokeMock, +})); + +function backendRepo(id: string) { + return { + id, + repo_id: id, + name: id, + path: `/repos/${id}`, + }; +} + +describe("repository list coordinator", () => { + beforeEach(() => { + __TESTS_ONLY.resetRepoListCoordinator(); + invokeMock.mockReset(); + }); + + it("shares one list request between concurrent consumers", async () => { + invokeMock.mockResolvedValue([backendRepo("one")]); + + const [first, second] = await Promise.all([getRepos(), getRepos()]); + + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(first).toEqual(second); + }); + + it("runs one trailing request when force refresh arrives in flight", async () => { + let releaseFirst!: (repos: ReturnType[]) => void; + invokeMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = resolve; + }) + ) + .mockResolvedValueOnce([backendRepo("fresh")]); + + const initial = getRepos(); + const forced = getRepos({ forceRefresh: true }); + releaseFirst([backendRepo("stale")]); + + const [initialResult, forcedResult] = await Promise.all([initial, forced]); + + expect(invokeMock).toHaveBeenCalledTimes(2); + expect(initialResult.data.repos[0]?.repo_id).toBe("fresh"); + expect(forcedResult.data.repos[0]?.repo_id).toBe("fresh"); + }); + + it("refreshes an active list after a repository mutation", async () => { + let releaseFirst!: (repos: ReturnType[]) => void; + invokeMock.mockImplementation((command: string) => { + if (command === "server_delete_repo") return Promise.resolve(true); + if ( + invokeMock.mock.calls.filter(([name]) => name === "server_list_repos") + .length === 1 + ) { + return new Promise((resolve) => { + releaseFirst = resolve; + }); + } + return Promise.resolve([backendRepo("remaining")]); + }); + + const listing = getRepos(); + await deleteRepo("removed"); + releaseFirst([backendRepo("removed"), backendRepo("remaining")]); + const result = await listing; + + expect( + invokeMock.mock.calls.filter(([name]) => name === "server_list_repos") + ).toHaveLength(2); + expect(result.data.repos.map((repo) => repo.repo_id)).toEqual([ + "remaining", + ]); + }); + + it("releases a failed list request so a later load can retry", async () => { + invokeMock + .mockRejectedValueOnce(new Error("backend unavailable")) + .mockResolvedValueOnce([backendRepo("recovered")]); + + await expect(getRepos()).rejects.toThrow("backend unavailable"); + await expect(getRepos()).resolves.toMatchObject({ + data: { repos: [{ repo_id: "recovered" }] }, + }); + + expect(invokeMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/api/tauri/repo/index.ts b/src/api/tauri/repo/index.ts index 07888b765..17690feed 100644 --- a/src/api/tauri/repo/index.ts +++ b/src/api/tauri/repo/index.ts @@ -36,12 +36,31 @@ function wrapResponse(data: T) { return { data, status: 0 }; } +interface RepoListResponse { + data: RepoList; + status: number; +} + +interface RepoListFlight { + forceRefresh: boolean; + generation: number; + promise: Promise; +} + +let repoListGeneration = 0; +let repoListFlight: RepoListFlight | undefined; +let repoListForcePending = false; + +function markRepoListChanged(forceRefresh = false): void { + repoListGeneration += 1; + repoListForcePending ||= forceRefresh; +} + // ============================================ // Repository CRUD (via Tauri commands) // ============================================ -/** Get current user's repository list */ -export async function getRepos() { +async function fetchRepos(): Promise { const repos = await invokeTauri< Array<{ id: string; @@ -66,6 +85,72 @@ export async function getRepos() { return wrapResponse({ repos: mapped }); } +/** + * Get the current repository list with one shared IPC request. + * + * A force request arriving behind a normal load, or a mutation completing + * during a load, advances the generation. The old response is awaited but not + * returned; all callers then share one trailing request. + */ +export async function getRepos(options?: { + forceRefresh?: boolean; +}): Promise { + const forceRefresh = options?.forceRefresh ?? false; + const current = repoListFlight; + + if ( + forceRefresh && + current && + !current.forceRefresh && + current.generation === repoListGeneration + ) { + markRepoListChanged(true); + } + + if (current) { + try { + const response = await current.promise; + if ( + current.generation === repoListGeneration && + (!forceRefresh || current.forceRefresh) + ) { + return response; + } + } catch (error) { + if (current.generation === repoListGeneration) throw error; + } + return getRepos({ forceRefresh }); + } + + const effectiveForceRefresh = forceRefresh || repoListForcePending; + repoListForcePending = false; + const generation = repoListGeneration; + const promise = fetchRepos(); + const flight: RepoListFlight = { + forceRefresh: effectiveForceRefresh, + generation, + promise, + }; + repoListFlight = flight; + const release = () => { + if (repoListFlight === flight) repoListFlight = undefined; + }; + void promise.then(release, release); + + try { + const response = await promise; + if (generation !== repoListGeneration) { + return getRepos(); + } + return response; + } catch (error) { + if (generation !== repoListGeneration) { + return getRepos(); + } + throw error; + } +} + /** Get repository by ID (path) */ export async function getRepoById(repoId: string) { const result = await invokeTauri<{ @@ -88,6 +173,7 @@ export async function getRepoById(repoId: string) { /** Delete / unwatch repository */ export async function deleteRepo(repoId: string) { await invokeTauri("server_delete_repo", { repoId }); + markRepoListChanged(); return wrapResponse(null); } @@ -97,6 +183,7 @@ export async function updateRepoVisibility( visibility: "public" | "private" ) { await invokeTauri("server_update_repo_visibility", { path, visibility }); + markRepoListChanged(); } /** Check GitHub repo visibility via backend (no CORS issues). Returns "public", "private", or null. */ @@ -124,6 +211,7 @@ export async function importLocalRepo(data: { fs_path: string }) { path: string; kind?: string; }>("server_import_repo", { path: data.fs_path }); + markRepoListChanged(); const repo: Repo = { repo_id: result.repo_id || result.id, user_id: "", @@ -149,6 +237,7 @@ export async function createFromGithub(data: { url: data.github_url, targetDir: data.fs_path, }); + markRepoListChanged(); const repo: Repo = { repo_id: result.repo_id || result.id, user_id: "", @@ -176,6 +265,7 @@ export async function createEmptyRepo(data: { path: data.fs_path, name: data.name, }); + markRepoListChanged(); const repo: Repo = { repo_id: result.repo_id || result.id, user_id: "", @@ -195,6 +285,7 @@ export async function importWorkFolder(data: { fs_path: string }) { path: string; kind: string; }>("server_import_folder", { path: data.fs_path }); + markRepoListChanged(); const repo: Repo = { repo_id: result.repo_id || result.id, user_id: "", @@ -220,6 +311,7 @@ export async function createWorkFolder(data: { path: data.fs_path, name: data.name, }); + markRepoListChanged(); const repo: Repo = { repo_id: result.repo_id || result.id, user_id: "", @@ -293,4 +385,12 @@ export const repoApi = { detectIDEs, }; +export const __TESTS_ONLY = { + resetRepoListCoordinator() { + repoListGeneration = 0; + repoListFlight = undefined; + repoListForcePending = false; + }, +}; + export default repoApi; diff --git a/src/hooks/git/useRepoSelection/useRepoLoader.ts b/src/hooks/git/useRepoSelection/useRepoLoader.ts index 1aa1e69ef..6dce4b8bc 100644 --- a/src/hooks/git/useRepoSelection/useRepoLoader.ts +++ b/src/hooks/git/useRepoSelection/useRepoLoader.ts @@ -94,6 +94,7 @@ export function useRepoLoader(): UseRepoLoaderReturn { const isHotReloadRef = useRef(false); const selectedRepoIdRef = useRef(selectedRepoId); const loadGenerationRef = useRef(0); + const forceRefreshRequestedRef = useRef(false); // === HOT RELOAD FIX === if (repos.length > 0 && !loadedReposRef.current && !isHotReloadRef.current) { @@ -124,11 +125,13 @@ export function useRepoLoader(): UseRepoLoaderReturn { } const loadRepos = useCallback(async () => { - if (globalLoadInProgress) { + const forceRefresh = forceRefreshRequestedRef.current; + forceRefreshRequestedRef.current = false; + if (globalLoadInProgress && !forceRefresh) { return; } - if (globalReposLoaded && loadedReposRef.current) { + if (!forceRefresh && globalReposLoaded && loadedReposRef.current) { return; } @@ -139,7 +142,7 @@ export function useRepoLoader(): UseRepoLoaderReturn { let loadSucceeded = false; try { - const response = await getRepos(); + const response = await getRepos({ forceRefresh }); // Discard stale response when forceRefreshRepos() started a newer call. // The newer call already owns globalLoadInProgress and loadingReposRef, @@ -230,7 +233,7 @@ export function useRepoLoader(): UseRepoLoaderReturn { const forceRefreshRepos = useCallback(async () => { loadedReposRef.current = false; setGlobalReposLoaded(false); - setGlobalLoadInProgress(false); + forceRefreshRequestedRef.current = true; await loadRepos(); }, [loadRepos]);