fix: prevent stale API responses from overwriting newer data
- SessionHistoryPage: move loadSessions into effect with cancelled flag so rapid filter/tab changes discard outdated responses - TreeLibraryPage: add request ID ref to loadTrees so stale responses from previous filter selections are discarded - QuickStartPage: add request ID ref to debounced search so out-of-order responses don't overwrite newer search results Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -176,7 +176,8 @@ export function QuickStartPage() {
|
|||||||
return () => window.removeEventListener('focus', onFocus)
|
return () => window.removeEventListener('focus', onFocus)
|
||||||
}, [loadFlows])
|
}, [loadFlows])
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search with staleness guard
|
||||||
|
const searchRequestId = useRef(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
@@ -188,13 +189,16 @@ export function QuickStartPage() {
|
|||||||
setIsSearching(true)
|
setIsSearching(true)
|
||||||
setShowResults(true)
|
setShowResults(true)
|
||||||
debounceRef.current = setTimeout(async () => {
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
const requestId = ++searchRequestId.current
|
||||||
try {
|
try {
|
||||||
const results = await treesApi.search(query, 8)
|
const results = await treesApi.search(query, 8)
|
||||||
|
if (requestId !== searchRequestId.current) return
|
||||||
setSearchResults(results)
|
setSearchResults(results)
|
||||||
} catch {
|
} catch {
|
||||||
|
if (requestId !== searchRequestId.current) return
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
} finally {
|
} finally {
|
||||||
setIsSearching(false)
|
if (requestId === searchRequestId.current) setIsSearching(false)
|
||||||
}
|
}
|
||||||
}, 300)
|
}, 300)
|
||||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||||
|
|||||||
@@ -60,7 +60,59 @@ export function SessionHistoryPage() {
|
|||||||
|
|
||||||
// Load sessions when filters change
|
// Load sessions when filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const loadSessions = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const params: Record<string, string | boolean> = {}
|
||||||
|
|
||||||
|
// Tab filter (all/active/completed)
|
||||||
|
if (filter !== 'all') {
|
||||||
|
params.completed = filter === 'completed'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search/filter params
|
||||||
|
if (filters.ticketNumber) {
|
||||||
|
params.ticket_number = filters.ticketNumber
|
||||||
|
}
|
||||||
|
if (filters.clientName) {
|
||||||
|
params.client_name = filters.clientName
|
||||||
|
}
|
||||||
|
if (filters.treeName) {
|
||||||
|
params.tree_name = filters.treeName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date range params
|
||||||
|
if (filters.dateRange?.from) {
|
||||||
|
const fromDate = filters.dateRange.from
|
||||||
|
const toDate = filters.dateRange.to || filters.dateRange.from
|
||||||
|
|
||||||
|
if (filters.dateType === 'started') {
|
||||||
|
params.started_after = fromDate.toISOString()
|
||||||
|
params.started_before = toDate.toISOString()
|
||||||
|
} else {
|
||||||
|
params.completed_after = fromDate.toISOString()
|
||||||
|
params.completed_before = toDate.toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionsData = await sessionsApi.list({ ...params, size: 51 })
|
||||||
|
if (cancelled) return
|
||||||
|
const truncated = sessionsData.length > 50
|
||||||
|
setHasMore(truncated)
|
||||||
|
setSessions(truncated ? sessionsData.slice(0, 50) : sessionsData)
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return
|
||||||
|
toast.error('Failed to load sessions')
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadSessions()
|
loadSessions()
|
||||||
|
return () => { cancelled = true }
|
||||||
}, [filter, filters])
|
}, [filter, filters])
|
||||||
|
|
||||||
// Update URL params when filters change
|
// Update URL params when filters change
|
||||||
@@ -79,53 +131,6 @@ export function SessionHistoryPage() {
|
|||||||
setSearchParams(params, { replace: true })
|
setSearchParams(params, { replace: true })
|
||||||
}, [filters, setSearchParams])
|
}, [filters, setSearchParams])
|
||||||
|
|
||||||
const loadSessions = async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
const params: Record<string, string | boolean> = {}
|
|
||||||
|
|
||||||
// Tab filter (all/active/completed)
|
|
||||||
if (filter !== 'all') {
|
|
||||||
params.completed = filter === 'completed'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search/filter params
|
|
||||||
if (filters.ticketNumber) {
|
|
||||||
params.ticket_number = filters.ticketNumber
|
|
||||||
}
|
|
||||||
if (filters.clientName) {
|
|
||||||
params.client_name = filters.clientName
|
|
||||||
}
|
|
||||||
if (filters.treeName) {
|
|
||||||
params.tree_name = filters.treeName
|
|
||||||
}
|
|
||||||
|
|
||||||
// Date range params
|
|
||||||
if (filters.dateRange?.from) {
|
|
||||||
const fromDate = filters.dateRange.from
|
|
||||||
const toDate = filters.dateRange.to || filters.dateRange.from
|
|
||||||
|
|
||||||
if (filters.dateType === 'started') {
|
|
||||||
params.started_after = fromDate.toISOString()
|
|
||||||
params.started_before = toDate.toISOString()
|
|
||||||
} else {
|
|
||||||
params.completed_after = fromDate.toISOString()
|
|
||||||
params.completed_before = toDate.toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionsData = await sessionsApi.list({ ...params, size: 51 })
|
|
||||||
const truncated = sessionsData.length > 50
|
|
||||||
setHasMore(truncated)
|
|
||||||
setSessions(truncated ? sessionsData.slice(0, 50) : sessionsData)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error('Failed to load sessions')
|
|
||||||
console.error(err)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFilterChange = (newFilters: SessionFilterState) => {
|
const handleFilterChange = (newFilters: SessionFilterState) => {
|
||||||
setFilters(newFilters)
|
setFilters(newFilters)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { X, RotateCcw, Play, FileUp } from 'lucide-react'
|
import { X, RotateCcw, Play, FileUp } from 'lucide-react'
|
||||||
import { PageMeta } from '@/components/common/PageMeta'
|
import { PageMeta } from '@/components/common/PageMeta'
|
||||||
@@ -158,20 +158,11 @@ export function TreeLibraryPage() {
|
|||||||
.catch((err) => console.error('Failed to load categories:', err))
|
.catch((err) => console.error('Failed to load categories:', err))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load trees when filters change
|
// Request ID ref to discard stale responses when filters change rapidly
|
||||||
useEffect(() => {
|
const loadTreesRequestId = useRef(0)
|
||||||
loadTrees()
|
|
||||||
}, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy, typeFilter])
|
|
||||||
|
|
||||||
// Load folders on mount and listen for changes
|
const loadTrees = useCallback(async () => {
|
||||||
useEffect(() => {
|
const requestId = ++loadTreesRequestId.current
|
||||||
loadFolders()
|
|
||||||
const handleFolderChange = () => loadFolders()
|
|
||||||
window.addEventListener('folder-changed', handleFolderChange)
|
|
||||||
return () => window.removeEventListener('folder-changed', handleFolderChange)
|
|
||||||
}, [loadFolders])
|
|
||||||
|
|
||||||
const loadTrees = async () => {
|
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const treesData = await treesApi.list({
|
const treesData = await treesApi.list({
|
||||||
@@ -181,14 +172,29 @@ export function TreeLibraryPage() {
|
|||||||
folder_id: selectedFolderId || undefined,
|
folder_id: selectedFolderId || undefined,
|
||||||
sort_by: treeLibrarySortBy,
|
sort_by: treeLibrarySortBy,
|
||||||
})
|
})
|
||||||
|
if (requestId !== loadTreesRequestId.current) return
|
||||||
setTrees(treesData)
|
setTrees(treesData)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (requestId !== loadTreesRequestId.current) return
|
||||||
toast.error('Failed to load flows')
|
toast.error('Failed to load flows')
|
||||||
console.error(err)
|
console.error(err)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
if (requestId === loadTreesRequestId.current) setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy, typeFilter])
|
||||||
|
|
||||||
|
// Load trees when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
loadTrees()
|
||||||
|
}, [loadTrees])
|
||||||
|
|
||||||
|
// Load folders on mount and listen for changes
|
||||||
|
useEffect(() => {
|
||||||
|
loadFolders()
|
||||||
|
const handleFolderChange = () => loadFolders()
|
||||||
|
window.addEventListener('folder-changed', handleFolderChange)
|
||||||
|
return () => window.removeEventListener('folder-changed', handleFolderChange)
|
||||||
|
}, [loadFolders])
|
||||||
|
|
||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user