[eric] frontend: reconnect layout refetch merges (keeps live card positions) instead of blind-replacing

This commit is contained in:
ciregenz
2026-06-09 04:52:49 -07:00
parent 09fc33beaf
commit e13f282050
2 changed files with 47 additions and 7 deletions
@@ -81,13 +81,13 @@ export function useDashboardLifecycle({
// CRITICAL path: these populate the cards the user expects to see
// on first paint. Don't defer.
dispatch(fetchSessions({ dashboardId }));
dispatch(fetchLayout(dashboardId));
dispatch(fetchLayout({ dashboardId }));
const cleanupBrowserHandler = initBrowserCommandHandler();
// Global broadcasts (spawned browser cards) skip the replay log, so a
// socket gap loses them; a reconnect refetch is the only way they return.
const unsubReconnect = dashboardWs.on('dashboard:reconnected', () => {
dispatch(fetchSessions({ dashboardId }));
dispatch(fetchLayout(dashboardId));
dispatch(fetchLayout({ dashboardId, isReconnect: true }));
});
// DEFERRABLE: history list (for the search palette) and outputs
// (for the apps panel) aren't on the first-paint path. Same for the
@@ -124,7 +124,11 @@ function generateTabId(): string {
export const fetchLayout = createAsyncThunk(
'dashboardLayout/fetch',
async (dashboardId: string) => {
// isReconnect distinguishes a socket-reconnect recovery refetch (merge, keep
// live positions) from a fresh mount/switch load (replace, snapshot is the
// user's saved layout). Passed explicitly, not inferred from state, so a
// stale in-flight fetch from a previous dashboard can't be misread as a merge.
async ({ dashboardId }: { dashboardId: string; isReconnect?: boolean }) => {
const res = await fetch(`${DASHBOARDS_API}/${dashboardId}`);
const data = await res.json();
const layout = data.layout ?? {};
@@ -296,6 +300,27 @@ export function findOpenSpotNear(
return findOpenGridCell(occupiedRects, newW, newH);
}
// Reconnect-refetch merge: ADD only the cards the snapshot carries that the
// client is missing (e.g. a spawned browser whose broadcast was lost in a
// socket gap), collision-resolving each against the live layout so a recovered
// card can't land on a card already on canvas, and NEVER touch a card the
// client already has (that's exactly what preserves its live, collision-placed
// position). The shared `occupied` list carries placements forward so two
// recovered cards in the same pass also avoid each other.
function addMissingCards<T extends { x: number; y: number; width: number; height: number }>(
live: Record<string, T>,
incoming: Record<string, T>,
occupied: Rect[],
): void {
for (const id of Object.keys(incoming)) {
if (live[id]) continue;
const card = incoming[id];
const pos = findOpenSpotNear(card.x, card.y, occupied, card.width, card.height);
live[id] = { ...card, x: pos.x, y: pos.y };
occupied.push({ x: pos.x, y: pos.y, w: card.width, h: card.height });
}
}
const dashboardLayoutSlice = createSlice({
name: 'dashboardLayout',
initialState,
@@ -924,11 +949,26 @@ const dashboardLayoutSlice = createSlice({
})
.addCase(fetchLayout.fulfilled, (state, action) => {
state.loading = false;
// A fresh mount/switch load replaces (the snapshot is the user's saved
// layout, authoritative). A reconnect refetch (useDashboardLifecycle
// line ~90) recovers cards lost in a socket gap and must MERGE, blind-
// replacing there clobbered the live, collision-placed positions of
// cards already on canvas (the overlap / vanish under load while many
// browsers spawn). The caller says which; never inferred from state.
const isReconnectRefetch = action.meta.arg.isReconnect === true;
state.initialized = true;
state.cards = action.payload.cards;
state.viewCards = action.payload.viewCards;
state.browserCards = action.payload.browserCards;
state.notes = action.payload.notes || {};
if (!isReconnectRefetch) {
state.cards = action.payload.cards;
state.viewCards = action.payload.viewCards;
state.browserCards = action.payload.browserCards;
state.notes = action.payload.notes || {};
} else {
const occupied = collectOccupiedRects(state, action.payload.expandedSessionIds);
addMissingCards(state.cards, action.payload.cards, occupied);
addMissingCards(state.viewCards, action.payload.viewCards, occupied);
addMissingCards(state.browserCards, action.payload.browserCards, occupied);
addMissingCards(state.notes, action.payload.notes || {}, occupied);
}
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
let maxZ = 0;