fix: resolve race conditions in Bible reader SEO URLs

- Add request cancellation with AbortController for books and verses fetching
- Implement debouncing (300ms) for version changes to prevent rapid API calls
- Fix conflicting useEffect hooks that caused incorrect Bible text display
- Add enhanced error handling and loading state management
- Prevent empty states when switching Bible versions quickly
- Improve user experience with immediate loading feedback

Fixes issue where selecting Bible versions rapidly would cause:
- 500 API errors from concurrent requests
- Wrong Bible text being displayed
- Empty content states
- Poor user experience during filtering

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-29 19:46:19 +00:00
parent 61a5180e2f
commit b337b82fde

View File

@@ -179,6 +179,9 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
const [versionsLoading, setVersionsLoading] = useState(true) const [versionsLoading, setVersionsLoading] = useState(true)
const [showAllVersions, setShowAllVersions] = useState(false) const [showAllVersions, setShowAllVersions] = useState(false)
// Debounced version state to prevent rapid API calls
const [debouncedVersion, setDebouncedVersion] = useState<string>('')
// UI state // UI state
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
const [preferences, setPreferences] = useState<ReadingPreferences>(defaultPreferences) const [preferences, setPreferences] = useState<ReadingPreferences>(defaultPreferences)
@@ -425,29 +428,65 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
router.replace(newUrl, { scroll: false }) router.replace(newUrl, { scroll: false })
}, [locale, selectedVersion, selectedBook, selectedChapter, router, versions, books]) }, [locale, selectedVersion, selectedBook, selectedChapter, router, versions, books])
// Fetch books when version changes // Debounce version changes to prevent rapid API calls
useEffect(() => { useEffect(() => {
if (selectedVersion) { const timer = setTimeout(() => {
setDebouncedVersion(selectedVersion)
}, 300) // 300ms delay
return () => clearTimeout(timer)
}, [selectedVersion])
// Show loading immediately when selectedVersion changes (before debounce)
useEffect(() => {
if (selectedVersion && selectedVersion !== debouncedVersion) {
setLoading(true) setLoading(true)
fetch(`/api/bible/books?locale=${locale}&version=${selectedVersion}`) }
.then(res => res.json()) }, [selectedVersion, debouncedVersion])
.then(data => {
setBooks(data.books || []) // Fetch books when debounced version changes
if (data.books && data.books.length > 0) { useEffect(() => {
setSelectedBook(data.books[0].id) if (debouncedVersion) {
// Create an AbortController for this request
const abortController = new AbortController()
fetch(`/api/bible/books?locale=${locale}&version=${debouncedVersion}`, {
signal: abortController.signal
})
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`)
}
return res.json()
})
.then(data => {
// Only update state if the request wasn't aborted
if (!abortController.signal.aborted) {
setBooks(data.books || [])
if (data.books && data.books.length > 0 && !initialBook) {
setSelectedBook(data.books[0].id)
}
setLoading(false)
} }
setLoading(false)
}) })
.catch(err => { .catch(err => {
console.error('Error fetching books:', err) // Only log error if it's not an abort error
setLoading(false) if (!abortController.signal.aborted) {
console.error('Error fetching books:', err)
setLoading(false)
}
}) })
}
}, [locale, selectedVersion])
// Handle URL parameters // Cleanup function to abort the request if component unmounts or dependencies change
return () => {
abortController.abort()
}
}
}, [locale, debouncedVersion, initialBook])
// Handle URL parameters (only when not using initial props from SEO URLs)
useEffect(() => { useEffect(() => {
if (books.length > 0 && versions.length > 0) { if (books.length > 0 && versions.length > 0 && !initialVersion && !initialBook && !initialChapter) {
const bookParam = searchParams.get('book') const bookParam = searchParams.get('book')
const chapterParam = searchParams.get('chapter') const chapterParam = searchParams.get('chapter')
const verseParam = searchParams.get('verse') const verseParam = searchParams.get('verse')
@@ -481,7 +520,7 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
} }
} }
} }
}, [books, versions, searchParams]) }, [books, versions, searchParams, initialVersion, initialBook, initialChapter])
// Fetch verses when book/chapter changes // Fetch verses when book/chapter changes
useEffect(() => { useEffect(() => {
@@ -491,36 +530,57 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
// Store scroll position to prevent jumping // Store scroll position to prevent jumping
const scrollPosition = window.pageYOffset const scrollPosition = window.pageYOffset
fetch(`/api/bible/verses?bookId=${selectedBook}&chapter=${selectedChapter}`) // Create an AbortController for this request
.then(res => res.json()) const abortController = new AbortController()
fetch(`/api/bible/verses?bookId=${selectedBook}&chapter=${selectedChapter}`, {
signal: abortController.signal
})
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`)
}
return res.json()
})
.then(data => { .then(data => {
const newVerses = data.verses || [] // Only update state if the request wasn't aborted
if (!abortController.signal.aborted) {
const newVerses = data.verses || []
// Store previous verses before updating // Store previous verses before updating
setPreviousVerses(verses) setPreviousVerses(verses)
// Use requestAnimationFrame to ensure smooth transition // Use requestAnimationFrame to ensure smooth transition
requestAnimationFrame(() => { requestAnimationFrame(() => {
setVerses(newVerses) setVerses(newVerses)
// Small delay to allow content to render before removing loading state // Small delay to allow content to render before removing loading state
setTimeout(() => { setTimeout(() => {
setLoading(false) setLoading(false)
setPreviousVerses([]) // Clear previous content after transition setPreviousVerses([]) // Clear previous content after transition
// Restore scroll position if we're not navigating to a specific verse // Restore scroll position if we're not navigating to a specific verse
const urlVerse = new URLSearchParams(window.location.search).get('verse') const urlVerse = new URLSearchParams(window.location.search).get('verse')
if (!urlVerse) { if (!urlVerse) {
// Maintain scroll position for better UX // Maintain scroll position for better UX
window.scrollTo({ top: Math.min(scrollPosition, document.body.scrollHeight), behavior: 'auto' }) window.scrollTo({ top: Math.min(scrollPosition, document.body.scrollHeight), behavior: 'auto' })
} }
}, 50) // Small delay for smoother transition }, 50) // Small delay for smoother transition
}) })
}
}) })
.catch(err => { .catch(err => {
console.error('Error fetching verses:', err) // Only log error if it's not an abort error
setLoading(false) if (!abortController.signal.aborted) {
console.error('Error fetching verses:', err)
setLoading(false)
}
}) })
// Cleanup function to abort the request if component unmounts or dependencies change
return () => {
abortController.abort()
}
} }
}, [selectedBook, selectedChapter]) }, [selectedBook, selectedChapter])