/** * 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, useMemo } from 'react'; import { FileContextState, FileContextProviderProps, FileContextSelectors, FileContextStateValue, FileContextActionsValue, FileContextActions, FileContextAction, ModeType, FileId, FileRecord, toFileRecord, revokeFileResources, createFileId, computeContentHash } from '../types/fileContext'; // Import real services import { EnhancedPDFProcessingService } from '../services/enhancedPDFProcessingService'; import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; import { fileStorage } from '../services/fileStorage'; // Get service instances const enhancedPDFProcessingService = EnhancedPDFProcessingService.getInstance(); // Initial state const initialFileContextState: FileContextState = { files: { ids: [], byId: {} }, ui: { currentMode: 'pageEditor' as ModeType, selectedFileIds: [], selectedPageNumbers: [], isProcessing: false, processingProgress: 0, hasUnsavedChanges: false, pendingNavigation: null, showNavigationWarning: false } }; // Reducer function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState { switch (action.type) { case 'ADD_FILES': { const { fileRecords } = action.payload; const newIds: FileId[] = []; const newById: Record = { ...state.files.byId }; fileRecords.forEach(record => { // Only add if not already present (dedupe by stable ID) if (!newById[record.id]) { 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; // Immutable merge supports all FileRecord fields including contentHash, hashStatus return { ...state, files: { ...state.files, byId: { ...state.files.byId, [id]: { ...existingRecord, ...updates } } } }; } case 'SET_CURRENT_MODE': { return { ...state, ui: { ...state.ui, currentMode: action.payload } }; } 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 || 0 } }; } 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 '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); // Legacy context for backward compatibility const FileContext = createContext(undefined); // Provider component export function FileContextProvider({ children, enableUrlSync = true, enablePersistence = true }: FileContextProviderProps) { const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState); // File ref map - stores File objects outside React state const filesRef = useRef>(new Map()); // Cleanup timers and refs const cleanupTimers = useRef>(new Map()); const blobUrls = useRef>(new Set()); const pdfDocuments = useRef>(new Map()); // Stable state reference for selectors const stateRef = useRef(state); stateRef.current = state; // Stable selectors (memoized once to avoid re-renders) const selectors = useMemo(() => ({ getFile: (id: FileId) => filesRef.current.get(id), getFiles: (ids?: FileId[]) => { const currentIds = ids || stateRef.current.files.ids; return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[]; }, getFileRecord: (id: FileId) => stateRef.current.files.byId[id], getFileRecords: (ids?: FileId[]) => { const currentIds = ids || stateRef.current.files.ids; return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean); }, getAllFileIds: () => stateRef.current.files.ids, getSelectedFiles: () => { return stateRef.current.ui.selectedFileIds .map(id => filesRef.current.get(id)) .filter(Boolean) as File[]; }, getSelectedFileRecords: () => { return stateRef.current.ui.selectedFileIds .map(id => stateRef.current.files.byId[id]) .filter(Boolean); }, // Stable signature for effects - prevents unnecessary re-renders getFilesSignature: () => { return stateRef.current.files.ids .map(id => { const record = stateRef.current.files.byId[id]; return record ? `${id}:${record.size}:${record.lastModified}` : ''; }) .filter(Boolean) .join('|'); } }), []); // Empty dependency array - selectors are now stable // Centralized memory management const trackBlobUrl = useCallback((url: string) => { blobUrls.current.add(url); }, []); const trackPdfDocument = useCallback((fileId: string, pdfDoc: any) => { // 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); } } 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 (only blob: scheme) blobUrls.current.forEach(url => { if (url.startsWith('blob:')) { 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 as any).gc) { let gc = (window as any).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 = window.setTimeout(() => { cleanupFile(fileId); }, delay); cleanupTimers.current.set(fileId, timer); }, [cleanupFile]); // Action implementations const addFiles = useCallback(async (files: File[]): Promise => { // Generate UUID-based IDs and create records const fileRecords: FileRecord[] = []; const addedFiles: File[] = []; for (const file of files) { const fileId = createFileId(); // UUID-based, zero collisions // Store File in ref map filesRef.current.set(fileId, file); // Create record with pending hash status const record = toFileRecord(file, fileId); record.hashStatus = 'pending'; fileRecords.push(record); addedFiles.push(file); // Optional: Persist to IndexedDB if enabled if (enablePersistence) { try { // Generate thumbnail and store in IndexedDB with our UUID import('../utils/thumbnailUtils').then(({ generateThumbnailForFile }) => { return generateThumbnailForFile(file); }).then(thumbnail => { return fileStorage.storeFile(file, fileId, thumbnail); }).then(() => { console.log('File persisted to IndexedDB:', fileId); }).catch(error => { console.warn('Failed to persist file to IndexedDB:', error); }); } catch (error) { console.warn('Failed to initiate file persistence:', error); } } // Start async content hashing (don't block add operation) computeContentHash(file).then(contentHash => { // Only update if file still exists in context if (filesRef.current.has(fileId)) { updateFileRecord(fileId, { contentHash: contentHash || undefined, // Convert null to undefined hashStatus: contentHash ? 'completed' : 'failed' }); } }).catch(() => { // Hash failed, update status if file still exists if (filesRef.current.has(fileId)) { updateFileRecord(fileId, { hashStatus: 'failed' }); } }); } // Only dispatch if we have new files if (fileRecords.length > 0) { dispatch({ type: 'ADD_FILES', payload: { fileRecords } }); } // Return only the newly added files return addedFiles; }, [enablePersistence]); // Include enablePersistence for persistence logic const removeFiles = useCallback((fileIds: FileId[], deleteFromStorage: boolean = true) => { // Clean up Files from ref map first fileIds.forEach(fileId => { filesRef.current.delete(fileId); cleanupFile(fileId); }); // Update state 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 updateFileRecord = useCallback((id: FileId, updates: Partial) => { // Ensure immutable merge by dispatching action dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id, updates } }); }, []); // Navigation guard system functions const setHasUnsavedChanges = useCallback((hasChanges: boolean) => { dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }); }, []); const requestNavigation = useCallback((navigationFn: () => void): boolean => { // Use stateRef to avoid stale closure issues with rapid state changes if (stateRef.current.ui.hasUnsavedChanges) { dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } }); dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } }); return false; } else { navigationFn(); return true; } }, []); // No dependencies - uses stateRef for current state const confirmNavigation = useCallback(() => { // Use stateRef to get current navigation function if (stateRef.current.ui.pendingNavigation) { stateRef.current.ui.pendingNavigation(); dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); } dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } }); }, []); // No dependencies - uses stateRef const cancelNavigation = useCallback(() => { dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } }); }, []); // Memoized actions to prevent re-renders const actions = useMemo(() => ({ addFiles, removeFiles, updateFileRecord, clearAllFiles: () => { cleanupAllFiles(); filesRef.current.clear(); dispatch({ type: 'RESET_CONTEXT' }); }, setCurrentMode: (mode: ModeType) => dispatch({ type: 'SET_CURRENT_MODE', payload: mode }), setSelectedFiles: (fileIds: FileId[]) => dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } }), setSelectedPages: (pageNumbers: number[]) => dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } }), clearSelections: () => dispatch({ type: 'CLEAR_SELECTIONS' }), setProcessing: (isProcessing: boolean, progress = 0) => dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }), setHasUnsavedChanges, resetContext: () => { cleanupAllFiles(); filesRef.current.clear(); dispatch({ type: 'RESET_CONTEXT' }); }, // Legacy compatibility setMode: (mode: ModeType) => dispatch({ type: 'SET_CURRENT_MODE', payload: mode }), confirmNavigation, cancelNavigation }), [addFiles, removeFiles, cleanupAllFiles, setHasUnsavedChanges, confirmNavigation, cancelNavigation]); // Split context values to minimize re-renders const stateValue = useMemo(() => ({ state, selectors }), [state]); // selectors are now stable, no need to depend on them const actionsValue = useMemo(() => ({ actions, dispatch }), [actions]); // Legacy context value for backward compatibility const legacyContextValue = useMemo(() => ({ ...state, ...state.ui, // Action compatibility layer addFiles, removeFiles, updateFileRecord, clearAllFiles: actions.clearAllFiles, setCurrentMode: actions.setCurrentMode, setSelectedFiles: actions.setSelectedFiles, setSelectedPages: actions.setSelectedPages, clearSelections: actions.clearSelections, setHasUnsavedChanges, requestNavigation, confirmNavigation, cancelNavigation, trackBlobUrl, trackPdfDocument, cleanupFile, scheduleCleanup, // Missing operation functions (stubs) recordOperation: () => { console.warn('recordOperation is deprecated'); }, markOperationApplied: () => { console.warn('markOperationApplied is deprecated'); }, markOperationFailed: () => { console.warn('markOperationFailed is deprecated'); }, // Computed properties that components expect get activeFiles() { return selectors.getFiles(); }, // Getter to avoid creating new arrays on every render // Selectors ...selectors }), [state, actions, addFiles, removeFiles, updateFileRecord, setHasUnsavedChanges, requestNavigation, confirmNavigation, cancelNavigation, trackBlobUrl, trackPdfDocument, cleanupFile, scheduleCleanup]); // Removed selectors dependency // Cleanup on unmount useEffect(() => { return () => { console.log('FileContext unmounting - cleaning up all resources'); cleanupAllFiles(); }; }, [cleanupAllFiles]); return ( {children} ); } // New hooks for split contexts (prevent unnecessary re-renders) export function useFileState() { const context = useContext(FileStateContext); if (!context) { throw new Error('useFileState must be used within a FileContextProvider'); } return context; } export function useFileActions() { const context = useContext(FileActionsContext); if (!context) { throw new Error('useFileActions must be used within a FileContextProvider'); } return context; } // Legacy hook for backward compatibility export function useFileContext(): any { 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 { state, selectors } = useFileState(); const primaryFileId = state.files.ids[0]; return useMemo(() => ({ file: primaryFileId ? selectors.getFile(primaryFileId) : undefined, record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined }), [primaryFileId]); // selectors are stable, don't depend on them } export function useFileSelection() { const { state } = useFileState(); const { actions } = useFileActions(); return { selectedFileIds: state.ui.selectedFileIds, selectedPageNumbers: state.ui.selectedPageNumbers, setSelectedFiles: actions.setSelectedFiles, setSelectedPages: actions.setSelectedPages, clearSelections: actions.clearSelections }; } // Legacy compatibility hooks - provide stubs for removed functionality export function useToolFileSelection() { const { state, selectors } = useFileState(); const { actions } = useFileActions(); // Memoize selectedFiles to avoid recreating arrays const selectedFiles = useMemo(() => { return selectors.getSelectedFiles(); }, [state.ui.selectedFileIds]); // selectors are stable, don't depend on them return useMemo(() => ({ selectedFileIds: state.ui.selectedFileIds, selectedPageNumbers: state.ui.selectedPageNumbers, selectedFiles, // Now stable - only changes when selectedFileIds actually change setSelectedFiles: actions.setSelectedFiles, setSelectedPages: actions.setSelectedPages, clearSelections: actions.clearSelections, // Tool-specific properties that components expect maxFiles: 10, // Default value isToolMode: true, setMaxFiles: (maxFiles: number) => { console.log('setMaxFiles called with:', maxFiles); }, // Stub with proper signature setIsToolMode: (isToolMode: boolean) => { console.log('setIsToolMode called with:', isToolMode); } // Stub with proper signature }), [selectedFiles, state.ui.selectedFileIds, state.ui.selectedPageNumbers, actions]); } export function useProcessedFiles() { const { state, selectors } = useFileState(); // Create a Map-like interface for backward compatibility const compatibilityMap = { size: state.files.ids.length, get: (file: File) => { console.warn('useProcessedFiles.get is deprecated - File objects no longer have stable IDs'); return null; }, has: (file: File) => { console.warn('useProcessedFiles.has is deprecated - File objects no longer have stable IDs'); return false; }, set: () => { console.warn('processedFiles.set is deprecated - use FileRecord updates instead'); } }; return { processedFiles: compatibilityMap, // Map-like interface for backward compatibility getProcessedFile: (file: File) => { console.warn('getProcessedFile is deprecated - File objects no longer have stable IDs'); return null; }, updateProcessedFile: () => { console.warn('updateProcessedFile is deprecated - processed files are now stored in FileRecord'); } }; } export function useFileManagement() { const { actions } = useFileActions(); return { addFiles: actions.addFiles, removeFiles: actions.removeFiles, clearAllFiles: actions.clearAllFiles }; }