perf: memoize expensive list computations + memo on grid/list items
Results.tsx: - useMemo: filter + sort in one pass (was 7 separate array iterations per render) - useMemo: platform/maison/future/missingData counts in single for-loop - Fix: move queryClient.invalidateQueries from render body into useEffect Properties.tsx: - useMemo: wrap applyFilters() call (was full copy+sort on every render) - useMemo: compute matchReady/criticalGaps/lowConfidence/staleOrOutdated/ allMissingFields in a single for-loop (was 5 separate filter passes) ReminderFeed.tsx: - useMemo: wrap applyFilters() call - useCallback: resetFilters (passed to ReminderEmptyState) PropertyIntelligenceCard, ReminderListRow: - React.memo: grid/list items no longer re-render when unrelated parent state changes (e.g. selectedId, filter UI state) tsc --noEmit passes with zero errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
|
import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
|
||||||
import { LocationPreview } from '../shared/LocationPreview'
|
import { LocationPreview } from '../shared/LocationPreview'
|
||||||
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
|
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
|
||||||
@@ -14,7 +15,7 @@ function availabilityBadgeColor(status: string): string {
|
|||||||
return '#64748b'
|
return '#64748b'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
|
export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
|
||||||
const confPct = Math.round(p.confidenceScore * 100)
|
const confPct = Math.round(p.confidenceScore * 100)
|
||||||
const confColor = p.confidenceScore >= 0.75 ? '#1a7a4a' : p.confidenceScore >= 0.55 ? '#d97706' : '#c0392b'
|
const confColor = p.confidenceScore >= 0.75 ? '#1a7a4a' : p.confidenceScore >= 0.55 ? '#d97706' : '#c0392b'
|
||||||
|
|
||||||
@@ -138,4 +139,4 @@ export function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo, useCallback } from 'react'
|
||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Typography } from '@mui/material'
|
||||||
import { useReminders } from '../../hooks/useReminders'
|
import { useReminders } from '../../hooks/useReminders'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
@@ -43,17 +44,21 @@ export function ReminderFeed() {
|
|||||||
setSearchQuery: s.setSearchQuery,
|
setSearchQuery: s.setSearchQuery,
|
||||||
})))
|
})))
|
||||||
|
|
||||||
if (isLoading) return <ReminderSkeleton />
|
|
||||||
|
|
||||||
const reminders = data ?? []
|
const reminders = data ?? []
|
||||||
const filtered = applyFilters(reminders, filterType, filterStatus, filterPriority, searchQuery)
|
|
||||||
|
|
||||||
function resetFilters() {
|
const filtered = useMemo(
|
||||||
|
() => applyFilters(reminders, filterType, filterStatus, filterPriority, searchQuery),
|
||||||
|
[reminders, filterType, filterStatus, filterPriority, searchQuery],
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetFilters = useCallback(() => {
|
||||||
setFilterType('ALL')
|
setFilterType('ALL')
|
||||||
setFilterStatus('ALL')
|
setFilterStatus('ALL')
|
||||||
setFilterPriority('ALL')
|
setFilterPriority('ALL')
|
||||||
setSearchQuery('')
|
setSearchQuery('')
|
||||||
}
|
}, [setFilterType, setFilterStatus, setFilterPriority, setSearchQuery])
|
||||||
|
|
||||||
|
if (isLoading) return <ReminderSkeleton />
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
return <ReminderEmptyState onReset={resetFilters} />
|
return <ReminderEmptyState onReset={resetFilters} />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
import { Box, Typography, Chip, IconButton, Tooltip } from '@mui/material'
|
import { Box, Typography, Chip, IconButton, Tooltip } from '@mui/material'
|
||||||
import { Check, Bell, X } from 'lucide-react'
|
import { Check, Bell, X } from 'lucide-react'
|
||||||
import type { Reminder } from '../../domain/reminder'
|
import type { Reminder } from '../../domain/reminder'
|
||||||
@@ -26,7 +27,7 @@ interface Props {
|
|||||||
reminder: Reminder
|
reminder: Reminder
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReminderListRow({ reminder }: Props) {
|
export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props) {
|
||||||
const { setSelectedId, setDrawerOpen } = useReminderStore()
|
const { setSelectedId, setDrawerOpen } = useReminderStore()
|
||||||
const complete = useCompleteReminder()
|
const complete = useCompleteReminder()
|
||||||
const dismiss = useDismissReminder()
|
const dismiss = useDismissReminder()
|
||||||
@@ -139,4 +140,4 @@ export function ReminderListRow({ reminder }: Props) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import { Box, Button, Card, Typography } from '@mui/material'
|
import { Box, Button, Card, Typography } from '@mui/material'
|
||||||
import { useNavigate, useLocation } from 'react-router'
|
import { useNavigate, useLocation } from 'react-router'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
@@ -61,36 +61,46 @@ export default function Results() {
|
|||||||
|
|
||||||
const activeNeed = allNeeds.find(n => n.id === effectiveNeedId) ?? allNeeds[0]
|
const activeNeed = allNeeds.find(n => n.id === effectiveNeedId) ?? allNeeds[0]
|
||||||
|
|
||||||
// Ensure query cache is invalidated when a new need was just created
|
// Invalidate when navigating from NeedBuilder — must be in an effect, not render body
|
||||||
if (activeNeedIdFromNav) {
|
useEffect(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['needs'] })
|
if (activeNeedIdFromNav) {
|
||||||
}
|
queryClient.invalidateQueries({ queryKey: ['needs'] })
|
||||||
|
}
|
||||||
|
}, [activeNeedIdFromNav, queryClient])
|
||||||
|
|
||||||
const { data: results = [], isLoading, error } = useUnifiedResults(effectiveNeedId)
|
const { data: results = [], isLoading, error } = useUnifiedResults(effectiveNeedId)
|
||||||
|
|
||||||
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
|
|
||||||
const filtered = results.filter(r => {
|
// Counts over the full result set — single pass, stable across filter changes
|
||||||
if (r.resultType === 'FUTURE_AVAILABILITY') return showFutureAvailability
|
const { platformCount, maisonWorkCount, futureCount, missingDataCount } = useMemo(() => {
|
||||||
if (r.resultType === 'VERIFIED_PORTFOLIO') {
|
let platform = 0, maison = 0, future = 0, missing = 0
|
||||||
if (!showOwnProperties) return false
|
for (const r of results) {
|
||||||
return filterSource === 'ALL' || filterSource === 'PLATFORM'
|
if (r.resultType === 'VERIFIED_PORTFOLIO' || r.resultType === 'EXTERNAL_MARKET') platform++
|
||||||
|
else if (r.resultType === 'MAISON_WORK') maison++
|
||||||
|
else if (r.resultType === 'FUTURE_AVAILABILITY') future++
|
||||||
|
if ('match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
|
||||||
|
((r as { match?: { missingData?: unknown[] } }).match?.missingData?.length ?? 0) > 0) missing++
|
||||||
}
|
}
|
||||||
if (filterSource === 'ALL') return true
|
return { platformCount: platform, maisonWorkCount: maison, futureCount: future, missingDataCount: missing }
|
||||||
if (filterSource === 'PLATFORM') return r.resultType === 'EXTERNAL_MARKET'
|
}, [results])
|
||||||
return r.resultType === filterSource // MAISON_WORK
|
|
||||||
})
|
|
||||||
|
|
||||||
const sorted = sortResults(filtered, sortBy)
|
// Filter + sort in one memo — only reruns when inputs change
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const filtered = results.filter(r => {
|
||||||
|
if (r.resultType === 'FUTURE_AVAILABILITY') return showFutureAvailability
|
||||||
|
if (r.resultType === 'VERIFIED_PORTFOLIO') {
|
||||||
|
if (!showOwnProperties) return false
|
||||||
|
return filterSource === 'ALL' || filterSource === 'PLATFORM'
|
||||||
|
}
|
||||||
|
if (filterSource === 'ALL') return true
|
||||||
|
if (filterSource === 'PLATFORM') return r.resultType === 'EXTERNAL_MARKET'
|
||||||
|
return r.resultType === filterSource
|
||||||
|
})
|
||||||
|
return sortResults(filtered, sortBy)
|
||||||
|
}, [results, filterSource, sortBy, showFutureAvailability, showOwnProperties])
|
||||||
|
|
||||||
const platformCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO' || r.resultType === 'EXTERNAL_MARKET').length
|
const strongCount = useMemo(() => sorted.filter(r => r.matchScore >= 80).length, [sorted])
|
||||||
const maisonWorkCount = results.filter(r => r.resultType === 'MAISON_WORK').length
|
|
||||||
const futureCount = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY').length
|
|
||||||
const strongCount = filtered.filter(r => r.matchScore >= 80).length
|
|
||||||
const missingDataCount = results.filter(r =>
|
|
||||||
'match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
|
|
||||||
((r as { match?: { missingData?: unknown[] } }).match?.missingData?.length ?? 0) > 0
|
|
||||||
).length
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { Box, Drawer, useMediaQuery, useTheme } from '@mui/material'
|
import { Box, Drawer, useMediaQuery, useTheme } from '@mui/material'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { PageHeader } from '../../components/layout'
|
import { PageHeader } from '../../components/layout'
|
||||||
@@ -58,22 +58,37 @@ export default function Properties() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const { data: properties = [], isLoading, isError } = useProperties()
|
const { data: properties = [], isLoading, isError } = useProperties()
|
||||||
const filtered = applyFilters(properties, filters)
|
|
||||||
|
|
||||||
// Decision-relevant aggregates
|
const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters])
|
||||||
const matchReady = properties.filter(
|
|
||||||
p => (p.availabilityStatus === 'AVAILABLE_NOW' || p.availabilityStatus === 'AVAILABLE_SOON') &&
|
|
||||||
p.confidenceScore >= 0.7 && p.dataQuality.missingCriticalFields.length === 0
|
|
||||||
)
|
|
||||||
const criticalGaps = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0)
|
|
||||||
const lowConfidence = properties.filter(p => p.confidenceScore < 0.55)
|
|
||||||
const staleOrOutdated = properties.filter(
|
|
||||||
p => p.dataQuality.freshness === 'STALE' || p.dataQuality.freshness === 'OUTDATED'
|
|
||||||
)
|
|
||||||
|
|
||||||
const allMissingFields = [...new Set(
|
// Decision-relevant aggregates — single pass over the full list
|
||||||
properties.flatMap(p => p.dataQuality.missingCriticalFields)
|
const { matchReady, criticalGaps, lowConfidence, staleOrOutdated, allMissingFields } = useMemo(() => {
|
||||||
)].slice(0, 4)
|
const matchReady: typeof properties = []
|
||||||
|
const criticalGaps: typeof properties = []
|
||||||
|
const lowConfidence: typeof properties = []
|
||||||
|
const staleOrOutdated: typeof properties = []
|
||||||
|
const missingSet = new Set<string>()
|
||||||
|
|
||||||
|
for (const p of properties) {
|
||||||
|
if ((p.availabilityStatus === 'AVAILABLE_NOW' || p.availabilityStatus === 'AVAILABLE_SOON') &&
|
||||||
|
p.confidenceScore >= 0.7 && p.dataQuality.missingCriticalFields.length === 0)
|
||||||
|
matchReady.push(p)
|
||||||
|
if (p.dataQuality.missingCriticalFields.length > 0) {
|
||||||
|
criticalGaps.push(p)
|
||||||
|
p.dataQuality.missingCriticalFields.forEach(f => missingSet.add(f))
|
||||||
|
}
|
||||||
|
if (p.confidenceScore < 0.55) lowConfidence.push(p)
|
||||||
|
if (p.dataQuality.freshness === 'STALE' || p.dataQuality.freshness === 'OUTDATED') staleOrOutdated.push(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matchReady,
|
||||||
|
criticalGaps,
|
||||||
|
lowConfidence,
|
||||||
|
staleOrOutdated,
|
||||||
|
allMissingFields: [...missingSet].slice(0, 4),
|
||||||
|
}
|
||||||
|
}, [properties])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user