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:
Michael Chihlas
2026-03-09 17:34:23 -04:00
parent 7e3b383a65
commit 66ebc95cde
3 changed files with 80 additions and 65 deletions

View File

@@ -176,7 +176,8 @@ export function QuickStartPage() {
return () => window.removeEventListener('focus', onFocus)
}, [loadFlows])
// Debounced search
// Debounced search with staleness guard
const searchRequestId = useRef(0)
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
if (query.length < 2) {
@@ -188,13 +189,16 @@ export function QuickStartPage() {
setIsSearching(true)
setShowResults(true)
debounceRef.current = setTimeout(async () => {
const requestId = ++searchRequestId.current
try {
const results = await treesApi.search(query, 8)
if (requestId !== searchRequestId.current) return
setSearchResults(results)
} catch {
if (requestId !== searchRequestId.current) return
setSearchResults([])
} finally {
setIsSearching(false)
if (requestId === searchRequestId.current) setIsSearching(false)
}
}, 300)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }

View File

@@ -60,7 +60,59 @@ export function SessionHistoryPage() {
// Load sessions when filters change
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()
return () => { cancelled = true }
}, [filter, filters])
// Update URL params when filters change
@@ -79,53 +131,6 @@ export function SessionHistoryPage() {
setSearchParams(params, { replace: true })
}, [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) => {
setFilters(newFilters)
}

View File

@@ -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 { X, RotateCcw, Play, FileUp } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta'
@@ -158,20 +158,11 @@ export function TreeLibraryPage() {
.catch((err) => console.error('Failed to load categories:', err))
}, [])
// Load trees when filters change
useEffect(() => {
loadTrees()
}, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy, typeFilter])
// Request ID ref to discard stale responses when filters change rapidly
const loadTreesRequestId = useRef(0)
// 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 loadTrees = async () => {
const loadTrees = useCallback(async () => {
const requestId = ++loadTreesRequestId.current
setIsLoading(true)
try {
const treesData = await treesApi.list({
@@ -181,14 +172,29 @@ export function TreeLibraryPage() {
folder_id: selectedFolderId || undefined,
sort_by: treeLibrarySortBy,
})
if (requestId !== loadTreesRequestId.current) return
setTrees(treesData)
} catch (err) {
if (requestId !== loadTreesRequestId.current) return
toast.error('Failed to load flows')
console.error(err)
} 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 () => {
if (!searchQuery.trim()) {