diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 7bca656a0..42e6c1c68 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -74,7 +74,7 @@ function FileContextInner({ // File operations using unified addFiles helper with persistence const addRawFiles = useCallback(async (files: File[]): Promise => { - 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 if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) { @@ -91,12 +91,12 @@ function FileContextInner({ }, [indexedDB, enablePersistence]); const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise => { - const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch); + const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager); return result.map(({ file }) => file); }, []); const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>): Promise => { - const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch); + const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager); return result.map(({ file }) => file); }, []); @@ -179,7 +179,6 @@ function FileContextInner({ consumeFiles: consumeFilesWrapper, setHasUnsavedChanges, trackBlobUrl: lifecycleManager.trackBlobUrl, - trackPdfDocument: lifecycleManager.trackPdfDocument, cleanupFile: (fileId: string) => lifecycleManager.cleanupFile(fileId, stateRef), scheduleCleanup: (fileId: string, delay?: number) => lifecycleManager.scheduleCleanup(fileId, delay, stateRef) diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index ee73deba2..8f32fa521 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -13,6 +13,7 @@ import { } from '../../types/fileContext'; import { FileMetadata } from '../../types/file'; import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils'; +import { FileLifecycleManager } from './lifecycle'; import { fileProcessingService } from '../../services/fileProcessingService'; import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors'; @@ -59,7 +60,8 @@ export async function addFiles( options: AddFileOptions, stateRef: React.MutableRefObject, filesRef: React.MutableRefObject>, - dispatch: React.Dispatch + dispatch: React.Dispatch, + lifecycleManager: FileLifecycleManager ): Promise> { const fileRecords: FileRecord[] = []; const addedFiles: Array<{ file: File; id: FileId; thumbnail?: string }> = []; @@ -104,6 +106,10 @@ export async function addFiles( const record = toFileRecord(file, fileId); if (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 @@ -140,6 +146,10 @@ export async function addFiles( const record = toFileRecord(file, fileId); if (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 @@ -182,6 +192,10 @@ export async function addFiles( // Restore metadata from storage if (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 diff --git a/frontend/src/contexts/file/fileHooks.ts b/frontend/src/contexts/file/fileHooks.ts index fdf381b35..1a8990b7f 100644 --- a/frontend/src/contexts/file/fileHooks.ts +++ b/frontend/src/contexts/file/fileHooks.ts @@ -150,7 +150,6 @@ export function useFileContext() { return useMemo(() => ({ // Lifecycle management trackBlobUrl: actions.trackBlobUrl, - trackPdfDocument: actions.trackPdfDocument, scheduleCleanup: actions.scheduleCleanup, setUnsavedChanges: actions.setHasUnsavedChanges, diff --git a/frontend/src/contexts/file/lifecycle.ts b/frontend/src/contexts/file/lifecycle.ts index 703984c78..1879896a2 100644 --- a/frontend/src/contexts/file/lifecycle.ts +++ b/frontend/src/contexts/file/lifecycle.ts @@ -12,7 +12,6 @@ const DEBUG = process.env.NODE_ENV === 'development'; export class FileLifecycleManager { private cleanupTimers = new Map(); private blobUrls = new Set(); - private pdfDocuments = new Map(); private fileGenerations = new Map(); // Generation tokens to prevent stale cleanup 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) @@ -71,19 +52,6 @@ export class FileLifecycleManager { cleanupAllFiles = (): void => { 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 this.blobUrls.forEach(url => { try { @@ -162,22 +130,6 @@ export class FileLifecycleManager { // Remove from files ref 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 const timer = this.cleanupTimers.get(fileId); diff --git a/frontend/src/hooks/useMemoryManagement.ts b/frontend/src/hooks/useMemoryManagement.ts deleted file mode 100644 index d27e5ed56..000000000 --- a/frontend/src/hooks/useMemoryManagement.ts +++ /dev/null @@ -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 - }; -} \ No newline at end of file diff --git a/frontend/src/services/enhancedPDFProcessingService.ts b/frontend/src/services/enhancedPDFProcessingService.ts index 58fb783e7..f9f067c30 100644 --- a/frontend/src/services/enhancedPDFProcessingService.ts +++ b/frontend/src/services/enhancedPDFProcessingService.ts @@ -182,42 +182,44 @@ export class EnhancedPDFProcessingService { ): Promise { const arrayBuffer = await file.arrayBuffer(); const pdf = await pdfWorkerManager.createDocument(arrayBuffer); - const totalPages = pdf.numPages; + + try { + const totalPages = pdf.numPages; - state.progress = 10; - this.notifyListeners(); + state.progress = 10; + this.notifyListeners(); - const pages: PDFPage[] = []; + const pages: PDFPage[] = []; - for (let i = 1; i <= totalPages; i++) { - // Check for cancellation - if (state.cancellationToken?.signal.aborted) { - pdfWorkerManager.destroyDocument(pdf); - throw new Error('Processing cancelled'); + for (let i = 1; i <= totalPages; i++) { + // Check for cancellation + 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); - 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; + return this.createProcessedFile(file, pages, totalPages); + } finally { + pdfWorkerManager.destroyDocument(pdf); + state.progress = 100; this.notifyListeners(); } - - pdfWorkerManager.destroyDocument(pdf); - state.progress = 100; - this.notifyListeners(); - - return this.createProcessedFile(file, pages, totalPages); } /** diff --git a/frontend/src/tools/Merge.tsx b/frontend/src/tools/Merge.tsx index c28b2287c..ecacda92a 100644 --- a/frontend/src/tools/Merge.tsx +++ b/frontend/src/tools/Merge.tsx @@ -23,6 +23,15 @@ const MergePdfPanel: React.FC = ({ files, setDownloadUrl, pa const [errorMessage, setErrorMessage] = useState(null); 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(() => { setSelectedFiles(files.map(() => true)); }, [files]); @@ -67,6 +76,12 @@ const MergePdfPanel: React.FC = ({ files, setDownloadUrl, pa } 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); setDownloadUrl(url); setLocalDownloadUrl(url); diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 92b1f4613..b39c27589 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -245,7 +245,6 @@ export interface FileContextActions { // Resource management trackBlobUrl: (url: string) => void; - trackPdfDocument: (key: string, pdfDoc: any) => void; scheduleCleanup: (fileId: string, delay?: number) => void; cleanupFile: (fileId: string) => void; } diff --git a/frontend/src/utils/thumbnailUtils.ts b/frontend/src/utils/thumbnailUtils.ts index c1a130b83..3335e2bb6 100644 --- a/frontend/src/utils/thumbnailUtils.ts +++ b/frontend/src/utils/thumbnailUtils.ts @@ -333,7 +333,7 @@ export async function generateThumbnailForFile(file: File): Promise