Fix service map nodes disappearing on page reload

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 00:05:01 -05:00
parent 95d26ec805
commit 7c04c4633f
2 changed files with 56 additions and 16 deletions

View File

@@ -148,48 +148,58 @@ function ServiceMapperInner() {
const [ctxMenu, setCtxMenu] = useState<CtxMenu>(null); const [ctxMenu, setCtxMenu] = useState<CtxMenu>(null);
const [nodeEditState, setNodeEditState] = useState<NodeEditState>(null); const [nodeEditState, setNodeEditState] = useState<NodeEditState>(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 // Load maps list on mount
useEffect(() => { useEffect(() => {
fetchMaps().catch(() => toast.error('Failed to load maps')); fetchMaps().catch(() => toast.error('Failed to load maps'));
}, [fetchMaps]); }, [fetchMaps]);
// When active map changes, update flow state // When active map changes, update flow state and block position saves briefly
useEffect(() => { useEffect(() => {
if (activeMap) { if (activeMap) {
blockSaveRef.current = true;
setNodes(toFlowNodes(activeMap)); setNodes(toFlowNodes(activeMap));
setEdges(toFlowEdges(activeMap)); setEdges(toFlowEdges(activeMap));
// Unblock after React Flow has settled its initial layout
const t = setTimeout(() => { blockSaveRef.current = false; }, 800);
return () => clearTimeout(t);
} else { } else {
setNodes([]); setNodes([]);
setEdges([]); setEdges([]);
} }
}, [activeMap, setNodes, setEdges]); }, [activeMap, setNodes, setEdges]);
// Debounced node position save (500ms after drag end) // Debounced node position save (500ms after drag ends)
const handleNodesChange = useCallback( const handleNodesChange = useCallback(
(changes: NodeChange<Node>[]) => { (changes: NodeChange<Node>[]) => {
onNodesChange(changes); onNodesChange(changes);
const positionChanges = changes.filter( // Don't persist positions during initial load / fitView settle period
(c): c is NodeChange<Node> & { type: 'position'; dragging: false } => if (blockSaveRef.current) return;
c.type === 'position' && !(c as { dragging?: boolean }).dragging
); 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 (positionChanges.length === 0) return;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current); if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => { saveTimerRef.current = setTimeout(async () => {
for (const change of positionChanges) { for (const change of positionChanges) {
const nodeId = (change as { id: string }).id; const pc = change as { id: string; position: { x: number; y: number } };
const position = (change as { position?: { x: number; y: number } }).position;
if (!position) continue;
try { try {
await apiClient.nodes.update(nodeId, { await apiClient.nodes.update(pc.id, {
positionX: position.x, positionX: pc.position.x,
positionY: position.y, positionY: pc.position.y,
}); });
} catch { } catch {
// Silent — position drift on failure is acceptable // Silent — minor position drift on failure is acceptable
} }
} }
}, 500); }, 500);

View File

@@ -2,6 +2,17 @@ import { create } from 'zustand';
import type { ServiceMap, ServiceMapSummary } from '../types'; import type { ServiceMap, ServiceMapSummary } from '../types';
import { apiClient } from '../api/client'; 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 { interface MapState {
maps: ServiceMapSummary[]; maps: ServiceMapSummary[];
activeMap: ServiceMap | null; activeMap: ServiceMap | null;
@@ -13,7 +24,7 @@ interface MapState {
setActiveMap: (map: ServiceMap | null) => void; setActiveMap: (map: ServiceMap | null) => void;
} }
export const useMapStore = create<MapState>((set) => ({ export const useMapStore = create<MapState>((set, get) => ({
maps: [], maps: [],
activeMap: null, activeMap: null,
loading: false, loading: false,
@@ -23,6 +34,15 @@ export const useMapStore = create<MapState>((set) => ({
try { try {
const maps = await apiClient.maps.list(); const maps = await apiClient.maps.list();
set({ maps, loading: false }); 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 { } catch {
set({ loading: false }); set({ loading: false });
throw new Error('Failed to load maps'); throw new Error('Failed to load maps');
@@ -33,6 +53,7 @@ export const useMapStore = create<MapState>((set) => ({
set({ loading: true }); set({ loading: true });
try { try {
const map = await apiClient.maps.get(id); const map = await apiClient.maps.get(id);
saveLastMapId(id);
set({ activeMap: map, loading: false }); set({ activeMap: map, loading: false });
} catch { } catch {
set({ loading: false }); set({ loading: false });
@@ -42,17 +63,26 @@ export const useMapStore = create<MapState>((set) => ({
createMap: async (name, description) => { createMap: async (name, description) => {
const map = await apiClient.maps.create({ 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; return map;
}, },
deleteMap: async (id) => { deleteMap: async (id) => {
await apiClient.maps.delete(id); await apiClient.maps.delete(id);
if (getLastMapId() === id) saveLastMapId(null);
set((s) => ({ set((s) => ({
maps: s.maps.filter((m) => m.id !== id), maps: s.maps.filter((m) => m.id !== id),
activeMap: s.activeMap?.id === id ? null : s.activeMap, activeMap: s.activeMap?.id === id ? null : s.activeMap,
})); }));
}, },
setActiveMap: (map) => set({ activeMap: map }), setActiveMap: (map) => {
saveLastMapId(map?.id ?? null);
set({ activeMap: map });
},
})); }));