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 { AppBar, Toolbar, Typography, Container, Box, LinearProgress } from '@mui/material'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { NavigationTabs } from './NavigationTabs' import { NavigationTabs } from './NavigationTabs'
@ -66,9 +66,15 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
isProcessingQueue.current = false 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(() => { useEffect(() => {
console.log(`📋 [LAYOUT] ${documents.length} documents détectés`) console.log(`📋 [LAYOUT] ${memoizedDocuments.length} documents détectés`)
documents.forEach((doc) => { memoizedDocuments.forEach((doc) => {
// Vérifications plus strictes pour éviter les doublons // Vérifications plus strictes pour éviter les doublons
const hasExtraction = extractionById[doc.id] const hasExtraction = extractionById[doc.id]
const isProcessed = processedDocs.current.has(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 { useDropzone } from 'react-dropzone'
import { import {
Box, Box,
@ -30,7 +30,7 @@ import {
Visibility, Visibility,
Description, Description,
Image, Image,
PictureAsPdf, TextSnippet,
FolderOpen, FolderOpen,
Add as AddIcon, Add as AddIcon,
ContentCopy, ContentCopy,
@ -39,18 +39,130 @@ import { useAppDispatch, useAppSelector } from '../store'
import { import {
uploadFileToFolderThunk, uploadFileToFolderThunk,
loadFolderResults, loadFolderResults,
removeDocument,
setCurrentFolderHash, setCurrentFolderHash,
} from '../store/documentSlice' } from '../store/documentSlice'
import { Layout } from '../components/Layout' import { Layout } from '../components/Layout'
import { FilePreview } from '../components/FilePreview' import { FilePreview } from '../components/FilePreview'
import type { Document } from '../types' 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() { export default function UploadView() {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { documents, error, currentFolderHash, currentFolderName, loading, bootstrapped } = useAppSelector((state) => state.document) 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 [previewDocument, setPreviewDocument] = useState<Document | null>(null)
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
@ -139,31 +251,6 @@ export default function UploadView() {
multiple: true, 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 // Bootstrap maintenant géré dans App.tsx
@ -190,11 +277,6 @@ export default function UploadView() {
fetchMetaIfNeeded() fetchMetaIfNeeded()
}, [currentFolderHash, currentFolderName]) }, [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, // 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. // 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 */} {/* Liste des documents */}
{documents.length > 0 && ( {memoizedDocuments.length > 0 && (
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Documents analysés ({documents.length}) Documents analysés ({memoizedDocuments.length})
</Typography> </Typography>
<Card> <Card>
<List> <List>
{documents.map((doc, index) => ( {memoizedDocuments.map((doc, index) => (
<div key={`${doc.id}-${index}`}> <DocumentListItem
<ListItem> key={`${doc.id}-${index}`}
<ListItemIcon>{getFileIcon(doc.mimeType)}</ListItemIcon> doc={doc}
<ListItemText index={index}
primary={ onPreview={setPreviewDocument}
<Box> totalCount={memoizedDocuments.length}
<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>
))} ))}
</List> </List>
</Card> </Card>