stop swallowing dashboard persist failures

The optimistic cache made a refused write invisible: the UI stayed correct until the next reload, at
which point the change was simply gone. That is tolerable for a pane size and not for a chat panel's
agent name, which is the address a peer agent is delivered to.

Roll back only if the cache still holds exactly what we wrote — writes to one key overlap freely (a
window resize fires one per group) and rolling back over a later successful write would turn one
failure into two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 08:48:44 +00:00
co-authored by Claude Opus 5
parent 85452d1afa
commit 70c2f0811d
+42 -2
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import type { UserState } from './useSettings';
@@ -29,7 +30,9 @@ export function useDashboardState<T>(key: string, defaultValue: T) {
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
if (!(key in currentState)) {
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: defaultValue });
clientRef.current.patch('/dashboards', { [key]: defaultValue }).catch(() => { });
clientRef.current
.patch('/dashboards', { [key]: defaultValue })
.catch((err: unknown) => revert(queryClient, key, defaultValue, undefined, err));
}
}, [isSuccess, key, defaultValue, queryClient]);
@@ -43,10 +46,47 @@ export function useDashboardState<T>(key: string, defaultValue: T) {
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
clientRef.current.patch('/dashboards', { [key]: newValue }).catch(() => { });
clientRef.current
.patch('/dashboards', { [key]: newValue })
.catch((err: unknown) =>
revert(queryClient, key, newValue, key in currentState ? currentValue : undefined, err),
);
},
[key, defaultValue, queryClient],
);
return { key, value, setValue, isLoaded: isSuccess };
}
/**
* Undo an optimistic write the server refused, and say so.
*
* The optimistic cache is what made these failures invisible: the UI stayed correct until the next
* reload, at which point the change was simply gone. That is worst for the things this store holds that
* are not merely cosmetic — a chat panel's `config.agentName` is the address a peer agent is delivered
* to, so a silently-dropped write leaves a panel that answers to a name it will not have tomorrow.
*
* Reverts only if the cache still holds exactly what we wrote. Writes to one key overlap freely (a
* window resize fires one per group), and rolling back over a later successful write would turn one
* failure into two.
*/
function revert(
queryClient: ReturnType<typeof useQueryClient>,
key: string,
attempted: unknown,
previous: unknown,
err: unknown,
): void {
const state = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
if (state[key] === attempted) {
const next = { ...state };
if (previous === undefined) delete next[key];
else next[key] = previous;
queryClient.setQueryData(QUERY_KEY, next);
}
console.error(`[dashboard-state] failed to persist "${key}"`, err);
toast.error('Could not save your layout', {
description: `"${key}" was not written and has been rolled back. ${err instanceof Error ? err.message : String(err)}`,
});
}