stop handing the client a layout it cannot use
The layout columns defaulted to '[]' — an empty array for a column whose only legal contents are a LayoutNode object — and every upsert that omitted a layout wrote it. Creating a dashboard from the dashboard list is exactly that path, so the key came back present, the client's `key in state` check preferred it over the caller's default, and normalizeLayout called .children.map on it and threw. Three layers, because none of them was enforcing anything: - the columns are nullable with no default: NULL means "none stored", which is the truth - getAllDashboardState omits the key when what is stored is not an object, so rows written before this are repaired by the next write rather than crashing the read - useDashboardState checks kind-compatibility before casting jsonb to T, and falls back to the caller's default when it does not match. Only object-shaped defaults are guarded — a wrong primitive is a cosmetic surprise, a wrong container is a crash. Verified against the live DB: creating a dashboard with no layout no longer emits a ws-layout key, and a row hand-set back to '[]' is omitted too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,12 +36,12 @@ export function useDashboardState<T>(key: string, defaultValue: T) {
|
||||
}
|
||||
}, [isSuccess, key, defaultValue, queryClient]);
|
||||
|
||||
const value = key in state ? (state[key] as T) : defaultValue;
|
||||
const value = readValue(state, key, defaultValue);
|
||||
|
||||
const setValue = useCallback(
|
||||
(update: T | ((prev: T) => T)) => {
|
||||
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
|
||||
const currentValue = key in currentState ? (currentState[key] as T) : defaultValue;
|
||||
const currentValue = readValue(currentState, key, defaultValue);
|
||||
const newValue = typeof update === 'function' ? (update as (prev: T) => T)(currentValue) : update;
|
||||
|
||||
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
|
||||
@@ -58,6 +58,38 @@ export function useDashboardState<T>(key: string, defaultValue: T) {
|
||||
return { key, value, setValue, isLoaded: isSuccess };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a stored value, or the caller's default when what is stored cannot be that value.
|
||||
*
|
||||
* Everything here arrives from jsonb as `unknown` and used to be cast straight to `T`. Nothing validates
|
||||
* it on the way in either, so one wrong-shaped row — a layout column that defaulted to `[]`, a
|
||||
* `panelId → sessionId` map that arrived as an array — became a `.children.map is not a function` during
|
||||
* render, which is a white screen with no recovery short of SQL.
|
||||
*
|
||||
* The check is kind-compatibility, not a schema: only object-shaped defaults are guarded, because those
|
||||
* are the values whose consumers traverse or spread them. A wrong *primitive* is a cosmetic surprise; a
|
||||
* wrong container is a crash. Deliberately does NOT write the correction back — a read should not
|
||||
* overwrite the server, and the next real `setValue` repairs the row anyway.
|
||||
*/
|
||||
function readValue<T>(state: UserState, key: string, defaultValue: T): T {
|
||||
if (!(key in state)) return defaultValue;
|
||||
|
||||
const stored = state[key];
|
||||
const wantsContainer = typeof defaultValue === 'object' && defaultValue !== null;
|
||||
if (!wantsContainer) return stored as T;
|
||||
|
||||
const isContainer = typeof stored === 'object' && stored !== null;
|
||||
if (isContainer && Array.isArray(stored) === Array.isArray(defaultValue)) return stored as T;
|
||||
|
||||
if (!warned.has(key)) {
|
||||
warned.add(key);
|
||||
console.warn(`[dashboard-state] ignoring stored "${key}" — wrong shape, using the default instead`, stored);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const warned = new Set<string>();
|
||||
|
||||
/**
|
||||
* Undo an optimistic write the server refused, and say so.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user