From 39e3ce2d52ea8befc930b264d2f0d54a1be4825f Mon Sep 17 00:00:00 2001 From: Alassane <79718216+yattdev@users.noreply.github.com> Date: Thu, 10 Sep 2026 06:09:25 -0400 Subject: [PATCH] fix(web): retain eligible providers during refresh (#3498) Co-authored-by: ayattara Co-authored-by: Carlos Florencio --- .../use-remote-repositories.test.tsx | 108 +++++++++++++++++- .../integrations/use-remote-repositories.ts | 7 +- 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx b/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx index 41778c76fa..9e36fcb6b3 100644 --- a/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx +++ b/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx @@ -33,6 +33,7 @@ import { useRemoteRepositories } from "./use-remote-repositories"; import { pluginRegistry } from "@/lib/plugins/registry"; const WORKSPACE_ID = "workspace-1"; +const NEXT_WORKSPACE_ID = "workspace-2"; const PLUGIN_ID = "test-bitbucket-provider"; const GITLAB_UNAVAILABLE = "GitLab unavailable"; @@ -84,6 +85,14 @@ function expectWorkspaceScopedGitHubRequest() { }); } +const githubRepo = (name: string) => ({ + owner: "acme", + name, + full_name: `acme/${name}`, + default_branch: "main", + private: false, +}); + describe("useRemoteRepositories registered providers", () => { it("isolates a provider URL matcher failure", async () => { pluginRegistry.forPlugin(PLUGIN_ID).registerRepositoryProvider({ @@ -412,13 +421,15 @@ describe("useRemoteRepositories provider eligibility changes", () => { expect(result.current.repos[0]?.provider).toBe("github"); expect(mocks.listUserProjects).not.toHaveBeenCalled(); - rerender({ workspaceId: "workspace-2" }); + rerender({ workspaceId: NEXT_WORKSPACE_ID }); await waitFor(() => expect(result.current.repos[0]?.provider).toBe("gitlab")); expect(mocks.fetchAccessibleRepos).toHaveBeenCalledTimes(1); expect(mocks.listUserProjects).toHaveBeenCalledTimes(1); }); +}); +describe("useRemoteRepositories provider refreshes", () => { it("re-evaluates provider eligibility before a manual refresh", async () => { mocks.fetchAccessibleRepos.mockResolvedValue([]); mocks.listUserProjects.mockResolvedValue({ projects: [] }); @@ -438,6 +449,54 @@ describe("useRemoteRepositories provider eligibility changes", () => { expect(mocks.listAzureDevOpsProjects).toHaveBeenCalledTimes(1); await waitFor(() => expect(result.current.availableProviders).toEqual(["github"])); }); + + it("keeps currently eligible providers available while a refresh is loading", async () => { + mocks.fetchAccessibleRepos.mockResolvedValue([]); + mocks.listUserProjects.mockResolvedValue({ projects: [] }); + mocks.listAzureDevOpsProjects.mockResolvedValue({ projects: [] }); + const { result } = renderHook(() => useRemoteRepositories(WORKSPACE_ID)); + + await waitFor(() => expect(result.current.loading).toBe(false)); + let resolveRefresh: ((repos: never[]) => void) | undefined; + mocks.fetchAccessibleRepos.mockImplementationOnce( + () => new Promise((resolve) => (resolveRefresh = resolve)), + ); + setBuiltInAvailability({ azureDevOps: false, gitlab: false }); + + act(() => result.current.refresh?.()); + + await waitFor(() => expect(mocks.fetchAccessibleRepos).toHaveBeenCalledTimes(2)); + expect(result.current.availableProviders).toEqual(["github"]); + expect(result.current.repos).toEqual([]); + + act(() => resolveRefresh?.([])); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + it("clears a provider error while its refresh retry is loading", async () => { + setBuiltInAvailability({ gitlab: false, azureDevOps: false }); + mocks.fetchAccessibleRepos.mockRejectedValueOnce(new Error("GitHub unavailable")); + const { result } = renderHook(() => useRemoteRepositories(WORKSPACE_ID)); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.sourceErrors).toEqual([ + { provider: "github", error: new Error("GitHub unavailable") }, + ]); + + let resolveRetry: ((repos: never[]) => void) | undefined; + mocks.fetchAccessibleRepos.mockImplementationOnce( + () => new Promise((resolve) => (resolveRetry = resolve)), + ); + + act(() => result.current.refresh?.()); + + await waitFor(() => expect(mocks.fetchAccessibleRepos).toHaveBeenCalledTimes(2)); + expect(result.current.loading).toBe(true); + expect(result.current.sourceErrors).toEqual([]); + + act(() => resolveRetry?.([])); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); }); describe("useRemoteRepositories workspace scope", () => { @@ -486,11 +545,56 @@ describe("useRemoteRepositories workspace scope", () => { ); await waitFor(() => expect(result.current.repos).toHaveLength(1)); - rerender({ workspaceId: "workspace-2" }); + rerender({ workspaceId: NEXT_WORKSPACE_ID }); await waitFor(() => expect(result.current.loading).toBe(true)); expect(result.current.repos).toEqual([]); act(() => resolveNext?.({ projects: [] })); await waitFor(() => expect(result.current.loading).toBe(false)); }); + + it("does not publish a stale repository result after a workspace change", async () => { + setBuiltInAvailability({ gitlab: false, azureDevOps: false }); + let resolveFirst: ((value: unknown) => void) | undefined; + let resolveSecond: ((value: unknown) => void) | undefined; + mocks.fetchAccessibleRepos + .mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))) + .mockImplementationOnce(() => new Promise((resolve) => (resolveSecond = resolve))); + const { result, rerender } = renderHook( + ({ workspaceId }) => useRemoteRepositories(workspaceId), + { initialProps: { workspaceId: WORKSPACE_ID } }, + ); + + await waitFor(() => expect(mocks.fetchAccessibleRepos).toHaveBeenCalledTimes(1)); + rerender({ workspaceId: NEXT_WORKSPACE_ID }); + await waitFor(() => expect(mocks.fetchAccessibleRepos).toHaveBeenCalledTimes(2)); + + await act(async () => resolveFirst?.([githubRepo("stale")])); + expect(result.current.repos).toEqual([]); + + await act(async () => resolveSecond?.([githubRepo("current")])); + await waitFor(() => + expect(result.current.repos.map((repo) => repo.fullName)).toEqual(["acme/current"]), + ); + }); + + it("ignores a result resolved before the next workspace request starts", async () => { + setBuiltInAvailability({ gitlab: false, azureDevOps: false }); + let resolveFirst: ((value: unknown) => void) | undefined; + mocks.fetchAccessibleRepos + .mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))) + .mockImplementationOnce(() => new Promise(() => {})); + const { result, rerender } = renderHook( + ({ workspaceId }) => useRemoteRepositories(workspaceId), + { initialProps: { workspaceId: WORKSPACE_ID } }, + ); + + await waitFor(() => expect(mocks.fetchAccessibleRepos).toHaveBeenCalledTimes(1)); + await act(async () => { + resolveFirst?.([githubRepo("stale")]); + rerender({ workspaceId: NEXT_WORKSPACE_ID }); + }); + expect(result.current.repos).toEqual([]); + await waitFor(() => expect(mocks.fetchAccessibleRepos).toHaveBeenCalledTimes(2)); + }); }); diff --git a/apps/web/hooks/domains/integrations/use-remote-repositories.ts b/apps/web/hooks/domains/integrations/use-remote-repositories.ts index 260605409e..91382a71bd 100644 --- a/apps/web/hooks/domains/integrations/use-remote-repositories.ts +++ b/apps/web/hooks/domains/integrations/use-remote-repositories.ts @@ -307,11 +307,16 @@ function useBuiltInRepositorySource( const [loading, setLoading] = useState(true); const [sourceErrors, setSourceErrors] = useState([]); const [availableProviders, setAvailableProviders] = useState([]); + const workspaceRef = useRef(workspaceId); useEffect(() => { let cancelled = false; + const sameWorkspace = workspaceRef.current === workspaceId; + workspaceRef.current = workspaceId; setRepos([]); - setAvailableProviders([]); + setAvailableProviders((current) => + sameWorkspace ? current.filter((provider) => eligibility.providers.has(provider)) : [], + ); setSourceErrors([]); setLoading(true); if (eligibility.loading) {