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 { 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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user