Studio: reconcile external providers across browsers after delete (#5698)

Deleting a connection in one browser left the same connection stuck in
every other browser/tab. The user could not delete or edit it from there
because the local state never caught up with the server, and clicks
either no-op'd or threw on a missing-row backend response.

Two pieces caused the bug:

1. `ChatProvidersSettings` ran its backend sync once on mount and then
   silently kept localStorage providers whenever `listProviderConfigs`
   returned an empty array, on the assumption that an empty server
   response had to be a transient glitch. That assumption is wrong when
   another browser removed the last connection. With the guard gone,
   trust any successful API response, including an empty list. A focus /
   visibilitychange listener now triggers a silent re-sync so the dialog
   does not need to be closed and reopened to pick up remote deletes.

2. `deleteProviderConfig` threw on HTTP 404, so once Browser A deleted a
   connection, Browser B's "Delete" click failed and the local row stuck
   around. Treat 404 as success: the server's job is already done and
   the local cache only needs to be pruned.
This commit is contained in:
Daniel Han 2026-05-22 06:42:39 -07:00 committed by GitHub
commit a226b7e7e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 44 additions and 11 deletions

View file

@ -140,6 +140,13 @@ export async function deleteProviderConfig(providerId: string): Promise<void> {
const response = await authFetch(`/api/providers/${providerId}`, {
method: "DELETE",
});
// Treat 404 as success: another browser (or tab) already deleted this
// provider on the backend, so locally pruning the stale cache is the
// correct follow-up. Without this, the caller would throw and the user
// would be stuck with an entry they cannot remove from the UI.
if (response.status === 404) {
return;
}
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(parseErrorText(response.status, body));

View file

@ -345,15 +345,21 @@ export function ChatProvidersSettings({
useEffect(() => {
let isMounted = true;
const syncFromBackend = async () => {
setRegistryLoading(true);
setSyncingProviders(true);
const syncFromBackend = async ({
showSpinner = true,
}: { showSpinner?: boolean } = {}) => {
if (showSpinner) {
setRegistryLoading(true);
setSyncingProviders(true);
}
let syncSucceeded = false;
try {
const [registryRows, configRows] = await Promise.all([
listProviderRegistry(),
listProviderConfigs(),
]);
if (!isMounted) return;
syncSucceeded = true;
setRegistry(registryRows);
setProviderType((current) => {
if (
@ -406,25 +412,45 @@ export function ChatProvidersSettings({
updatedAt,
};
});
// Don't wipe localStorage providers when the server has no rows.
if (syncedProviders.length === 0 && providersRef.current.length > 0) {
return;
}
// Trust the backend response when it succeeds. An empty array means
// every connection was removed (often from another browser/tab) and
// the local cache should mirror that, otherwise the stale entries
// become un-removable in this browser until localStorage is cleared.
onProvidersChange(syncedProviders);
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
toast.error(`Failed to load connections: ${message}`);
// Only surface a toast for real failures, not for the silent
// background re-sync on tab focus.
if (showSpinner) {
const message =
error instanceof Error ? error.message : "Unknown error";
toast.error(`Failed to load connections: ${message}`);
}
} finally {
if (isMounted) {
if (isMounted && showSpinner) {
setRegistryLoading(false);
setSyncingProviders(false);
}
}
return syncSucceeded;
};
void syncFromBackend();
// Re-sync silently when the tab regains focus so deletes made in
// another browser propagate without forcing the user to reopen the
// dialog. Skip when the document is hidden to avoid background work.
const handleVisibilityChange = () => {
if (typeof document === "undefined" || document.hidden) return;
void syncFromBackend({ showSpinner: false });
};
if (typeof window !== "undefined") {
window.addEventListener("focus", handleVisibilityChange);
document.addEventListener("visibilitychange", handleVisibilityChange);
}
return () => {
isMounted = false;
if (typeof window !== "undefined") {
window.removeEventListener("focus", handleVisibilityChange);
document.removeEventListener("visibilitychange", handleVisibilityChange);
}
};
}, [onProvidersChange]);