PR #3498
Sections
Review

fix(web): retain eligible providers during refresh

main ← feature/fix-repository-provi-bdm 2 files +99 −1 PR #3498 ↗

Refresh now keeps eligible providers visible and filters stale results, so the repository picker does not flicker or show data from the previous workspace.

Why this change

Manual refresh cleared availableProviders to empty before the new request finished. The picker showed no provider for a moment even when the provider was still eligible. A late response from a previous workspace could also overwrite the current result.

What it does

Architecture, end to end

The hook merges built-in and plugin sources. Eligibility gates which providers are requested. Refresh re-checks eligibility and bumps a version to reload.

flowchart LR
  WS[workspaceId] --> Access[useBuiltInRepositoryAccess]
  Access --> Elig[eligibility.providers]
  Elig --> BuiltIn[useBuiltInRepositorySource]
  BuiltIn --> Load[loadBuiltInRepositories]
  Load --> Settle[settleRepositoryRequests]
  Settle --> UI[repos + availableProviders]
  Refresh[refresh()] --> Access
  Refresh --> Version[refreshVersion++]
  Version --> BuiltIn
  WS --> Plugin[usePluginRepositorySource]
  Plugin --> UI

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

function useBuiltInRepositorySource(workspaceId, refreshVersion, eligibility)
Click for details →

Keeps current providers visible while refresh loads and filters only providers that are no longer eligible.

Diff: keep eligible providers
  workspaceId: string,
  refreshVersion: number,
  eligibility: BuiltInRepositoryEligibility,
): RepositorySourceState {
  const [repos, setRepos] = useState<RemoteRepository[]>([]);
  const [loading, setLoading] = useState(true);
  const [sourceErrors, setSourceErrors] = useState<RemoteRepositorySourceError[]>([]);
  const [availableProviders, setAvailableProviders] = useState<RemoteRepositoryProvider[]>([]);
  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);
Guard stale responses
loadBuiltInRepositories(workspaceId, eligibility.providers)
  .then((result) => {
    if (cancelled) return;
    setRepos(result.repos);
    setAvailableProviders(result.availableProviders);
    setSourceErrors(result.sourceErrors);
  })
  .catch((cause) => {
    if (!cancelled) setSourceErrors([{ provider: "built-in", error: toError(cause) }]);
  })
  .finally(() => {
    if (!cancelled) setLoading(false);
  });
return () => {
  cancelled = true;
};
refresh(): void
Click for details →

Refresh triggers provider status checks and bumps the version so the effect reloads with current eligibility.

Eligibility and refresh
function useBuiltInRepositoryAccess(workspaceId: string): BuiltInRepositoryAccess {
  const githubStatus = useGitHubStatus(workspaceId);
  const gitlabStatus = useGitLabStatus(workspaceId);
  const azureDevOpsConnection = useAzureDevOpsConnection(workspaceId || undefined);
  const providers = useMemo(() => {
    const eligible = new Set<RemoteRepositoryProvider>();
    if (hasProviderConnection(githubStatus.status)) eligible.add("github");
    if (hasProviderConnection(gitlabStatus.status)) eligible.add("gitlab");
    if (azureDevOpsConnection.data?.hasSecret && azureDevOpsConnection.data.lastOk) {
      eligible.add("azure_devops");
    }
    return eligible;
  }, [azureDevOpsConnection.data, githubStatus.status, gitlabStatus.status]);
  const refresh = useCallback(() => {
    void githubStatus.refresh();
    void gitlabStatus.refresh();
    azureDevOpsConnection.refresh();
  }, [azureDevOpsConnection.refresh, githubStatus.refresh, gitlabStatus.refresh]);
  return { eligibility: { providers, loading }, refresh };
}
Hook refresh
const { eligibility, refresh: refreshBuiltIns } = useBuiltInRepositoryAccess(workspaceId);
const builtInSource = useBuiltInRepositorySource(workspaceId, refreshVersion, eligibility);
const refresh = useCallback(() => {
  refreshBuiltIns();
  setRefreshVersion((version) => version + 1);
}, [refreshBuiltIns]);
describe("useRemoteRepositories provider refreshes")
Click for details →

Tests prove refresh keeps eligible providers, clears errors while loading, and ignores stale workspace results.

Keep providers while loading
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"]);

  act(() => resolveRefresh?.([]));
  await waitFor(() => expect(result.current.loading).toBe(false));
});
Stale workspace guard
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: "workspace-2" });
  await waitFor(() => expect(mocks.fetchAccessibleRepos).toHaveBeenCalledTimes(2));

  await act(async () => {
    resolveFirst?.([{ owner: "acme", name: "stale", full_name: "acme/stale", default_branch: "main", private: false }]);
  });
  expect(result.current.repos).toEqual([]);

  await act(async () => {
    resolveSecond?.([{ owner: "acme", name: "current", full_name: "acme/current", default_branch: "main", private: false }]);
  });
  await waitFor(() => expect(result.current.repos.map((r) => r.fullName)).toEqual(["acme/current"]));
});
Read the changes as a list

