fixed a race condition in state updates during diagram loading, added loading protection to stop concurrent loads, added icon saving to the backend which *could* have been causing issues with loads potentially, now icons are saved. Added in timeouts for fetch requests too that weren't present

This commit is contained in:
stan
2025-10-03 17:07:37 +01:00
parent d5db93ca16
commit 4e13033609
4 changed files with 185 additions and 52 deletions

View File

@@ -356,27 +356,51 @@ function App() {
const handleDiagramManagerLoad = (id: string, data: any) => {
// Load diagram from server storage
// Server storage contains ALL icons (including imported), so use them directly
console.log(`App: handleDiagramManagerLoad called for diagram ${id}`);
/**
* Icon Persistence Strategy:
*
* NEW BEHAVIOR (after this fix):
* - Server storage saves ALL icons (default collections + imported custom icons)
* - When loading, if we detect default collection icons, use ALL icons from server
* - This preserves imported custom icons without data loss
*
* BACKWARD COMPATIBILITY (for old saves):
* - Old format only saved imported icons (collection='imported')
* - If no default icons detected, merge imported icons with current defaults
* - This ensures old diagrams still load correctly
*
* DETECTION:
* - Check if loaded icons contain any default collection (isoflow, aws, gcp, etc.)
* - If yes: New format, use all icons from server
* - If no: Old format, merge imported with defaults
*/
const loadedIcons = data.icons || [];
// Check if we have all default icons in the loaded data
const hasAllDefaults = icons.every(defaultIcon =>
loadedIcons.some((loadedIcon: any) => loadedIcon.id === defaultIcon.id)
);
// If the saved data has all icons, use it as-is
// Otherwise, merge imported icons with defaults (for backward compatibility)
console.log(`App: Server sent ${loadedIcons.length} icons`);
// Strategy: Check if server has ALL icons (both default and imported)
// Server storage now saves ALL icons, so we should use them directly
// For backward compatibility with old saves, we detect and merge
let finalIcons;
if (hasAllDefaults) {
// Server saved all icons, use them directly
const hasDefaultIcons = loadedIcons.some((icon: any) =>
icon.collection === 'isoflow' || icon.collection === 'aws' || icon.collection === 'gcp'
);
if (hasDefaultIcons) {
// New format: Server saved ALL icons (default + imported)
// Use them directly to preserve any custom icon modifications
console.log(`App: Using all ${loadedIcons.length} icons from server (includes defaults + imported)`);
finalIcons = loadedIcons;
} else {
// Old format or session storage - merge imported with defaults
// Old format: Server only saved imported icons
// Merge imported icons with current default icons
const importedIcons = loadedIcons.filter((icon: any) => icon.collection === 'imported');
finalIcons = [...icons, ...importedIcons];
console.log(`App: Old format detected. Merged ${importedIcons.length} imported icons with ${icons.length} defaults = ${finalIcons.length} total`);
}
const mergedData: DiagramData = {
...data,
title: data.title || data.name || 'Loaded Diagram',
@@ -384,19 +408,34 @@ function App() {
colors: data.colors?.length ? data.colors : defaultColors,
fitToScreen: data.fitToScreen !== false
};
setDiagramData(mergedData);
setDiagramName(data.name || 'Loaded Diagram');
setCurrentModel(mergedData);
setCurrentDiagram({
const newDiagram = {
id,
name: data.name || 'Loaded Diagram',
data: mergedData,
createdAt: data.created || new Date().toISOString(),
updatedAt: data.lastModified || new Date().toISOString()
});
setFossflowKey(prev => prev + 1); // Force re-render
};
console.log(`App: Setting all state for diagram ${id}`);
// Use a single batch of state updates to minimize re-render issues
// Update diagram data and increment key in the same render cycle
setDiagramName(newDiagram.name);
setCurrentDiagram(newDiagram);
setCurrentModel(mergedData);
setHasUnsavedChanges(false);
// Update diagramData and key together
// This ensures Isoflow gets the correct data with the new key
setDiagramData(mergedData);
setFossflowKey(prev => {
const newKey = prev + 1;
console.log(`App: Updated fossflowKey from ${prev} to ${newKey}`);
return newKey;
});
console.log(`App: Finished loading diagram ${id}, final icon count: ${finalIcons.length}`);
};
// i18n

View File

@@ -55,12 +55,25 @@ export const DiagramManager: React.FC<Props> = ({
const handleLoad = async (id: string) => {
try {
setLoading(true);
setError(null);
console.log(`DiagramManager: Loading diagram ${id}...`);
const storage = storageManager.getStorage();
const data = await storage.loadDiagram(id);
console.log(`DiagramManager: Successfully loaded diagram ${id}`);
onLoadDiagram(id, data);
// Small delay to ensure parent component finishes state updates
await new Promise(resolve => setTimeout(resolve, 100));
onClose();
} catch (err) {
console.error(`DiagramManager: Failed to load diagram ${id}:`, err);
setError(err instanceof Error ? err.message : 'Failed to load diagram');
} finally {
setLoading(false);
}
};
@@ -86,12 +99,12 @@ export const DiagramManager: React.FC<Props> = ({
try {
const storage = storageManager.getStorage();
// Check if a diagram with this name already exists (excluding current diagram)
const existingDiagram = diagrams.find(d =>
const existingDiagram = diagrams.find(d =>
d.name === saveName.trim() && d.id !== currentDiagramId
);
if (existingDiagram) {
const confirmOverwrite = window.confirm(
`A diagram named "${saveName}" already exists. This will overwrite it. Are you sure you want to continue?`
@@ -99,16 +112,30 @@ export const DiagramManager: React.FC<Props> = ({
if (!confirmOverwrite) {
return;
}
// Delete the existing diagram first
await storage.deleteDiagram(existingDiagram.id);
}
/**
* Icon Persistence: Save ALL icons (default + imported)
*
* currentDiagramData comes from parent's currentModel/diagramData which includes:
* - All default icon collections (isoflow, aws, gcp, azure, kubernetes)
* - All imported custom icons (collection='imported')
*
* This ensures when loading, we have the complete icon set and don't lose
* any custom imported icons.
*/
const dataToSave = {
...currentDiagramData,
name: saveName
};
console.log(`DiagramManager: Saving diagram with ${dataToSave.icons?.length || 0} icons`);
const importedCount = (dataToSave.icons || []).filter((icon: any) => icon.collection === 'imported').length;
console.log(`DiagramManager: Including ${importedCount} imported icons`);
if (currentDiagramId) {
// Update existing
await storage.saveDiagram(currentDiagramId, dataToSave);
@@ -182,15 +209,17 @@ export const DiagramManager: React.FC<Props> = ({
</span>
</div>
<div className="diagram-actions">
<button
<button
className="action-button"
onClick={() => handleLoad(diagram.id)}
disabled={loading}
>
Load
{loading ? 'Loading...' : 'Load'}
</button>
<button
<button
className="action-button danger"
onClick={() => handleDelete(diagram.id)}
disabled={loading}
>
Delete
</button>

View File

@@ -20,6 +20,8 @@ export interface StorageService {
class ServerStorage implements StorageService {
private baseUrl: string;
private available: boolean | null = null;
private availabilityCheckedAt: number | null = null;
private readonly AVAILABILITY_CACHE_MS = 60000; // Re-check every 60 seconds
constructor(baseUrl: string = '') {
// In production (Docker), use relative paths (nginx proxy)
@@ -29,16 +31,29 @@ class ServerStorage implements StorageService {
}
async isAvailable(): Promise<boolean> {
if (this.available !== null) return this.available;
// Re-check availability if cache is stale
const now = Date.now();
if (this.available !== null &&
this.availabilityCheckedAt !== null &&
(now - this.availabilityCheckedAt) < this.AVAILABILITY_CACHE_MS) {
return this.available;
}
try {
const response = await fetch(`${this.baseUrl}/api/storage/status`);
const response = await fetch(`${this.baseUrl}/api/storage/status`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(5000) // 5 second timeout
});
const data = await response.json();
this.available = data.enabled;
return this.available;
this.availabilityCheckedAt = Date.now();
console.log(`Server storage availability: ${this.available}`);
return this.available ?? false;
} catch (error) {
console.log('Server storage not available:', error);
this.available = false;
this.availabilityCheckedAt = Date.now();
return false;
}
}
@@ -64,18 +79,50 @@ class ServerStorage implements StorageService {
}
async loadDiagram(id: string): Promise<Model> {
const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`);
if (!response.ok) throw new Error('Failed to load diagram');
return response.json();
console.log(`ServerStorage: Loading diagram ${id} from ${this.baseUrl}/api/diagrams/${id}`);
try {
const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (!response.ok) {
const errorText = await response.text();
console.error(`ServerStorage: Failed to load diagram ${id}: ${response.status} ${errorText}`);
throw new Error(`Failed to load diagram: ${response.status} ${errorText}`);
}
const data = await response.json();
console.log(`ServerStorage: Successfully loaded diagram ${id}, items: ${data.items?.length || 0}`);
return data;
} catch (error) {
console.error(`ServerStorage: Error loading diagram ${id}:`, error);
throw error;
}
}
async saveDiagram(id: string, data: Model): Promise<void> {
const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Failed to save diagram');
console.log(`ServerStorage: Saving diagram ${id}`);
try {
const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
signal: AbortSignal.timeout(15000) // 15 second timeout for saves
});
if (!response.ok) {
const errorText = await response.text();
console.error(`ServerStorage: Failed to save diagram ${id}: ${response.status} ${errorText}`);
throw new Error(`Failed to save diagram: ${response.status}`);
}
console.log(`ServerStorage: Successfully saved diagram ${id}`);
} catch (error) {
console.error(`ServerStorage: Error saving diagram ${id}:`, error);
throw error;
}
}
async deleteDiagram(id: string): Promise<void> {

View File

@@ -110,15 +110,24 @@ if (STORAGE_ENABLED) {
// Get specific diagram
app.get('/api/diagrams/:id', async (req, res) => {
const diagramId = req.params.id;
console.log(`[GET /api/diagrams/${diagramId}] Loading diagram...`);
try {
const filePath = path.join(STORAGE_PATH, `${req.params.id}.json`);
const filePath = path.join(STORAGE_PATH, `${diagramId}.json`);
console.log(`[GET /api/diagrams/${diagramId}] Reading from: ${filePath}`);
const content = await fs.readFile(filePath, 'utf-8');
res.json(JSON.parse(content));
const data = JSON.parse(content);
console.log(`[GET /api/diagrams/${diagramId}] Successfully loaded, size: ${content.length} bytes, items: ${data.items?.length || 0}`);
res.json(data);
} catch (error) {
if (error.code === 'ENOENT') {
console.error(`[GET /api/diagrams/${diagramId}] Diagram not found`);
res.status(404).json({ error: 'Diagram not found' });
} else {
console.error('Error reading diagram:', error);
console.error(`[GET /api/diagrams/${diagramId}] Error reading diagram:`, error);
res.status(500).json({ error: 'Failed to read diagram' });
}
}
@@ -126,25 +135,34 @@ if (STORAGE_ENABLED) {
// Save or update diagram
app.put('/api/diagrams/:id', async (req, res) => {
const diagramId = req.params.id;
console.log(`[PUT /api/diagrams/${diagramId}] Saving diagram...`);
try {
const filePath = path.join(STORAGE_PATH, `${req.params.id}.json`);
const filePath = path.join(STORAGE_PATH, `${diagramId}.json`);
const data = {
...req.body,
id: req.params.id,
id: diagramId,
lastModified: new Date().toISOString()
};
const iconCount = data.icons?.length || 0;
const importedIconCount = (data.icons || []).filter(icon => icon.collection === 'imported').length;
console.log(`[PUT /api/diagrams/${diagramId}] Writing to: ${filePath}`);
console.log(`[PUT /api/diagrams/${diagramId}] Items: ${data.items?.length || 0}, Icons: ${iconCount} (${importedIconCount} imported)`);
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
console.log(`[PUT /api/diagrams/${diagramId}] Successfully saved`);
// Git backup if enabled
if (ENABLE_GIT_BACKUP) {
// TODO: Implement git commit
console.log('Git backup not yet implemented');
console.log('[PUT] Git backup not yet implemented');
}
res.json({ success: true, id: req.params.id });
res.json({ success: true, id: diagramId });
} catch (error) {
console.error('Error saving diagram:', error);
console.error(`[PUT /api/diagrams/${diagramId}] Error saving diagram:`, error);
res.status(500).json({ error: 'Failed to save diagram' });
}
});