From 7c04c4633f1b52367086f39db6b02f39d34d5d9b Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 22 Mar 2026 00:05:01 -0500 Subject: [PATCH] Fix service map nodes disappearing on page reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Zustand store resets to activeMap=null on every page load. fetchMaps() only loaded the summary list — nodes were never reloaded because the user had to manually re-select their map each time. Fixes: 1. Persist last active map ID in localStorage (key: rackmapper:lastMapId) - loadMap() saves the ID on successful load - setActiveMap() saves/clears the ID - deleteMap() clears the ID if the deleted map was active 2. Auto-restore on mount inside fetchMaps(): - If the saved map ID is still in the list, auto-load it - If there is exactly one map, auto-load it as a convenience 3. Block spurious position saves during map load (blockSaveRef): - fitView fires position NodeChanges for all nodes after load - Without a guard these would overwrite stored positions with React Flow's fitted coordinates immediately on every reload - blockSaveRef is set true on activeMap change, cleared after 800ms 4. Tighten the position-change filter: - Require dragging === false (strict equality, not just falsy) - Require position != null before saving - Both conditions must be true to queue a save Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/mapper/ServiceMapper.tsx | 36 ++++++++++++------- client/src/store/useMapStore.ts | 36 +++++++++++++++++-- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/client/src/components/mapper/ServiceMapper.tsx b/client/src/components/mapper/ServiceMapper.tsx index edfd3e0..dfd7f31 100644 --- a/client/src/components/mapper/ServiceMapper.tsx +++ b/client/src/components/mapper/ServiceMapper.tsx @@ -148,48 +148,58 @@ function ServiceMapperInner() { const [ctxMenu, setCtxMenu] = useState(null); const [nodeEditState, setNodeEditState] = useState(null); + // Block position saves briefly after map load to prevent fitView from + // firing spurious position changes that overwrite stored positions + const blockSaveRef = useRef(false); // Load maps list on mount useEffect(() => { fetchMaps().catch(() => toast.error('Failed to load maps')); }, [fetchMaps]); - // When active map changes, update flow state + // When active map changes, update flow state and block position saves briefly useEffect(() => { if (activeMap) { + blockSaveRef.current = true; setNodes(toFlowNodes(activeMap)); setEdges(toFlowEdges(activeMap)); + // Unblock after React Flow has settled its initial layout + const t = setTimeout(() => { blockSaveRef.current = false; }, 800); + return () => clearTimeout(t); } else { setNodes([]); setEdges([]); } }, [activeMap, setNodes, setEdges]); - // Debounced node position save (500ms after drag end) + // Debounced node position save (500ms after drag ends) const handleNodesChange = useCallback( (changes: NodeChange[]) => { onNodesChange(changes); - const positionChanges = changes.filter( - (c): c is NodeChange & { type: 'position'; dragging: false } => - c.type === 'position' && !(c as { dragging?: boolean }).dragging - ); + // Don't persist positions during initial load / fitView settle period + if (blockSaveRef.current) return; + + const positionChanges = changes.filter((c) => { + if (c.type !== 'position') return false; + const pc = c as { type: 'position'; id: string; position?: { x: number; y: number }; dragging?: boolean }; + // Only save when drag has fully ended (dragging === false) and position is present + return pc.dragging === false && pc.position != null; + }); if (positionChanges.length === 0) return; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(async () => { for (const change of positionChanges) { - const nodeId = (change as { id: string }).id; - const position = (change as { position?: { x: number; y: number } }).position; - if (!position) continue; + const pc = change as { id: string; position: { x: number; y: number } }; try { - await apiClient.nodes.update(nodeId, { - positionX: position.x, - positionY: position.y, + await apiClient.nodes.update(pc.id, { + positionX: pc.position.x, + positionY: pc.position.y, }); } catch { - // Silent — position drift on failure is acceptable + // Silent — minor position drift on failure is acceptable } } }, 500); diff --git a/client/src/store/useMapStore.ts b/client/src/store/useMapStore.ts index 2a2e499..34da498 100644 --- a/client/src/store/useMapStore.ts +++ b/client/src/store/useMapStore.ts @@ -2,6 +2,17 @@ import { create } from 'zustand'; import type { ServiceMap, ServiceMapSummary } from '../types'; import { apiClient } from '../api/client'; +const LAST_MAP_KEY = 'rackmapper:lastMapId'; + +function saveLastMapId(id: string | null) { + if (id) localStorage.setItem(LAST_MAP_KEY, id); + else localStorage.removeItem(LAST_MAP_KEY); +} + +function getLastMapId(): string | null { + return localStorage.getItem(LAST_MAP_KEY); +} + interface MapState { maps: ServiceMapSummary[]; activeMap: ServiceMap | null; @@ -13,7 +24,7 @@ interface MapState { setActiveMap: (map: ServiceMap | null) => void; } -export const useMapStore = create((set) => ({ +export const useMapStore = create((set, get) => ({ maps: [], activeMap: null, loading: false, @@ -23,6 +34,15 @@ export const useMapStore = create((set) => ({ try { const maps = await apiClient.maps.list(); set({ maps, loading: false }); + + // Auto-restore the last active map after loading the list + const lastId = getLastMapId(); + if (lastId && maps.some((m) => m.id === lastId)) { + await get().loadMap(lastId); + } else if (maps.length === 1) { + // Convenience: auto-load if there's only one map + await get().loadMap(maps[0].id); + } } catch { set({ loading: false }); throw new Error('Failed to load maps'); @@ -33,6 +53,7 @@ export const useMapStore = create((set) => ({ set({ loading: true }); try { const map = await apiClient.maps.get(id); + saveLastMapId(id); set({ activeMap: map, loading: false }); } catch { set({ loading: false }); @@ -42,17 +63,26 @@ export const useMapStore = create((set) => ({ createMap: async (name, description) => { const map = await apiClient.maps.create({ name, description }); - set((s) => ({ maps: [{ id: map.id, name: map.name, description: map.description, createdAt: map.createdAt, updatedAt: map.updatedAt }, ...s.maps] })); + set((s) => ({ + maps: [ + { id: map.id, name: map.name, description: map.description, createdAt: map.createdAt, updatedAt: map.updatedAt }, + ...s.maps, + ], + })); return map; }, deleteMap: async (id) => { await apiClient.maps.delete(id); + if (getLastMapId() === id) saveLastMapId(null); set((s) => ({ maps: s.maps.filter((m) => m.id !== id), activeMap: s.activeMap?.id === id ? null : s.activeMap, })); }, - setActiveMap: (map) => set({ activeMap: map }), + setActiveMap: (map) => { + saveLastMapId(map?.id ?? null); + set({ activeMap: map }); + }, }));