learning_ai_common_plat/packages/customizable-workspace/src/useWorkspaceLayout.ts
saravanakumardb1 8562711f49 fix(workspace+theme): sanitise corrupt spans, short-circuit re-reconcile, drop unreachable regex
──────────────────────────────────────────────────────────────────
customizable-workspace
──────────────────────────────────────────────────────────────────

Two issues caught in the audit pass:

1. **Corrupt persisted spans broke the grid layout.**
   localStorage entries from older versions (or hand-edited debug
   sessions) could contain span=NaN / 0 / -3 / 99. These flowed
   straight into `grid-column: span <bad>` which silently broke
   the whole row. The visual symptom was a tile rendering at zero
   width or pushing every sibling off-screen.
   Fix: `reconcile()` now clamps every span (including newly
   appended tiles' defaultSpan) to the legal `[1, 4]` range via
   `sanitiseSpan()`.

2. **Re-reconcile effect could loop when callers forget to memoise.**
   The `useEffect([tiles, hydrated])` always called `setLayout`
   with a fresh `{ entries: [...] }` object reference, even when
   the content was identical. If `tiles` itself was a fresh
   reference per parent render (e.g. `tiles=[{...}]` inline),
   every render \u2192 setLayout \u2192 save effect \u2192 (no loop because
   tiles ref same), but constant unnecessary writes to
   localStorage.
   Fix: added `sameLayout(a, b)` structural-equality check;
   setLayout now short-circuits to the previous state when the
   reconciled output is identical.

Tests: 10 \u2192 11
  reconcile  \u00b7 sanitises corrupt spans (NaN/0/negative/>4) \u2192 clamp

──────────────────────────────────────────────────────────────────
generative-theme
──────────────────────────────────────────────────────────────────

Cosmetic but worth fixing: the `rose` palette regex included
`warm` as a keyword, but the `citrus` palette \u2014 listed earlier in
the PALETTES table \u2014 also matched `warm`. Since first-match wins,
`warm` was unreachable in rose and the entry was misleading.

Dropped `warm` from the rose regex. Citrus retains it (was always
where it routed in practice). All 18 existing tests still pass.
2026-05-27 18:46:19 -07:00

161 lines
5.2 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
LayoutEntry,
LayoutPersistence,
LayoutSpec,
TileSpan,
TileSpec,
} from './types.js';
import { localStoragePersistence } from './persistence.js';
export interface UseWorkspaceLayoutOptions {
/** Persistence key (route-scoped). Should be product + view unique. */
storageKey: string;
/** Override the persistence layer — default is `localStorage`. */
persistence?: LayoutPersistence;
}
export interface UseWorkspaceLayoutHelpers {
layout: LayoutSpec;
/** Move a tile from `fromIndex` → `toIndex` (clamped). */
move: (fromIndex: number, toIndex: number) => void;
/** Set a tile's span. */
resize: (id: string, span: TileSpan) => void;
/** Reset to the default layout (one entry per tile, defaultSpan or 2). */
reset: () => void;
/** True once the persistence load has resolved. */
hydrated: boolean;
}
/**
* `useWorkspaceLayout` — derives the live layout from a tile registry +
* persistence layer. Order + spans are persisted on every change.
*
* The hook is `tiles`-driven: pass the canonical tile registry and the
* hook returns the current ordered+sized layout. Tiles absent from
* persisted layout are appended in registry order; persisted entries
* referencing removed tiles are dropped (defensive against schema
* drift between releases).
*/
export function useWorkspaceLayout(
tiles: TileSpec[],
options: UseWorkspaceLayoutOptions,
): UseWorkspaceLayoutHelpers {
const persistence = options.persistence ?? localStoragePersistence;
const persistenceRef = useRef(persistence);
persistenceRef.current = persistence;
const defaults: LayoutSpec = useMemo(
() => ({
entries: tiles.map((t) => ({ id: t.id, span: t.defaultSpan ?? 2 })),
}),
[tiles],
);
const [layout, setLayout] = useState<LayoutSpec>(defaults);
const [hydrated, setHydrated] = useState(false);
// One-shot load on mount.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const loaded = await persistenceRef.current.load(options.storageKey);
if (cancelled) return;
if (loaded) {
setLayout(reconcile(loaded, tiles));
}
} finally {
if (!cancelled) setHydrated(true);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options.storageKey]);
// Re-reconcile whenever the tile registry changes (add/remove/reorder).
// Short-circuit identical layouts to avoid setLayout → save effect →
// re-render loop when callers forget to memoise `tiles`.
useEffect(() => {
if (!hydrated) return;
setLayout((cur) => {
const next = reconcile(cur, tiles);
return sameLayout(cur, next) ? cur : next;
});
}, [tiles, hydrated]);
// Persist on every change (after hydration so initial load doesn't write back).
useEffect(() => {
if (!hydrated) return;
void persistenceRef.current.save(options.storageKey, layout);
}, [layout, options.storageKey, hydrated]);
const move = useCallback((from: number, to: number) => {
setLayout((cur) => {
const len = cur.entries.length;
if (from === to || from < 0 || from >= len) return cur;
const clampedTo = Math.max(0, Math.min(len - 1, to));
const next = cur.entries.slice();
const [moved] = next.splice(from, 1);
if (!moved) return cur;
next.splice(clampedTo, 0, moved);
return { entries: next };
});
}, []);
const resize = useCallback((id: string, span: TileSpan) => {
setLayout((cur) => ({
entries: cur.entries.map((e) => (e.id === id ? { ...e, span } : e)),
}));
}, []);
const reset = useCallback(() => {
setLayout(defaults);
}, [defaults]);
return { layout, move, resize, reset, hydrated };
}
/**
* Drop entries that no longer exist; append new tiles in registry order
* with their default span. Pure function (used by tests).
*
* Also sanitises spans — a corrupt persisted entry with span=NaN /
* 0 / negative / 100 used to render "grid-column: span NaN" which
* silently broke the layout; we now clamp to `[1, 4]`.
*/
export function reconcile(
layout: LayoutSpec,
tiles: TileSpec[],
): LayoutSpec {
const knownIds = new Set(tiles.map((t) => t.id));
const surviving: LayoutEntry[] = layout.entries
.filter((e) => knownIds.has(e.id))
.map((e) => ({ id: e.id, span: sanitiseSpan(e.span) }));
const seen = new Set(surviving.map((e) => e.id));
for (const t of tiles) {
if (!seen.has(t.id)) {
surviving.push({ id: t.id, span: sanitiseSpan(t.defaultSpan ?? 2) });
}
}
return { entries: surviving };
}
function sanitiseSpan(span: unknown): TileSpan {
const n = typeof span === 'number' && Number.isFinite(span) ? span : 2;
return Math.max(1, Math.min(4, Math.round(n))) as TileSpan;
}
/** Cheap structural equality between two layouts. */
function sameLayout(a: LayoutSpec, b: LayoutSpec): boolean {
if (a.entries.length !== b.entries.length) return false;
for (let i = 0; i < a.entries.length; i++) {
const ae = a.entries[i]!;
const be = b.entries[i]!;
if (ae.id !== be.id || ae.span !== be.span) return false;
}
return true;
}