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:
2026-08-07 09:08:15 +00:00
co-authored by Claude Opus 5
parent 64961d49f5
commit ef036dfcd5
3 changed files with 57 additions and 8 deletions
+34 -2
View File
@@ -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.
*