From bfba3baf7e6b28c1dd060bb3eea7f0e60cca13ae Mon Sep 17 00:00:00 2001 From: pac7 <47831526-pac7@users.noreply.replit.com> Date: Wed, 8 Oct 2025 19:27:31 +0000 Subject: [PATCH] Improve component stability and user experience with safety checks Implement robust error handling, safety checks for data structures, and state management improvements across various components to prevent runtime errors and enhance user experience. Replit-Commit-Author: Agent Replit-Commit-Session-Id: a71e826a-1d38-4b6e-a34f-fbf5ba1f1b25 Replit-Commit-Checkpoint-Type: intermediate_checkpoint --- .replit | 4 + ...rs-for-sev-1759950616776_1759950616777.txt | 47 ++++++++++++ .../history/EntityHistoryTimeline.tsx | 21 ++++- .../moderation/EntityEditPreview.tsx | 76 +++++++++++++++---- src/components/moderation/PhotoModal.tsx | 18 ++++- src/components/search/AutocompleteSearch.tsx | 7 +- src/components/ui/sidebar-context.ts | 13 ++++ src/components/ui/sidebar.tsx | 29 ++----- .../upload/EntityMultiImageUploader.tsx | 50 ------------ src/hooks/useEntityVersions.ts | 43 ++++++++--- src/hooks/useSidebar.ts | 11 +++ src/lib/imageUploadHelper.ts | 15 ++-- 12 files changed, 224 insertions(+), 110 deletions(-) create mode 100644 attached_assets/Pasted--Critical-Issues-1-Hot-Module-Reload-HMR-Failures-Your-browser-console-shows-HMR-errors-for-sev-1759950616776_1759950616777.txt create mode 100644 src/components/ui/sidebar-context.ts create mode 100644 src/hooks/useSidebar.ts diff --git a/.replit b/.replit index fc81a45d..7ad070ef 100644 --- a/.replit +++ b/.replit @@ -33,3 +33,7 @@ outputType = "webview" [[ports]] localPort = 5000 externalPort = 80 + +[[ports]] +localPort = 45171 +externalPort = 3000 diff --git a/attached_assets/Pasted--Critical-Issues-1-Hot-Module-Reload-HMR-Failures-Your-browser-console-shows-HMR-errors-for-sev-1759950616776_1759950616777.txt b/attached_assets/Pasted--Critical-Issues-1-Hot-Module-Reload-HMR-Failures-Your-browser-console-shows-HMR-errors-for-sev-1759950616776_1759950616777.txt new file mode 100644 index 00000000..36f8cb07 --- /dev/null +++ b/attached_assets/Pasted--Critical-Issues-1-Hot-Module-Reload-HMR-Failures-Your-browser-console-shows-HMR-errors-for-sev-1759950616776_1759950616777.txt @@ -0,0 +1,47 @@ +🔴 Critical Issues +1. Hot Module Reload (HMR) Failures +Your browser console shows HMR errors for several components: + +ManufacturerRides.tsx and ManufacturerModels.tsx +Version-related components (VersionIndicator, VersionComparisonDialog, EntityVersionHistory) +Impact: During development, changes to these files won't refresh automatically, requiring full page reloads. + +2. Fast Refresh Incompatibility +Two exports are breaking React Fast Refresh: + +useSidebar in src/components/ui/sidebar.tsx +uploadPendingImages in src/components/upload/EntityMultiImageUploader.tsx +Impact: Components using these will cause full page reloads on changes instead of hot updates. + +⚠️ High Priority Issues +3. Potential Race Conditions +useEntityVersions hook: Uses a request counter but could still have timing issues when multiple requests are in flight +useModerationQueue hook: Lacks explicit request cancellation, could show stale data if network is slow +Impact: Users might see outdated information or experience data inconsistencies. + +4. Memory Leaks +PhotoUpload component: Object URLs might not be revoked if upload errors occur +useEntityVersions hook: Supabase realtime subscriptions might not cleanup properly on unmount +useAuth hook: Timeouts for Novu updates might not be cleared when component unmounts +Impact: Memory usage grows over time, eventually slowing down or crashing the browser. + +5. Missing Null/Undefined Safety Checks +PhotoModal: Accessing currentPhoto without checking if photos array is empty +EntityHistoryTimeline: Accessing event config without verifying it exists +useEntityVersions: Assumes Supabase data is always an array +EntityEditPreview: Accessing nested image data without structure validation +Impact: Runtime errors that crash components when data is missing. + +🟡 Medium Priority Issues +6. Inconsistent Error Handling +Error handling varies across components: + +Some use console.error() (silent for users) +Others use toast.error() (visible notifications) +File upload errors don't show which specific file failed +Impact: Users don't always know when something went wrong. + +7. State Management Issues +useSearch hook: Uses JSON.stringify() for memoization which fails with functions/Symbols +AutocompleteSearch: Search dropdown state not properly tied to loading state +EntityEditPreview: Deep object comparison issues for detecting changes \ No newline at end of file diff --git a/src/components/history/EntityHistoryTimeline.tsx b/src/components/history/EntityHistoryTimeline.tsx index 0b4db2fe..c57299b8 100644 --- a/src/components/history/EntityHistoryTimeline.tsx +++ b/src/components/history/EntityHistoryTimeline.tsx @@ -26,6 +26,9 @@ const eventTypeConfig: Record {sortedEvents.map((event, index) => { - const config = eventTypeConfig[event.type]; + // Safety check: verify event.type exists in eventTypeConfig, use fallback if not + const config = event.type && eventTypeConfig[event.type] + ? eventTypeConfig[event.type] + : defaultEventConfig; const Icon = config.icon; return ( @@ -105,14 +111,25 @@ export function EntityHistoryTimeline({ events, entityName }: EntityHistoryTimel } function formatEventDate(dateString: string): string { + // Safety check: validate dateString exists and is a string + if (!dateString || typeof dateString !== 'string') { + return 'Unknown date'; + } + try { // Handle year-only dates if (/^\d{4}$/.test(dateString)) { return dateString; } - // Handle full dates + // Validate date string before creating Date object const date = new Date(dateString); + + // Check if date is valid + if (isNaN(date.getTime())) { + return dateString; + } + return format(date, 'MMMM d, yyyy'); } catch { return dateString; diff --git a/src/components/moderation/EntityEditPreview.tsx b/src/components/moderation/EntityEditPreview.tsx index 6a522501..74313505 100644 --- a/src/components/moderation/EntityEditPreview.tsx +++ b/src/components/moderation/EntityEditPreview.tsx @@ -11,6 +11,36 @@ interface EntityEditPreviewProps { entityName?: string; } +/** + * Deep equality check for detecting changes in nested objects/arrays + */ +const deepEqual = (a: any, b: any): boolean => { + // Handle null/undefined cases + if (a === b) return true; + if (a == null || b == null) return false; + if (typeof a !== typeof b) return false; + + // Handle primitives and functions + if (typeof a !== 'object') return a === b; + + // Handle arrays + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item, index) => deepEqual(item, b[index])); + } + + // One is array, other is not + if (Array.isArray(a) !== Array.isArray(b)) return false; + + // Handle objects + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) return false; + + return keysA.every(key => deepEqual(a[key], b[key])); +}; + interface ImageAssignments { uploaded: Array<{ url: string; @@ -51,7 +81,10 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti .eq('submission_id', submissionId) .order('order_index', { ascending: true }); - if (error) throw error; + if (error) { + console.error('EntityEditPreview.fetchSubmissionItems: Failed to fetch submission items:', error); + throw error; + } if (items && items.length > 0) { const firstItem = items[0]; @@ -75,21 +108,35 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti if (data.images) { const images: ImageAssignments = data.images; + // Safety check: verify uploaded array exists and is valid + if (!images.uploaded || !Array.isArray(images.uploaded)) { + // Invalid images data structure, skip image processing + return; + } + // Extract banner image if (images.banner_assignment !== null && images.banner_assignment !== undefined) { - const bannerImg = images.uploaded[images.banner_assignment]; - if (bannerImg) { - setBannerImageUrl(bannerImg.url); - changed.push('banner_image'); + // Safety check: verify index is within bounds + if (images.banner_assignment >= 0 && images.banner_assignment < images.uploaded.length) { + const bannerImg = images.uploaded[images.banner_assignment]; + // Validate nested image data + if (bannerImg && bannerImg.url) { + setBannerImageUrl(bannerImg.url); + changed.push('banner_image'); + } } } // Extract card image if (images.card_assignment !== null && images.card_assignment !== undefined) { - const cardImg = images.uploaded[images.card_assignment]; - if (cardImg) { - setCardImageUrl(cardImg.url); - changed.push('card_image'); + // Safety check: verify index is within bounds + if (images.card_assignment >= 0 && images.card_assignment < images.uploaded.length) { + const cardImg = images.uploaded[images.card_assignment]; + // Validate nested image data + if (cardImg && cardImg.url) { + setCardImageUrl(cardImg.url); + changed.push('card_image'); + } } } } @@ -99,9 +146,12 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti const originalData = firstItem.original_data as any; const excludeFields = ['images', 'updated_at', 'created_at']; Object.keys(data).forEach(key => { - if (!excludeFields.includes(key) && - data[key] !== originalData[key]) { - changed.push(key); + if (!excludeFields.includes(key)) { + // Use deep equality check for objects and arrays + const isEqual = deepEqual(data[key], originalData[key]); + if (!isEqual) { + changed.push(key); + } } }); } @@ -109,7 +159,7 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti setChangedFields(changed); } } catch (error) { - console.error('Error fetching submission items:', error); + console.error('EntityEditPreview.fetchSubmissionItems: Error fetching submission items:', error); } finally { setLoading(false); } diff --git a/src/components/moderation/PhotoModal.tsx b/src/components/moderation/PhotoModal.tsx index 2db85d5a..5a5ee770 100644 --- a/src/components/moderation/PhotoModal.tsx +++ b/src/components/moderation/PhotoModal.tsx @@ -23,7 +23,19 @@ export function PhotoModal({ photos, initialIndex, isOpen, onClose }: PhotoModal const [touchEnd, setTouchEnd] = useState(null); const imageRef = useRef(null); - const currentPhoto = photos[currentIndex]; + // Safety check: ensure photos array exists and is not empty + if (!photos || photos.length === 0) { + return null; + } + + // Clamp currentIndex to valid bounds + const safeIndex = Math.max(0, Math.min(currentIndex, photos.length - 1)); + const currentPhoto = photos[safeIndex]; + + // Early return if currentPhoto is undefined + if (!currentPhoto) { + return null; + } // Minimum swipe distance (in px) const minSwipeDistance = 50; @@ -100,7 +112,7 @@ export function PhotoModal({ photos, initialIndex, isOpen, onClose }: PhotoModal )} {photos.length > 1 && ( - {currentIndex + 1} of {photos.length} + {safeIndex + 1} of {photos.length} )} @@ -111,7 +123,7 @@ export function PhotoModal({ photos, initialIndex, isOpen, onClose }: PhotoModal - {isOpen && displayItems.length > 0 && ( + {isOpen && (displayItems.length > 0 || loading) && ( {query.length === 0 && showRecentSearches && suggestions.length > 0 && ( @@ -316,7 +316,7 @@ export function AutocompleteSearch({ > )} - {displayItems.map((item, index) => ( + {displayItems.length > 0 && displayItems.map((item, index) => ( handleResultClick(item)} @@ -364,10 +364,11 @@ export function AutocompleteSearch({ {loading && ( + Searching... )} - {query.length > 0 && results.length > 0 && ( + {query.length > 0 && results.length > 0 && !loading && ( <> void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +export const SidebarContext = createContext(null); diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index beb1a591..502bfcca 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -11,6 +11,8 @@ import { Separator } from "@/components/ui/separator"; import { Sheet, SheetContent } from "@/components/ui/sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { SidebarContext, type SidebarContext as SidebarContextType } from "@/components/ui/sidebar-context"; +import { useSidebar } from "@/hooks/useSidebar"; const SIDEBAR_COOKIE_NAME = "sidebar:state"; const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; @@ -19,27 +21,6 @@ const SIDEBAR_WIDTH_MOBILE = "18rem"; const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_KEYBOARD_SHORTCUT = "b"; -type SidebarContext = { - state: "expanded" | "collapsed"; - open: boolean; - setOpen: (open: boolean) => void; - openMobile: boolean; - setOpenMobile: (open: boolean) => void; - isMobile: boolean; - toggleSidebar: () => void; -}; - -const SidebarContext = React.createContext(null); - -function useSidebar() { - const context = React.useContext(SidebarContext); - if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider."); - } - - return context; -} - const SidebarProvider = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & { @@ -92,7 +73,7 @@ const SidebarProvider = React.forwardRef< // This makes it easier to style the sidebar with Tailwind classes. const state = open ? "expanded" : "collapsed"; - const contextValue = React.useMemo( + const contextValue = React.useMemo( () => ({ state, open, @@ -633,5 +614,7 @@ export { SidebarRail, SidebarSeparator, SidebarTrigger, - useSidebar, }; + +// Re-export useSidebar from the hooks file for backwards compatibility +export { useSidebar } from "@/hooks/useSidebar"; diff --git a/src/components/upload/EntityMultiImageUploader.tsx b/src/components/upload/EntityMultiImageUploader.tsx index bc4f67be..f71c98a3 100644 --- a/src/components/upload/EntityMultiImageUploader.tsx +++ b/src/components/upload/EntityMultiImageUploader.tsx @@ -393,53 +393,3 @@ export function EntityMultiImageUploader({ ); } - -// Helper function to upload all local files -export async function uploadPendingImages(images: UploadedImage[]): Promise { - const uploadedImages: UploadedImage[] = []; - - for (const image of images) { - if (image.isLocal && image.file) { - try { - // Get upload URL from Cloudflare - const { data: uploadData, error: uploadError } = await fetch('/api/upload-image', { - method: 'POST', - }).then(res => res.json()); - - if (uploadError) { - throw new Error('Failed to get upload URL'); - } - - // Upload to Cloudflare - const formData = new FormData(); - formData.append('file', image.file); - - const uploadResponse = await fetch(uploadData.uploadURL, { - method: 'POST', - body: formData, - }); - - if (!uploadResponse.ok) { - throw new Error('Failed to upload image'); - } - - const result = await uploadResponse.json(); - - // Return uploaded image with Cloudflare data - uploadedImages.push({ - url: result.result.variants[0], - cloudflare_id: result.result.id, - caption: image.caption, - }); - } catch (error) { - console.error('Error uploading image:', error); - throw error; - } - } else { - // Already uploaded, keep as is - uploadedImages.push(image); - } - } - - return uploadedImages; -} diff --git a/src/hooks/useEntityVersions.ts b/src/hooks/useEntityVersions.ts index 290ae63e..4820d71c 100644 --- a/src/hooks/useEntityVersions.ts +++ b/src/hooks/useEntityVersions.ts @@ -67,8 +67,18 @@ export function useEntityVersions(entityType: string, entityId: string) { // Only continue if this is still the latest request if (currentRequestId !== requestCounterRef.current) return; + // Safety check: verify data is an array before processing + if (!Array.isArray(data)) { + if (isMountedRef.current && currentRequestId === requestCounterRef.current) { + setVersions([]); + setCurrentVersion(null); + setLoading(false); + } + return; + } + // Fetch profiles separately - const userIds = [...new Set(data?.map(v => v.changed_by).filter(Boolean) || [])]; + const userIds = [...new Set(data.map(v => v.changed_by).filter(Boolean))]; const { data: profiles } = await supabase .from('profiles') .select('user_id, username, avatar_url') @@ -77,8 +87,11 @@ export function useEntityVersions(entityType: string, entityId: string) { // Check again if this is still the latest request if (currentRequestId !== requestCounterRef.current) return; - const versionsWithProfiles = data?.map(v => { - const profile = profiles?.find(p => p.user_id === v.changed_by); + // Safety check: verify profiles array exists before filtering + const profilesArray = Array.isArray(profiles) ? profiles : []; + + const versionsWithProfiles = data.map(v => { + const profile = profilesArray.find(p => p.user_id === v.changed_by); return { ...v, changer_profile: profile || { @@ -90,14 +103,16 @@ export function useEntityVersions(entityType: string, entityId: string) { // Only update state if component is still mounted and this is still the latest request if (isMountedRef.current && currentRequestId === requestCounterRef.current) { - setVersions(versionsWithProfiles || []); - setCurrentVersion(versionsWithProfiles?.find(v => v.is_current) || null); + setVersions(versionsWithProfiles); + setCurrentVersion(versionsWithProfiles.find(v => v.is_current) || null); setLoading(false); } } catch (error: any) { console.error('Error fetching versions:', error); if (isMountedRef.current) { - toast.error('Failed to load version history'); + // Safe error message access with fallback + const errorMessage = error?.message || 'Failed to load version history'; + toast.error(errorMessage); setLoading(false); } } @@ -114,12 +129,15 @@ export function useEntityVersions(entityType: string, entityId: string) { if (error) throw error; if (isMountedRef.current) { - setFieldHistory(data as FieldChange[] || []); + // Safety check: ensure data is an array + const fieldChanges = Array.isArray(data) ? data as FieldChange[] : []; + setFieldHistory(fieldChanges); } } catch (error: any) { console.error('Error fetching field history:', error); if (isMountedRef.current) { - toast.error('Failed to load field history'); + const errorMessage = error?.message || 'Failed to load field history'; + toast.error(errorMessage); } } }; @@ -137,7 +155,8 @@ export function useEntityVersions(entityType: string, entityId: string) { } catch (error: any) { console.error('Error comparing versions:', error); if (isMountedRef.current) { - toast.error('Failed to compare versions'); + const errorMessage = error?.message || 'Failed to compare versions'; + toast.error(errorMessage); } return null; } @@ -166,7 +185,8 @@ export function useEntityVersions(entityType: string, entityId: string) { } catch (error: any) { console.error('Error rolling back version:', error); if (isMountedRef.current) { - toast.error('Failed to rollback version'); + const errorMessage = error?.message || 'Failed to rollback version'; + toast.error(errorMessage); } return null; } @@ -195,7 +215,8 @@ export function useEntityVersions(entityType: string, entityId: string) { } catch (error: any) { console.error('Error creating version:', error); if (isMountedRef.current) { - toast.error('Failed to create version'); + const errorMessage = error?.message || 'Failed to create version'; + toast.error(errorMessage); } return null; } diff --git a/src/hooks/useSidebar.ts b/src/hooks/useSidebar.ts new file mode 100644 index 00000000..035997ea --- /dev/null +++ b/src/hooks/useSidebar.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +import { SidebarContext } from "@/components/ui/sidebar-context"; + +export function useSidebar() { + const context = useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} diff --git a/src/lib/imageUploadHelper.ts b/src/lib/imageUploadHelper.ts index 5c3549f9..9f58abba 100644 --- a/src/lib/imageUploadHelper.ts +++ b/src/lib/imageUploadHelper.ts @@ -23,13 +23,16 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise => { if (image.isLocal && image.file) { + const fileName = image.file.name; + // Step 1: Get upload URL from our Supabase Edge Function const { data: uploadUrlData, error: urlError } = await supabase.functions.invoke('upload-image', { body: { action: 'get-upload-url' } }); if (urlError || !uploadUrlData?.uploadURL) { - throw new Error(`Failed to get upload URL for image ${index + 1}: ${urlError?.message || 'Unknown error'}`); + console.error(`imageUploadHelper.uploadPendingImages: Failed to get upload URL for "${fileName}":`, urlError); + throw new Error(`Failed to get upload URL for "${fileName}": ${urlError?.message || 'Unknown error'}`); } // Step 2: Upload file directly to Cloudflare @@ -43,13 +46,15 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise 0) { if (newlyUploadedImageIds.length > 0) { - console.error(`Some uploads failed. Cleaning up ${newlyUploadedImageIds.length} newly uploaded images...`); + console.error(`imageUploadHelper.uploadPendingImages: Some uploads failed. Cleaning up ${newlyUploadedImageIds.length} newly uploaded images...`); // Attempt cleanup in parallel but don't throw if it fails await Promise.allSettled( @@ -107,7 +112,7 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise { - console.error(`Failed to cleanup image ${imageId}:`, cleanupError); + console.error(`imageUploadHelper.uploadPendingImages: Failed to cleanup image ${imageId}:`, cleanupError); }) ) );
- {currentIndex + 1} of {photos.length} + {safeIndex + 1} of {photos.length}