perf(ui): eliminate flash with React.memo, useMemo, and optimized polling
This commit is contained in:
parent
adb33507bc
commit
4bbd914a4a
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user