Clean up file management

This commit is contained in:
Reece Browne 2025-08-20 14:47:59 +01:00
parent 3a5402b55a
commit a260d72925
9 changed files with 65 additions and 115 deletions

View File

@ -74,7 +74,7 @@ function FileContextInner({
// File operations using unified addFiles helper with persistence // File operations using unified addFiles helper with persistence
const addRawFiles = useCallback(async (files: File[]): Promise<File[]> => { const addRawFiles = useCallback(async (files: File[]): Promise<File[]> => {
const addedFilesWithIds = await addFiles('raw', { files }, stateRef, filesRef, dispatch); const addedFilesWithIds = await addFiles('raw', { files }, stateRef, filesRef, dispatch, lifecycleManager);
// Persist to IndexedDB if enabled - pass existing thumbnail to prevent double generation // Persist to IndexedDB if enabled - pass existing thumbnail to prevent double generation
if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) { if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) {
@ -91,12 +91,12 @@ function FileContextInner({
}, [indexedDB, enablePersistence]); }, [indexedDB, enablePersistence]);
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<File[]> => { const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<File[]> => {
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch); const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
return result.map(({ file }) => file); return result.map(({ file }) => file);
}, []); }, []);
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>): Promise<File[]> => { const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>): Promise<File[]> => {
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch); const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
return result.map(({ file }) => file); return result.map(({ file }) => file);
}, []); }, []);
@ -179,7 +179,6 @@ function FileContextInner({
consumeFiles: consumeFilesWrapper, consumeFiles: consumeFilesWrapper,
setHasUnsavedChanges, setHasUnsavedChanges,
trackBlobUrl: lifecycleManager.trackBlobUrl, trackBlobUrl: lifecycleManager.trackBlobUrl,
trackPdfDocument: lifecycleManager.trackPdfDocument,
cleanupFile: (fileId: string) => lifecycleManager.cleanupFile(fileId, stateRef), cleanupFile: (fileId: string) => lifecycleManager.cleanupFile(fileId, stateRef),
scheduleCleanup: (fileId: string, delay?: number) => scheduleCleanup: (fileId: string, delay?: number) =>
lifecycleManager.scheduleCleanup(fileId, delay, stateRef) lifecycleManager.scheduleCleanup(fileId, delay, stateRef)

View File

@ -13,6 +13,7 @@ import {
} from '../../types/fileContext'; } from '../../types/fileContext';
import { FileMetadata } from '../../types/file'; import { FileMetadata } from '../../types/file';
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils'; import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
import { FileLifecycleManager } from './lifecycle';
import { fileProcessingService } from '../../services/fileProcessingService'; import { fileProcessingService } from '../../services/fileProcessingService';
import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors'; import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors';
@ -59,7 +60,8 @@ export async function addFiles(
options: AddFileOptions, options: AddFileOptions,
stateRef: React.MutableRefObject<FileContextState>, stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>, filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction> dispatch: React.Dispatch<FileContextAction>,
lifecycleManager: FileLifecycleManager
): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> { ): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> {
const fileRecords: FileRecord[] = []; const fileRecords: FileRecord[] = [];
const addedFiles: Array<{ file: File; id: FileId; thumbnail?: string }> = []; const addedFiles: Array<{ file: File; id: FileId; thumbnail?: string }> = [];
@ -104,6 +106,10 @@ export async function addFiles(
const record = toFileRecord(file, fileId); const record = toFileRecord(file, fileId);
if (thumbnail) { if (thumbnail) {
record.thumbnailUrl = thumbnail; record.thumbnailUrl = thumbnail;
// Track blob URLs for cleanup (images return blob URLs that need revocation)
if (thumbnail.startsWith('blob:')) {
lifecycleManager.trackBlobUrl(thumbnail);
}
} }
// Create initial processedFile metadata with page count // Create initial processedFile metadata with page count
@ -140,6 +146,10 @@ export async function addFiles(
const record = toFileRecord(file, fileId); const record = toFileRecord(file, fileId);
if (thumbnail) { if (thumbnail) {
record.thumbnailUrl = thumbnail; record.thumbnailUrl = thumbnail;
// Track blob URLs for cleanup (images return blob URLs that need revocation)
if (thumbnail.startsWith('blob:')) {
lifecycleManager.trackBlobUrl(thumbnail);
}
} }
// Create processedFile with provided metadata // Create processedFile with provided metadata
@ -182,6 +192,10 @@ export async function addFiles(
// Restore metadata from storage // Restore metadata from storage
if (metadata.thumbnail) { if (metadata.thumbnail) {
record.thumbnailUrl = metadata.thumbnail; record.thumbnailUrl = metadata.thumbnail;
// Track blob URLs for cleanup (images return blob URLs that need revocation)
if (metadata.thumbnail.startsWith('blob:')) {
lifecycleManager.trackBlobUrl(metadata.thumbnail);
}
} }
// Generate processedFile metadata for stored files using PDF worker manager // Generate processedFile metadata for stored files using PDF worker manager

View File

@ -150,7 +150,6 @@ export function useFileContext() {
return useMemo(() => ({ return useMemo(() => ({
// Lifecycle management // Lifecycle management
trackBlobUrl: actions.trackBlobUrl, trackBlobUrl: actions.trackBlobUrl,
trackPdfDocument: actions.trackPdfDocument,
scheduleCleanup: actions.scheduleCleanup, scheduleCleanup: actions.scheduleCleanup,
setUnsavedChanges: actions.setHasUnsavedChanges, setUnsavedChanges: actions.setHasUnsavedChanges,

View File

@ -12,7 +12,6 @@ const DEBUG = process.env.NODE_ENV === 'development';
export class FileLifecycleManager { export class FileLifecycleManager {
private cleanupTimers = new Map<string, number>(); private cleanupTimers = new Map<string, number>();
private blobUrls = new Set<string>(); private blobUrls = new Set<string>();
private pdfDocuments = new Map<string, any>();
private fileGenerations = new Map<string, number>(); // Generation tokens to prevent stale cleanup private fileGenerations = new Map<string, number>(); // Generation tokens to prevent stale cleanup
constructor( constructor(
@ -33,24 +32,6 @@ export class FileLifecycleManager {
} }
}; };
/**
* Track PDF documents for cleanup
*/
trackPdfDocument = (key: string, pdfDoc: any): void => {
// Clean up existing PDF document if present
const existing = this.pdfDocuments.get(key);
if (existing && typeof existing.destroy === 'function') {
try {
existing.destroy();
if (DEBUG) console.log(`🗂️ Destroyed existing PDF document for key: ${key}`);
} catch (error) {
if (DEBUG) console.warn('Error destroying existing PDF document:', error);
}
}
this.pdfDocuments.set(key, pdfDoc);
if (DEBUG) console.log(`🗂️ Tracking PDF document for key: ${key}`);
};
/** /**
* Clean up resources for a specific file (with stateRef access for complete cleanup) * Clean up resources for a specific file (with stateRef access for complete cleanup)
@ -71,19 +52,6 @@ export class FileLifecycleManager {
cleanupAllFiles = (): void => { cleanupAllFiles = (): void => {
if (DEBUG) console.log('🗂️ Cleaning up all files and resources'); if (DEBUG) console.log('🗂️ Cleaning up all files and resources');
// Clean up all PDF documents
this.pdfDocuments.forEach((pdfDoc, key) => {
if (pdfDoc && typeof pdfDoc.destroy === 'function') {
try {
pdfDoc.destroy();
if (DEBUG) console.log(`🗂️ Destroyed PDF document for key: ${key}`);
} catch (error) {
if (DEBUG) console.warn(`Error destroying PDF document for key ${key}:`, error);
}
}
});
this.pdfDocuments.clear();
// Revoke all blob URLs // Revoke all blob URLs
this.blobUrls.forEach(url => { this.blobUrls.forEach(url => {
try { try {
@ -162,22 +130,6 @@ export class FileLifecycleManager {
// Remove from files ref // Remove from files ref
this.filesRef.current.delete(fileId); this.filesRef.current.delete(fileId);
// Clean up PDF documents (scan all keys that start with fileId)
const keysToDelete: string[] = [];
this.pdfDocuments.forEach((pdfDoc, key) => {
if (key === fileId || key.startsWith(`${fileId}:`)) {
if (pdfDoc && typeof pdfDoc.destroy === 'function') {
try {
pdfDoc.destroy();
keysToDelete.push(key);
if (DEBUG) console.log(`🗂️ Destroyed PDF document for key: ${key}`);
} catch (error) {
if (DEBUG) console.warn(`Error destroying PDF document for key ${key}:`, error);
}
}
}
});
keysToDelete.forEach(key => this.pdfDocuments.delete(key));
// Cancel cleanup timer and generation // Cancel cleanup timer and generation
const timer = this.cleanupTimers.get(fileId); const timer = this.cleanupTimers.get(fileId);

View File

@ -1,30 +0,0 @@
import { useCallback } from 'react';
import { useFileContext } from '../contexts/FileContext';
/**
* Hook for components that need to register resources with centralized memory management
*/
export function useMemoryManagement() {
const { trackBlobUrl, trackPdfDocument, scheduleCleanup } = useFileContext();
const registerBlobUrl = useCallback((url: string) => {
trackBlobUrl(url);
return url;
}, [trackBlobUrl]);
const registerPdfDocument = useCallback((fileId: string, pdfDoc: any) => {
trackPdfDocument(fileId, pdfDoc);
return pdfDoc;
}, [trackPdfDocument]);
const cancelCleanup = useCallback((fileId: string) => {
// Cancel scheduled cleanup (user is actively using the file)
scheduleCleanup(fileId, -1); // -1 cancels the timer
}, [scheduleCleanup]);
return {
registerBlobUrl,
registerPdfDocument,
cancelCleanup
};
}

View File

@ -182,42 +182,44 @@ export class EnhancedPDFProcessingService {
): Promise<ProcessedFile> { ): Promise<ProcessedFile> {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(arrayBuffer); const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
const totalPages = pdf.numPages;
state.progress = 10; try {
this.notifyListeners(); const totalPages = pdf.numPages;
const pages: PDFPage[] = []; state.progress = 10;
this.notifyListeners();
for (let i = 1; i <= totalPages; i++) { const pages: PDFPage[] = [];
// Check for cancellation
if (state.cancellationToken?.signal.aborted) { for (let i = 1; i <= totalPages; i++) {
pdfWorkerManager.destroyDocument(pdf); // Check for cancellation
throw new Error('Processing cancelled'); if (state.cancellationToken?.signal.aborted) {
throw new Error('Processing cancelled');
}
const page = await pdf.getPage(i);
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
pages.push({
id: `${file.name}-page-${i}`,
pageNumber: i,
thumbnail,
rotation: 0,
selected: false
});
// Update progress
state.progress = 10 + (i / totalPages) * 85;
state.currentPage = i;
this.notifyListeners();
} }
const page = await pdf.getPage(i); return this.createProcessedFile(file, pages, totalPages);
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality); } finally {
pdfWorkerManager.destroyDocument(pdf);
pages.push({ state.progress = 100;
id: `${file.name}-page-${i}`,
pageNumber: i,
thumbnail,
rotation: 0,
selected: false
});
// Update progress
state.progress = 10 + (i / totalPages) * 85;
state.currentPage = i;
this.notifyListeners(); this.notifyListeners();
} }
pdfWorkerManager.destroyDocument(pdf);
state.progress = 100;
this.notifyListeners();
return this.createProcessedFile(file, pages, totalPages);
} }
/** /**

View File

@ -23,6 +23,15 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({ files, setDownloadUrl, pa
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("merge-pdfs"); const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("merge-pdfs");
// Cleanup blob URL when component unmounts or new URL is set
useEffect(() => {
return () => {
if (downloadUrl && downloadUrl.startsWith('blob:')) {
URL.revokeObjectURL(downloadUrl);
}
};
}, [downloadUrl]);
useEffect(() => { useEffect(() => {
setSelectedFiles(files.map(() => true)); setSelectedFiles(files.map(() => true));
}, [files]); }, [files]);
@ -67,6 +76,12 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({ files, setDownloadUrl, pa
} }
const blob = await response.blob(); const blob = await response.blob();
// Clean up previous blob URL before setting new one
if (downloadUrl && downloadUrl.startsWith('blob:')) {
URL.revokeObjectURL(downloadUrl);
}
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
setDownloadUrl(url); setDownloadUrl(url);
setLocalDownloadUrl(url); setLocalDownloadUrl(url);

View File

@ -245,7 +245,6 @@ export interface FileContextActions {
// Resource management // Resource management
trackBlobUrl: (url: string) => void; trackBlobUrl: (url: string) => void;
trackPdfDocument: (key: string, pdfDoc: any) => void;
scheduleCleanup: (fileId: string, delay?: number) => void; scheduleCleanup: (fileId: string, delay?: number) => void;
cleanupFile: (fileId: string) => void; cleanupFile: (fileId: string) => void;
} }

View File

@ -333,7 +333,7 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
return generatePlaceholderThumbnail(file); return generatePlaceholderThumbnail(file);
} }
// Handle image files // Handle image files - creates blob URL that needs cleanup by caller
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
return URL.createObjectURL(file); return URL.createObjectURL(file);
} }