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:
parent
ebe504b558
commit
a226b7e7e9
2 changed files with 44 additions and 11 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue