mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
219 lines
7.7 KiB
TypeScript
219 lines
7.7 KiB
TypeScript
/**
|
|
* File lifecycle management - Resource cleanup and memory management
|
|
*/
|
|
|
|
import { FileId, FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
|
|
|
|
const DEBUG = process.env.NODE_ENV === 'development';
|
|
|
|
/**
|
|
* Resource tracking and cleanup utilities
|
|
*/
|
|
export class FileLifecycleManager {
|
|
private cleanupTimers = new Map<string, number>();
|
|
private blobUrls = new Set<string>();
|
|
private fileGenerations = new Map<string, number>(); // Generation tokens to prevent stale cleanup
|
|
|
|
constructor(
|
|
private filesRef: React.MutableRefObject<Map<FileId, File>>,
|
|
private dispatch: React.Dispatch<FileContextAction>
|
|
) {}
|
|
|
|
/**
|
|
* Track blob URLs for cleanup
|
|
*/
|
|
trackBlobUrl = (url: string): void => {
|
|
// Only track actual blob URLs to avoid trying to revoke other schemes
|
|
if (url.startsWith('blob:')) {
|
|
this.blobUrls.add(url);
|
|
if (DEBUG) console.log(`🗂️ Tracking blob URL: ${url.substring(0, 50)}...`);
|
|
} else {
|
|
if (DEBUG) console.warn(`🗂️ Attempted to track non-blob URL: ${url.substring(0, 50)}...`);
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* Clean up resources for a specific file (with stateRef access for complete cleanup)
|
|
*/
|
|
cleanupFile = (fileId: string, stateRef?: React.MutableRefObject<any>): void => {
|
|
if (DEBUG) console.log(`🗂️ Cleaning up resources for file: ${fileId}`);
|
|
|
|
// Use comprehensive cleanup (same as removeFiles)
|
|
this.cleanupAllResourcesForFile(fileId, stateRef);
|
|
|
|
// Remove file from state
|
|
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds: [fileId] } });
|
|
};
|
|
|
|
/**
|
|
* Clean up all files and resources
|
|
*/
|
|
cleanupAllFiles = (): void => {
|
|
if (DEBUG) console.log('🗂️ Cleaning up all files and resources');
|
|
|
|
// Revoke all blob URLs
|
|
this.blobUrls.forEach(url => {
|
|
try {
|
|
URL.revokeObjectURL(url);
|
|
if (DEBUG) console.log(`🗂️ Revoked blob URL: ${url.substring(0, 50)}...`);
|
|
} catch (error) {
|
|
if (DEBUG) console.warn('Error revoking blob URL:', error);
|
|
}
|
|
});
|
|
this.blobUrls.clear();
|
|
|
|
// Clear all cleanup timers and generations
|
|
this.cleanupTimers.forEach(timer => clearTimeout(timer));
|
|
this.cleanupTimers.clear();
|
|
this.fileGenerations.clear();
|
|
|
|
// Clear files ref
|
|
this.filesRef.current.clear();
|
|
|
|
if (DEBUG) console.log('🗂️ All resources cleaned up');
|
|
};
|
|
|
|
/**
|
|
* Schedule delayed cleanup for a file with generation token to prevent stale cleanup
|
|
*/
|
|
scheduleCleanup = (fileId: string, delay: number = 30000, stateRef?: React.MutableRefObject<any>): void => {
|
|
// Cancel existing timer
|
|
const existingTimer = this.cleanupTimers.get(fileId);
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer);
|
|
this.cleanupTimers.delete(fileId);
|
|
}
|
|
|
|
// If delay is negative, just cancel (don't reschedule)
|
|
if (delay < 0) {
|
|
return;
|
|
}
|
|
|
|
// Increment generation for this file to invalidate any pending cleanup
|
|
const currentGen = (this.fileGenerations.get(fileId) || 0) + 1;
|
|
this.fileGenerations.set(fileId, currentGen);
|
|
|
|
// Schedule new cleanup with generation token
|
|
const timer = window.setTimeout(() => {
|
|
// Check if this cleanup is still valid (file hasn't been re-added)
|
|
if (this.fileGenerations.get(fileId) === currentGen) {
|
|
this.cleanupFile(fileId, stateRef);
|
|
} else {
|
|
if (DEBUG) console.log(`🗂️ Skipped stale cleanup for file ${fileId} (generation mismatch)`);
|
|
}
|
|
}, delay);
|
|
|
|
this.cleanupTimers.set(fileId, timer);
|
|
if (DEBUG) console.log(`🗂️ Scheduled cleanup for file ${fileId} in ${delay}ms (gen ${currentGen})`);
|
|
};
|
|
|
|
/**
|
|
* Remove a file immediately with complete resource cleanup
|
|
*/
|
|
removeFiles = (fileIds: FileId[], stateRef?: React.MutableRefObject<any>): void => {
|
|
if (DEBUG) console.log(`🗂️ Removing ${fileIds.length} files immediately`);
|
|
|
|
fileIds.forEach(fileId => {
|
|
// Clean up all resources for this file
|
|
this.cleanupAllResourcesForFile(fileId, stateRef);
|
|
});
|
|
|
|
// Dispatch removal action once for all files (reducer only updates state)
|
|
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds } });
|
|
};
|
|
|
|
/**
|
|
* Complete resource cleanup for a single file
|
|
*/
|
|
private cleanupAllResourcesForFile = (fileId: FileId, stateRef?: React.MutableRefObject<any>): void => {
|
|
// Remove from files ref
|
|
this.filesRef.current.delete(fileId);
|
|
|
|
|
|
// Cancel cleanup timer and generation
|
|
const timer = this.cleanupTimers.get(fileId);
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
this.cleanupTimers.delete(fileId);
|
|
if (DEBUG) console.log(`🗂️ Cancelled cleanup timer for file: ${fileId}`);
|
|
}
|
|
this.fileGenerations.delete(fileId);
|
|
|
|
// Clean up blob URLs from file record if we have access to state
|
|
if (stateRef) {
|
|
const record = stateRef.current.files.byId[fileId];
|
|
if (record) {
|
|
// Defer revocation of thumbnail blob URLs to prevent image loading race conditions
|
|
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
|
|
try {
|
|
// Add a small delay to ensure images have time to load
|
|
setTimeout(() => {
|
|
URL.revokeObjectURL(record.thumbnailUrl);
|
|
if (DEBUG) console.log(`🗂️ Revoked thumbnail blob URL for file: ${fileId}`);
|
|
}, 1000); // 1 second delay
|
|
} catch (error) {
|
|
if (DEBUG) console.warn('Error revoking thumbnail URL:', error);
|
|
}
|
|
}
|
|
|
|
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
|
|
try {
|
|
// Add a small delay to ensure any pending operations complete
|
|
setTimeout(() => {
|
|
URL.revokeObjectURL(record.blobUrl);
|
|
if (DEBUG) console.log(`🗂️ Revoked file blob URL for file: ${fileId}`);
|
|
}, 1000); // 1 second delay
|
|
} catch (error) {
|
|
if (DEBUG) console.warn('Error revoking file URL:', error);
|
|
}
|
|
}
|
|
|
|
// Clean up processed file thumbnails with delay
|
|
if (record.processedFile?.pages) {
|
|
record.processedFile.pages.forEach((page: ProcessedFilePage, index: number) => {
|
|
if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
|
|
try {
|
|
const thumbnailUrl = page.thumbnail;
|
|
// Add delay for page thumbnails too
|
|
setTimeout(() => {
|
|
URL.revokeObjectURL(thumbnailUrl);
|
|
if (DEBUG) console.log(`🗂️ Revoked page ${index} thumbnail for file: ${fileId}`);
|
|
}, 1000); // 1 second delay
|
|
} catch (error) {
|
|
if (DEBUG) console.warn('Error revoking page thumbnail URL:', error);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Update file record with race condition guards
|
|
*/
|
|
updateFileRecord = (fileId: FileId, updates: Partial<FileRecord>, stateRef?: React.MutableRefObject<any>): void => {
|
|
// Guard against updating removed files (race condition protection)
|
|
if (!this.filesRef.current.has(fileId)) {
|
|
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);
|
|
return;
|
|
}
|
|
|
|
// Additional state guard for rare race conditions
|
|
if (stateRef && !stateRef.current.files.byId[fileId]) {
|
|
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (state): ${fileId}`);
|
|
return;
|
|
}
|
|
|
|
this.dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } });
|
|
};
|
|
|
|
/**
|
|
* Cleanup on unmount
|
|
*/
|
|
destroy = (): void => {
|
|
if (DEBUG) console.log('🗂️ FileLifecycleManager destroying - cleaning up all resources');
|
|
this.cleanupAllFiles();
|
|
};
|
|
} |