Retain eligible providers during refresh

apps/web/hooks/domains/integrations/use-remote-repositories.ts

Keeps current providers visible while refresh loads and filters only providers that are no longer eligible.

Diff: keep eligible providers
  workspaceId: string,
  refreshVersion: number,
  eligibility: BuiltInRepositoryEligibility,
): RepositorySourceState {
  const [repos, setRepos] = useState<RemoteRepository[]>([]);
  const [loading, setLoading] = useState(true);
  const [sourceErrors, setSourceErrors] = useState<RemoteRepositorySourceError[]>([]);
  const [availableProviders, setAvailableProviders] = useState<RemoteRepositoryProvider[]>([]);
  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);
Guard stale responses
loadBuiltInRepositories(workspaceId, eligibility.providers)
  .then((result) => {
    if (cancelled) return;
    setRepos(result.repos);
    setAvailableProviders(result.availableProviders);
    setSourceErrors(result.sourceErrors);
  })
  .catch((cause) => {
    if (!cancelled) setSourceErrors([{ provider: "built-in", error: toError(cause) }]);
  })
  .finally(() => {
    if (!cancelled) setLoading(false);
  });
return () => {
  cancelled = true;
};

Refresh re-evaluates eligibility

apps/web/hooks/domains/integrations/use-remote-repositories.ts

Refresh triggers provider status checks and bumps the version so the effect reloads with current eligibility.

Eligibility and refresh
function useBuiltInRepositoryAccess(workspaceId: string): BuiltInRepositoryAccess {
  const githubStatus = useGitHubStatus(workspaceId);
  const gitlabStatus = useGitLabStatus(workspaceId);
  const azureDevOpsConnection = useAzureDevOpsConnection(workspaceId || undefined);
  const providers = useMemo(() => {
    const eligible = new Set<RemoteRepositoryProvider>();
    if (hasProviderConnection(githubStatus.status)) eligible.add("github");
    if (hasProviderConnection(gitlabStatus.status)) eligible.add("gitlab");
    if (azureDevOpsConnection.data?.hasSecret && azureDevOpsConnection.data.lastOk) {
      eligible.add("azure_devops");
    }
    return eligible;
  }, [azureDevOpsConnection.data, githubStatus.status, gitlabStatus.status]);
  const refresh = useCallback(() => {
    void githubStatus.refresh();
    void gitlabStatus.refresh();
    azureDevOpsConnection.refresh();
  }, [azureDevOpsConnection.refresh, githubStatus.refresh, gitlabStatus.refresh]);
  return { eligibility: { providers, loading }, refresh };
}
Hook refresh
const { eligibility, refresh: refreshBuiltIns } = useBuiltInRepositoryAccess(workspaceId);
const builtInSource = useBuiltInRepositorySource(workspaceId, refreshVersion, eligibility);
const refresh = useCallback(() => {
  refreshBuiltIns();
  setRefreshVersion((version) => version + 1);
}, [refreshBuiltIns]);

Tests for refresh and stale guard

apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx

Tests prove refresh keeps eligible providers, clears errors while loading, and ignores stale workspace results.

Keep providers while loading
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"]);

  act(() => resolveRefresh?.([]));
  await waitFor(() => expect(result.current.loading).toBe(false));
});
Stale workspace guard
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: "workspace-2" });
  await waitFor(() => expect(mocks.fetchAccessibleRepos).toHaveBeenCalledTimes(2));

  await act(async () => {
    resolveFirst?.([{ owner: "acme", name: "stale", full_name: "acme/stale", default_branch: "main", private: false }]);
  });
  expect(result.current.repos).toEqual([]);

  await act(async () => {
    resolveSecond?.([{ owner: "acme", name: "current", full_name: "acme/current", default_branch: "main", private: false }]);
  });
  await waitFor(() => expect(result.current.repos.map((r) => r.fullName)).toEqual(["acme/current"]));
});

Risk

2 / 10 Low
1 low5 medium10 high

Why this score

  • Frontend hook only, no backend or storage change and easy to revert.
  • Small diff with clear filter logic and existing cancelled guard.
  • Four new tests cover refresh, loading, error clearing, and stale results.

Trade-offs and review notes

Where to look first

  1. Check the sameWorkspace filter in useBuiltInRepositorySource and the workspaceRef update order.
  2. Confirm refresh calls refreshBuiltIns before bumping refreshVersion so eligibility is current.
  3. Verify the stale guard with cancelled flag and the new tests for loading and error clearing.