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:
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user