/** * Refactored FileContext with reducer pattern and normalized state * * PERFORMANCE IMPROVEMENTS: * - Normalized state: File objects stored in refs, only IDs in state * - Pure reducer: No object creation in reducer functions * - Split contexts: StateContext vs ActionsContext prevents unnecessary rerenders * - Individual selector hooks: Avoid selector object recreation * - Stable actions: useCallback + stateRef prevents action recreation * - Throttled persistence: Debounced localStorage writes * - Proper resource cleanup: Automatic blob URL revocation * * USAGE: * - State access: useFileState(), useFileRecord(), useFileSelection() * - Actions only: useFileActions(), useFileManagement(), useViewerActions() * - Combined: useFileContext() (legacy - causes rerenders on any state change) * - FileRecord is the new lightweight "processed file" - no heavy processing needed * * PERFORMANCE NOTES: * - useFileState() still rerenders on ANY state change (selectors object recreation) * - For list UIs: consider ids-only context or use-context-selector * - Individual hooks (useFileRecord) are the most performant option */ import React, { createContext, useContext, useReducer, useCallback, useEffect, useRef } from 'react'; import { FileContextValue, FileContextState, FileContextProviderProps, FileContextSelectors, FileContextStateValue, FileContextActionsValue, FileContextActions, ModeType, FileId, FileRecord, toFileRecord, revokeFileResources, createStableFileId } from '../types/fileContext'; import { EnhancedPDFProcessingService } from '../services/enhancedPDFProcessingService'; // Initial state const initialViewerConfig: ViewerConfig = { zoom: 1.0, currentPage: 1, viewMode: 'single', sidebarOpen: false }; const initialState: FileContextState = { activeFiles: [], processedFiles: new Map(), currentMode: 'pageEditor', currentView: 'fileEditor', // Legacy field currentTool: null, // Legacy field fileEditHistory: new Map(), globalFileOperations: [], fileOperationHistory: new Map(), selectedFileIds: [], selectedPageNumbers: [], viewerConfig: initialViewerConfig, isProcessing: false, processingProgress: 0, lastExportConfig: undefined, hasUnsavedChanges: false, pendingNavigation: null, showNavigationWarning: false }; // Action types type FileContextAction = | { type: 'SET_ACTIVE_FILES'; payload: File[] } | { type: 'ADD_FILES'; payload: File[] } | { type: 'REMOVE_FILES'; payload: string[] } | { type: 'SET_PROCESSED_FILES'; payload: Map } | { type: 'UPDATE_PROCESSED_FILE'; payload: { file: File; processedFile: ProcessedFile } } | { type: 'SET_CURRENT_MODE'; payload: ModeType } | { type: 'SET_CURRENT_VIEW'; payload: ViewType } | { type: 'SET_CURRENT_TOOL'; payload: ToolType } | { type: 'SET_SELECTED_FILES'; payload: string[] } | { type: 'SET_SELECTED_PAGES'; payload: number[] } | { type: 'CLEAR_SELECTIONS' } | { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } } | { type: 'UPDATE_VIEWER_CONFIG'; payload: Partial } | { type: 'ADD_PAGE_OPERATIONS'; payload: { fileId: string; operations: PageOperation[] } } | { type: 'ADD_FILE_OPERATION'; payload: FileOperation } | { type: 'RECORD_OPERATION'; payload: { fileId: string; operation: FileOperation | PageOperation } } | { type: 'MARK_OPERATION_APPLIED'; payload: { fileId: string; operationId: string } } | { type: 'MARK_OPERATION_FAILED'; payload: { fileId: string; operationId: string; error: string } } | { type: 'CLEAR_FILE_HISTORY'; payload: string } | { type: 'SET_EXPORT_CONFIG'; payload: FileContextState['lastExportConfig'] } | { type: 'SET_UNSAVED_CHANGES'; payload: boolean } | { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null } | { type: 'SHOW_NAVIGATION_WARNING'; payload: boolean } | { type: 'RESET_CONTEXT' } | { type: 'LOAD_STATE'; payload: Partial }; // Reducer function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState { switch (action.type) { case 'ADD_FILES': { const { files } = action.payload; const newIds: FileId[] = []; const newById: Record = { ...state.files.byId }; files.forEach(file => { const stableId = createStableFileId(file); // Only add if not already present (dedupe by stable ID) if (!newById[stableId]) { const record = toFileRecord(file, stableId); newIds.push(record.id); newById[record.id] = record; } }); return { ...state, files: { ids: [...state.files.ids, ...newIds], byId: newById } }; } case 'REMOVE_FILES': { const { fileIds } = action.payload; const remainingIds = state.files.ids.filter(id => !fileIds.includes(id)); const newById = { ...state.files.byId }; // Clean up removed files fileIds.forEach(id => { const record = newById[id]; if (record) { revokeFileResources(record); delete newById[id]; } }); return { ...state, files: { ids: remainingIds, byId: newById }, ui: { ...state.ui, selectedFileIds: state.ui.selectedFileIds.filter(id => !fileIds.includes(id)) } }; } case 'UPDATE_FILE_RECORD': { const { id, updates } = action.payload; const existingRecord = state.files.byId[id]; if (!existingRecord) return state; return { ...state, files: { ...state.files, byId: { ...state.files.byId, [id]: { ...existingRecord, ...updates } } } }; } case 'SET_CURRENT_MODE': { const coreViews = ['viewer', 'pageEditor', 'fileEditor']; const isToolMode = !coreViews.includes(action.payload); return { ...state, files: { ids: [], byId: {} }, ui: { ...state.ui, selectedFileIds: [], selectedPageNumbers: [] } }; } case 'SET_MODE': { return { ...state, ui: { ...state.ui, currentMode: action.payload.mode } }; } case 'SET_SELECTED_FILES': { return { ...state, ui: { ...state.ui, selectedFileIds: action.payload.fileIds } }; } case 'SET_SELECTED_PAGES': { return { ...state, ui: { ...state.ui, selectedPageNumbers: action.payload.pageNumbers } }; } case 'CLEAR_SELECTIONS': { return { ...state, ui: { ...state.ui, selectedFileIds: [], selectedPageNumbers: [] } }; } case 'SET_PROCESSING': { return { ...state, ui: { ...state.ui, isProcessing: action.payload.isProcessing, processingProgress: action.payload.progress } }; } // Tool selection cases (replaces FileSelectionContext) case 'SET_TOOL_MODE': { return { ...state, viewerConfig: { ...state.viewerConfig, ...action.payload } }; } case 'ADD_PAGE_OPERATIONS': { const newHistory = new Map(state.fileEditHistory); const existing = newHistory.get(action.payload.fileId); newHistory.set(action.payload.fileId, { fileId: action.payload.fileId, pageOperations: existing ? [...existing.pageOperations, ...action.payload.operations] : action.payload.operations, lastModified: Date.now() }); return { ...state, fileEditHistory: newHistory }; } case 'ADD_FILE_OPERATION': { return { ...state, globalFileOperations: [...state.globalFileOperations, action.payload] }; } case 'RECORD_OPERATION': { const { fileId, operation } = action.payload; const newOperationHistory = new Map(state.fileOperationHistory); const existingHistory = newOperationHistory.get(fileId); if (existingHistory) { // Add operation to existing history newOperationHistory.set(fileId, { ...existingHistory, operations: [...existingHistory.operations, operation], lastModified: Date.now() }); } else { // Create new history for this file newOperationHistory.set(fileId, { fileId, fileName: fileId, // Will be updated with actual filename when available operations: [operation], createdAt: Date.now(), lastModified: Date.now() }); } return { ...state, fileOperationHistory: newOperationHistory }; } case 'MARK_OPERATION_APPLIED': { const appliedHistory = new Map(state.fileOperationHistory); const appliedFileHistory = appliedHistory.get(action.payload.fileId); if (appliedFileHistory) { const updatedOperations = appliedFileHistory.operations.map(op => op.id === action.payload.operationId ? { ...op, status: 'applied' as const } : op ); appliedHistory.set(action.payload.fileId, { ...appliedFileHistory, operations: updatedOperations, lastModified: Date.now() }); } return { ...state, fileOperationHistory: appliedHistory }; } case 'MARK_OPERATION_FAILED': { const failedHistory = new Map(state.fileOperationHistory); const failedFileHistory = failedHistory.get(action.payload.fileId); if (failedFileHistory) { const updatedOperations = failedFileHistory.operations.map(op => op.id === action.payload.operationId ? { ...op, status: 'failed' as const, metadata: { ...op.metadata, error: action.payload.error } } : op ); failedHistory.set(action.payload.fileId, { ...failedFileHistory, operations: updatedOperations, lastModified: Date.now() }); } return { ...state, fileOperationHistory: failedHistory }; } case 'CLEAR_FILE_HISTORY': { const clearedHistory = new Map(state.fileOperationHistory); clearedHistory.delete(action.payload); return { ...state, fileOperationHistory: clearedHistory }; } case 'SET_UNSAVED_CHANGES': { return { ...state, ui: { ...state.ui, hasUnsavedChanges: action.payload.hasChanges } }; } case 'SET_PENDING_NAVIGATION': { return { ...state, ui: { ...state.ui, pendingNavigation: action.payload.navigationFn } }; } case 'SHOW_NAVIGATION_WARNING': { return { ...state, ui: { ...state.ui, showNavigationWarning: action.payload.show } }; } case 'CONFIRM_NAVIGATION': { const pendingNavigation = state.ui.pendingNavigation; if (pendingNavigation) { pendingNavigation(); } return { ...state, ui: { ...state.ui, pendingNavigation: null, showNavigationWarning: false } }; } case 'CANCEL_NAVIGATION': { return { ...state, ui: { ...state.ui, pendingNavigation: null, showNavigationWarning: false } }; } case 'RESET_CONTEXT': { // Clean up all resources before reset Object.values(state.files.byId).forEach(revokeFileResources); return { ...initialFileContextState }; } default: return state; } } // Split contexts for performance const FileStateContext = createContext(undefined); const FileActionsContext = createContext(undefined); // Provider component export function FileContextProvider({ children, enableUrlSync = true, enablePersistence = true }: FileContextProviderProps) { const [state, dispatch] = useReducer(fileContextReducer, initialState); // Cleanup timers and refs const cleanupTimers = useRef>(new Map()); const blobUrls = useRef>(new Set()); const pdfDocuments = useRef>(new Map()); // Enhanced file processing hook const { processedFiles, processingStates, isProcessing: globalProcessing, processingProgress, actions: processingActions } = useEnhancedProcessedFiles(state.activeFiles, { strategy: 'progressive_chunked', thumbnailQuality: 'medium', chunkSize: 5, // Process 5 pages at a time for smooth progress priorityPageCount: 0 // No special priority pages }); // Update processed files when they change useEffect(() => { dispatch({ type: 'SET_PROCESSED_FILES', payload: processedFiles }); dispatch({ type: 'SET_PROCESSING', payload: { isProcessing: globalProcessing, progress: processingProgress.overall } }); }, [processedFiles, globalProcessing, processingProgress.overall]); // Centralized memory management const trackBlobUrl = useCallback((url: string) => { blobUrls.current.add(url); }, []); const trackPdfDocument = useCallback((fileId: string, pdfDoc: PDFDocument) => { // Clean up existing document for this file if any const existing = pdfDocuments.current.get(fileId); if (existing && existing.destroy) { try { existing.destroy(); } catch (error) { console.warn('Error destroying existing PDF document:', error); } } pdfDocuments.current.set(fileId, pdfDoc); }, []); const cleanupFile = useCallback(async (fileId: string) => { console.log('Cleaning up file:', fileId); try { // Cancel any pending cleanup timer const timer = cleanupTimers.current.get(fileId); if (timer) { clearTimeout(timer); cleanupTimers.current.delete(fileId); } // Cleanup PDF document instances (but preserve processed file cache) const pdfDoc = pdfDocuments.current.get(fileId); if (pdfDoc && pdfDoc.destroy) { pdfDoc.destroy(); pdfDocuments.current.delete(fileId); } // IMPORTANT: Don't cancel processing or clear cache during normal view switches // Only do this when file is actually being removed // enhancedPDFProcessingService.cancelProcessing(fileId); // thumbnailGenerationService.stopGeneration(); } catch (error) { console.warn('Error during file cleanup:', error); } }, []); const cleanupAllFiles = useCallback(() => { console.log('Cleaning up all files'); try { // Clear all timers cleanupTimers.current.forEach(timer => clearTimeout(timer)); cleanupTimers.current.clear(); // Destroy all PDF documents pdfDocuments.current.forEach((pdfDoc, fileId) => { if (pdfDoc && pdfDoc.destroy) { try { pdfDoc.destroy(); } catch (error) { console.warn(`Error destroying PDF document for ${fileId}:`, error); } } }); pdfDocuments.current.clear(); // Revoke all blob URLs blobUrls.current.forEach(url => { try { URL.revokeObjectURL(url); } catch (error) { console.warn('Error revoking blob URL:', error); } }); blobUrls.current.clear(); // Clear all processing enhancedPDFProcessingService.clearAllProcessing(); // Destroy thumbnails thumbnailGenerationService.destroy(); // Force garbage collection hint if (typeof window !== 'undefined' && window.gc) { let gc = window.gc setTimeout(() => gc(), 100); } } catch (error) { console.warn('Error during cleanup all files:', error); } }, []); const scheduleCleanup = useCallback((fileId: string, delay: number = 30000) => { // Cancel existing timer const existingTimer = cleanupTimers.current.get(fileId); if (existingTimer) { clearTimeout(existingTimer); cleanupTimers.current.delete(fileId); } // If delay is negative, just cancel (don't reschedule) if (delay < 0) { return; } // Schedule new cleanup const timer = setTimeout(() => { cleanupFile(fileId); }, delay); cleanupTimers.current.set(fileId, timer); }, [cleanupFile]); // Action implementations const addFiles = useCallback(async (files: File[]): Promise => { dispatch({ type: 'ADD_FILES', payload: files }); // Auto-save to IndexedDB if persistence enabled if (enablePersistence) { for (const file of files) { try { // Check if file already has an explicit ID property (already in IndexedDB) const fileId = getFileId(file); if (!fileId) { // File doesn't have explicit ID, store it with thumbnail try { // Generate thumbnail for better recent files experience const thumbnail = await (thumbnailGenerationService as any /* FIX ME */).generateThumbnail(file); const storedFile = await fileStorage.storeFile(file, thumbnail); // Add the ID to the file object Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); } catch (thumbnailError) { // If thumbnail generation fails, store without thumbnail console.warn('Failed to generate thumbnail, storing without:', thumbnailError); const storedFile = await fileStorage.storeFile(file); Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); } } } catch (error) { console.error('Failed to store file:', error); } } } // Return files with their IDs assigned return files; }, [enablePersistence]); const removeFiles = useCallback((fileIds: string[], deleteFromStorage: boolean = true) => { // FULL cleanup for actually removed files (including cache) fileIds.forEach(fileId => { // Cancel processing and clear caches when file is actually removed enhancedPDFProcessingService.cancelProcessing(fileId); cleanupFile(fileId); }); dispatch({ type: 'REMOVE_FILES', payload: fileIds }); // Remove from IndexedDB only if requested if (enablePersistence && deleteFromStorage) { fileIds.forEach(async (fileId) => { try { await fileStorage.deleteFile(fileId); } catch (error) { console.error('Failed to remove file from storage:', error); } }); } }, [enablePersistence, cleanupFile]); const replaceFile = useCallback(async (oldFileId: string, newFile: File) => { // Remove old file and add new one removeFiles([oldFileId]); await addFiles([newFile]); }, [removeFiles, addFiles]); const clearAllFiles = useCallback(() => { // Cleanup all memory before clearing files cleanupAllFiles(); dispatch({ type: 'SET_ACTIVE_FILES', payload: [] }); dispatch({ type: 'CLEAR_SELECTIONS' }); }, [cleanupAllFiles]); // Navigation guard system functions const setHasUnsavedChanges = useCallback((hasChanges: boolean) => { dispatch({ type: 'SET_UNSAVED_CHANGES', payload: hasChanges }); }, []); const requestNavigation = useCallback((navigationFn: () => void): boolean => { if (state.hasUnsavedChanges) { dispatch({ type: 'SET_PENDING_NAVIGATION', payload: navigationFn }); dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: true }); return false; } else { navigationFn(); return true; } }, [state.hasUnsavedChanges]); const confirmNavigation = useCallback(() => { if (state.pendingNavigation) { state.pendingNavigation(); dispatch({ type: 'SET_PENDING_NAVIGATION', payload: null }); } dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: false }); }, [state.pendingNavigation]); const cancelNavigation = useCallback(() => { dispatch({ type: 'SET_PENDING_NAVIGATION', payload: null }); dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: false }); }, []); const setCurrentMode = useCallback((mode: ModeType) => { requestNavigation(() => { dispatch({ type: 'SET_CURRENT_MODE', payload: mode }); if (state.currentMode !== mode && state.activeFiles.length > 0) { if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) { let gc = window.gc; window.requestIdleCallback(() => { gc(); }, { timeout: 5000 }); } } }); }, [requestNavigation, state.currentMode, state.activeFiles]); const setCurrentView = useCallback((view: ViewType) => { requestNavigation(() => { dispatch({ type: 'SET_CURRENT_VIEW', payload: view }); if (state.currentView !== view && state.activeFiles.length > 0) { if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) { let gc = window.gc; window.requestIdleCallback(() => { gc(); }, { timeout: 5000 }); } } }); }, [requestNavigation, state.currentView, state.activeFiles]); const setCurrentTool = useCallback((tool: ToolType) => { requestNavigation(() => { dispatch({ type: 'SET_CURRENT_TOOL', payload: tool }); }); }, [requestNavigation]); const setSelectedFiles = useCallback((fileIds: string[]) => { dispatch({ type: 'SET_SELECTED_FILES', payload: fileIds }); }, []); const setSelectedPages = useCallback((pageNumbers: number[]) => { dispatch({ type: 'SET_SELECTED_PAGES', payload: pageNumbers }); }, []); const updateProcessedFile = useCallback((file: File, processedFile: ProcessedFile) => { dispatch({ type: 'UPDATE_PROCESSED_FILE', payload: { file, processedFile } }); }, []); const clearSelections = useCallback(() => { dispatch({ type: 'CLEAR_SELECTIONS' }); }, []); const applyPageOperations = useCallback((fileId: string, operations: PageOperation[]) => { dispatch({ type: 'ADD_PAGE_OPERATIONS', payload: { fileId, operations } }); }, []); const applyFileOperation = useCallback((operation: FileOperation) => { dispatch({ type: 'ADD_FILE_OPERATION', payload: operation }); }, []); const undoLastOperation = useCallback((fileId?: string) => { console.warn('Undo not yet implemented'); }, []); const updateViewerConfig = useCallback((config: Partial) => { dispatch({ type: 'UPDATE_VIEWER_CONFIG', payload: config }); }, []); const setExportConfig = useCallback((config: FileContextState['lastExportConfig']) => { dispatch({ type: 'SET_EXPORT_CONFIG', payload: config }); }, []); // Operation history management functions const recordOperation = useCallback((fileId: string, operation: FileOperation | PageOperation) => { dispatch({ type: 'RECORD_OPERATION', payload: { fileId, operation } }); }, []); const markOperationApplied = useCallback((fileId: string, operationId: string) => { dispatch({ type: 'MARK_OPERATION_APPLIED', payload: { fileId, operationId } }); }, []); const markOperationFailed = useCallback((fileId: string, operationId: string, error: string) => { dispatch({ type: 'MARK_OPERATION_FAILED', payload: { fileId, operationId, error } }); }, []); const getFileHistory = useCallback((fileId: string): FileOperationHistory | undefined => { return state.fileOperationHistory.get(fileId); }, [state.fileOperationHistory]); const getAppliedOperations = useCallback((fileId: string): (FileOperation | PageOperation)[] => { const history = state.fileOperationHistory.get(fileId); return history ? history.operations.filter(op => op.status === 'applied') : []; }, [state.fileOperationHistory]); const clearFileHistory = useCallback((fileId: string) => { dispatch({ type: 'CLEAR_FILE_HISTORY', payload: fileId }); }, []); // Utility functions const getFileById = useCallback((fileId: string): File | undefined => { return state.activeFiles.find(file => { const actualFileId = getFileId(file); return actualFileId && actualFileId === fileId; }); }, [state.activeFiles]); const getProcessedFileById = useCallback((fileId: string): ProcessedFile | undefined => { const file = getFileById(fileId); return file ? state.processedFiles.get(file) : undefined; }, [getFileById, state.processedFiles]); const getCurrentFile = useCallback((): File | undefined => { if (state.selectedFileIds.length > 0) { return getFileById(state.selectedFileIds[0]); } return state.activeFiles[0]; // Default to first file }, [state.selectedFileIds, state.activeFiles, getFileById]); const getCurrentProcessedFile = useCallback((): ProcessedFile | undefined => { const file = getCurrentFile(); return file ? state.processedFiles.get(file) : undefined; }, [getCurrentFile, state.processedFiles]); // Context persistence const saveContext = useCallback(async () => { if (!enablePersistence) return; try { const contextData = { currentView: state.currentView, currentTool: state.currentTool, selectedFileIds: state.selectedFileIds, selectedPageNumbers: state.selectedPageNumbers, viewerConfig: state.viewerConfig, lastExportConfig: state.lastExportConfig, timestamp: Date.now() }; localStorage.setItem('fileContext', JSON.stringify(contextData)); } catch (error) { console.error('Failed to save context:', error); } }, [state, enablePersistence]); const loadContext = useCallback(async () => { if (!enablePersistence) return; try { const saved = localStorage.getItem('fileContext'); if (saved) { const contextData = JSON.parse(saved); dispatch({ type: 'LOAD_STATE', payload: contextData }); } } catch (error) { console.error('Failed to load context:', error); } }, [enablePersistence]); const resetContext = useCallback(() => { dispatch({ type: 'RESET_CONTEXT' }); if (enablePersistence) { localStorage.removeItem('fileContext'); } }, [enablePersistence]); // Auto-save context when it changes useEffect(() => { saveContext(); }, [saveContext]); // Load context on mount useEffect(() => { loadContext(); }, [loadContext]); // Cleanup on unmount useEffect(() => { return () => { console.log('FileContext unmounting - cleaning up all resources'); cleanupAllFiles(); }; }, [cleanupAllFiles]); const contextValue: FileContextValue = { // State ...state, // Actions addFiles, removeFiles, replaceFile, clearAllFiles, setCurrentMode, setCurrentView, setCurrentTool, setSelectedFiles, setSelectedPages, updateProcessedFile, clearSelections, applyPageOperations, applyFileOperation, undoLastOperation, updateViewerConfig, setExportConfig, getFileById, getProcessedFileById, getCurrentFile, getCurrentProcessedFile, saveContext, loadContext, resetContext, // Operation history management recordOperation, markOperationApplied, markOperationFailed, getFileHistory, getAppliedOperations, clearFileHistory, // Navigation guard system setHasUnsavedChanges, requestNavigation, confirmNavigation, cancelNavigation, // Memory management trackBlobUrl, trackPdfDocument, cleanupFile, scheduleCleanup }; return ( {children} ); } // Custom hook to use the context export function useFileContext(): FileContextValue { const context = useContext(FileContext); if (!context) { throw new Error('useFileContext must be used within a FileContextProvider'); } return context; } // Helper hooks for specific aspects export function useCurrentFile() { const { getCurrentFile, getCurrentProcessedFile } = useFileContext(); return { file: getCurrentFile(), processedFile: getCurrentProcessedFile() }; } export function useFileSelection() { const { selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages, clearSelections } = useFileContext(); return { selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages, clearSelections }; } export function useViewerState() { const { viewerConfig, updateViewerConfig } = useFileContext(); return { config: viewerConfig, updateConfig: updateViewerConfig }; }