From b337b82fde9dd81a704d6ff382fbd8e0cf920770 Mon Sep 17 00:00:00 2001 From: Andrei Date: Mon, 29 Sep 2025 19:46:19 +0000 Subject: [PATCH] fix: resolve race conditions in Bible reader SEO URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/[locale]/bible/reader.tsx | 136 ++++++++++++++++++++++++---------- 1 file changed, 98 insertions(+), 38 deletions(-) diff --git a/app/[locale]/bible/reader.tsx b/app/[locale]/bible/reader.tsx index 46bfb5d..f8451b7 100644 --- a/app/[locale]/bible/reader.tsx +++ b/app/[locale]/bible/reader.tsx @@ -179,6 +179,9 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha const [versionsLoading, setVersionsLoading] = useState(true) const [showAllVersions, setShowAllVersions] = useState(false) + // Debounced version state to prevent rapid API calls + const [debouncedVersion, setDebouncedVersion] = useState('') + // UI state const [settingsOpen, setSettingsOpen] = useState(false) const [preferences, setPreferences] = useState(defaultPreferences) @@ -425,29 +428,65 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha router.replace(newUrl, { scroll: false }) }, [locale, selectedVersion, selectedBook, selectedChapter, router, versions, books]) - // Fetch books when version changes + // Debounce version changes to prevent rapid API calls 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) - fetch(`/api/bible/books?locale=${locale}&version=${selectedVersion}`) - .then(res => res.json()) - .then(data => { - setBooks(data.books || []) - if (data.books && data.books.length > 0) { - setSelectedBook(data.books[0].id) + } + }, [selectedVersion, debouncedVersion]) + + // Fetch books when debounced version changes + useEffect(() => { + 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 => { - console.error('Error fetching books:', err) - setLoading(false) + // Only log error if it's not an abort error + 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(() => { - if (books.length > 0 && versions.length > 0) { + if (books.length > 0 && versions.length > 0 && !initialVersion && !initialBook && !initialChapter) { const bookParam = searchParams.get('book') const chapterParam = searchParams.get('chapter') 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 useEffect(() => { @@ -491,36 +530,57 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha // Store scroll position to prevent jumping const scrollPosition = window.pageYOffset - fetch(`/api/bible/verses?bookId=${selectedBook}&chapter=${selectedChapter}`) - .then(res => res.json()) + // Create an AbortController for this request + 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 => { - 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 - setPreviousVerses(verses) + // Store previous verses before updating + setPreviousVerses(verses) - // Use requestAnimationFrame to ensure smooth transition - requestAnimationFrame(() => { - setVerses(newVerses) + // Use requestAnimationFrame to ensure smooth transition + requestAnimationFrame(() => { + setVerses(newVerses) - // Small delay to allow content to render before removing loading state - setTimeout(() => { - setLoading(false) - setPreviousVerses([]) // Clear previous content after transition + // Small delay to allow content to render before removing loading state + setTimeout(() => { + setLoading(false) + setPreviousVerses([]) // Clear previous content after transition - // Restore scroll position if we're not navigating to a specific verse - const urlVerse = new URLSearchParams(window.location.search).get('verse') - if (!urlVerse) { - // Maintain scroll position for better UX - window.scrollTo({ top: Math.min(scrollPosition, document.body.scrollHeight), behavior: 'auto' }) - } - }, 50) // Small delay for smoother transition - }) + // Restore scroll position if we're not navigating to a specific verse + const urlVerse = new URLSearchParams(window.location.search).get('verse') + if (!urlVerse) { + // Maintain scroll position for better UX + window.scrollTo({ top: Math.min(scrollPosition, document.body.scrollHeight), behavior: 'auto' }) + } + }, 50) // Small delay for smoother transition + }) + } }) .catch(err => { - console.error('Error fetching verses:', err) - setLoading(false) + // Only log error if it's not an abort error + 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])