perf(ui): eliminate flash with React.memo, useMemo, and optimized polling

This commit is contained in:
4NK IA 2025-09-17 16:36:02 +00:00
parent adb33507bc
commit 4bbd914a4a
2 changed files with 135 additions and 111 deletions

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react'
import React, { useEffect, useRef, useMemo } from 'react'
import { AppBar, Toolbar, Typography, Container, Box, LinearProgress } from '@mui/material'
import { useNavigate, useLocation } from 'react-router-dom'
import { NavigationTabs } from './NavigationTabs'
@ -66,9 +66,15 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
isProcessingQueue.current = false
}
// Mémoriser la liste des documents pour éviter les re-renders inutiles
const memoizedDocuments = useMemo(() => {
console.log(`📋 [LAYOUT] Recalcul de la liste des documents: ${documents.length}`)
return documents
}, [documents])
useEffect(() => {
console.log(`📋 [LAYOUT] ${documents.length} documents détectés`)
documents.forEach((doc) => {
console.log(`📋 [LAYOUT] ${memoizedDocuments.length} documents détectés`)
memoizedDocuments.forEach((doc) => {
// Vérifications plus strictes pour éviter les doublons
const hasExtraction = extractionById[doc.id]
const isProcessed = processedDocs.current.has(doc.id)

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState, useMemo, memo } from 'react'
import { useDropzone } from 'react-dropzone'
import {
Box,
@ -30,7 +30,7 @@ import {
Visibility,
Description,
Image,
PictureAsPdf,
TextSnippet,
FolderOpen,
Add as AddIcon,
ContentCopy,
@ -39,18 +39,130 @@ import { useAppDispatch, useAppSelector } from '../store'
import {
uploadFileToFolderThunk,
loadFolderResults,
removeDocument,
setCurrentFolderHash,
} from '../store/documentSlice'
import { Layout } from '../components/Layout'
import { FilePreview } from '../components/FilePreview'
import type { Document } from '../types'
// Composant mémorisé pour les items de la liste
const DocumentListItem = memo(({ doc, index, onPreview, totalCount }: {
doc: Document,
index: number,
onPreview: (doc: Document) => void,
totalCount: number
}) => {
const getFileIcon = (mimeType: string) => {
if (mimeType.startsWith('image/')) return <Image />
if (mimeType === 'application/pdf') return <Description />
if (mimeType.startsWith('text/')) return <TextSnippet />
return <Description />
}
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircle color="success" />
case 'processing':
return <HourglassEmpty color="warning" />
case 'error':
return <ErrorIcon color="error" />
default:
return <HourglassEmpty color="disabled" />
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'success'
case 'processing':
return 'warning'
case 'error':
return 'error'
default:
return 'default'
}
}
return (
<div key={`${doc.id}-${index}`}>
<ListItem>
<ListItemIcon>{getFileIcon(doc.mimeType)}</ListItemIcon>
<ListItemText
primary={
<Box>
<Box display="flex" alignItems="center" gap={1} mb={1}>
{getStatusIcon(doc.status)}
<Typography
variant="subtitle1"
sx={{
wordBreak: 'break-word',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
maxWidth: { xs: '200px', sm: '300px', md: '400px' },
}}
>
{(doc as any).displayName || doc.name}
</Typography>
</Box>
<Box display="flex" gap={1} flexWrap="wrap">
<Chip
label={doc.status}
size="small"
color={
getStatusColor(doc.status) as
| 'success'
| 'error'
| 'warning'
| 'default'
}
/>
{doc.mimeType && doc.mimeType !== 'application/octet-stream' && (
<Chip label={doc.mimeType} size="small" variant="outlined" />
)}
{doc.size > 0 && (
<Chip
label={`${(doc.size / 1024 / 1024).toFixed(2)} MB`}
size="small"
variant="outlined"
/>
)}
</Box>
</Box>
}
/>
<Box display="flex" gap={1} flexDirection={{ xs: 'column', sm: 'row' }}>
<Button
size="small"
startIcon={<Visibility />}
onClick={() => onPreview(doc)}
disabled={doc.status !== 'completed'}
fullWidth
>
Aperçu
</Button>
</Box>
</ListItem>
{index < totalCount - 1 && <Divider />}
</div>
)
})
export default function UploadView() {
const dispatch = useAppDispatch()
const { documents, error, currentFolderHash, currentFolderName, loading, bootstrapped } = useAppSelector((state) => state.document)
console.log('🏠 [UPLOAD_VIEW] Component loaded, documents count:', documents.length)
// Mémoriser la liste des documents pour éviter les re-renders inutiles
const memoizedDocuments = useMemo(() => {
console.log('🏠 [UPLOAD_VIEW] Recalcul de la liste des documents:', documents.length)
return documents
}, [documents])
console.log('🏠 [UPLOAD_VIEW] Component loaded, documents count:', memoizedDocuments.length)
const [previewDocument, setPreviewDocument] = useState<Document | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
@ -139,31 +251,6 @@ export default function UploadView() {
multiple: true,
})
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircle color="success" />
case 'error':
return <ErrorIcon color="error" />
case 'processing':
return <CircularProgress size={20} />
default:
return <HourglassEmpty color="action" />
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'success'
case 'error':
return 'error'
case 'processing':
return 'warning'
default:
return 'default'
}
}
// Bootstrap maintenant géré dans App.tsx
@ -190,11 +277,6 @@ export default function UploadView() {
fetchMetaIfNeeded()
}, [currentFolderHash, currentFolderName])
const getFileIcon = (mimeType: string) => {
if (mimeType.includes('pdf')) return <PictureAsPdf color="error" />
if (mimeType.includes('image')) return <Image color="primary" />
return <Description color="action" />
}
// Affichage anti-clignotement: tant que le bootstrap n'est pas terminé ou que le chargement est en cours,
// on affiche un spinner global au lieu d'alterner rapidement les états de liste vide / liste remplie.
@ -321,86 +403,22 @@ export default function UploadView() {
)}
{/* Liste des documents */}
{documents.length > 0 && (
{memoizedDocuments.length > 0 && (
<Box sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom>
Documents analysés ({documents.length})
Documents analysés ({memoizedDocuments.length})
</Typography>
<Card>
<List>
{documents.map((doc, index) => (
<div key={`${doc.id}-${index}`}>
<ListItem>
<ListItemIcon>{getFileIcon(doc.mimeType)}</ListItemIcon>
<ListItemText
primary={
<Box>
<Box display="flex" alignItems="center" gap={1} mb={1}>
{getStatusIcon(doc.status)}
<Typography
variant="subtitle1"
sx={{
wordBreak: 'break-word',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
maxWidth: { xs: '200px', sm: '300px', md: '400px' },
}}
>
{(doc as any).displayName || doc.name}
</Typography>
</Box>
<Box display="flex" gap={1} flexWrap="wrap">
<Chip
label={doc.status}
size="small"
color={
getStatusColor(doc.status) as
| 'success'
| 'error'
| 'warning'
| 'default'
}
/>
{doc.mimeType && doc.mimeType !== 'application/octet-stream' && (
<Chip label={doc.mimeType} size="small" variant="outlined" />
)}
{doc.size > 0 && (
<Chip
label={`${(doc.size / 1024 / 1024).toFixed(2)} MB`}
size="small"
variant="outlined"
/>
)}
</Box>
</Box>
}
/>
<Box display="flex" gap={1} flexDirection={{ xs: 'column', sm: 'row' }}>
<Button
size="small"
startIcon={<Visibility />}
onClick={() => setPreviewDocument(doc)}
disabled={doc.status !== 'completed'}
fullWidth
>
Aperçu
</Button>
<Button
size="small"
color="error"
onClick={() => dispatch(removeDocument(doc.id))}
fullWidth
>
Supprimer
</Button>
</Box>
</ListItem>
{index < documents.length - 1 && <Divider />}
</div>
{memoizedDocuments.map((doc, index) => (
<DocumentListItem
key={`${doc.id}-${index}`}
doc={doc}
index={index}
onPreview={setPreviewDocument}
totalCount={memoizedDocuments.length}
/>
))}
</List>
</Card>