mirror of
https://github.com/dergigi/boris.git
synced 2025-12-17 06:34:24 +01:00
fix: resolve article loading race condition and populate cache from explore
- Move cache/EventStore checks before relayPool check in useArticleLoader to fix race condition where articles wouldn't load on direct navigation - Add relayPool to dependency array so effect re-runs when it becomes available - Populate localStorage cache when articles are loaded in explore view - Extract cacheArticleEvent() helper to eliminate code duplication - Enhance saveToCache() with settings parameter and better error handling
This commit is contained in:
@@ -73,19 +73,21 @@ export function useArticleLoader({
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
|
||||
if (!relayPool || !naddr) {
|
||||
console.log('[article-loader] Skipping load - missing relayPool or naddr', { hasRelayPool: !!relayPool, hasNaddr: !!naddr })
|
||||
// First check: naddr is required
|
||||
if (!naddr) {
|
||||
console.log('[article-loader] Skipping load - missing naddr')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[article-loader] Starting load for naddr:', naddr)
|
||||
|
||||
// Synchronously check cache sources BEFORE starting async loading
|
||||
// Synchronously check cache sources BEFORE checking relayPool
|
||||
// This prevents showing loading skeletons when content is immediately available
|
||||
// Do this outside the async function for immediate execution
|
||||
// and fixes the race condition where relayPool isn't ready yet
|
||||
let foundInCache = false
|
||||
try {
|
||||
console.log('[article-loader] Checking localStorage cache...')
|
||||
// Check localStorage cache first (synchronous)
|
||||
// Check localStorage cache first (synchronous, doesn't need relayPool)
|
||||
const cachedArticle = getFromCache(naddr)
|
||||
if (cachedArticle) {
|
||||
console.log('[article-loader] ✅ Cache HIT - loading from localStorage', {
|
||||
@@ -93,6 +95,7 @@ export function useArticleLoader({
|
||||
hasMarkdown: !!cachedArticle.markdown,
|
||||
markdownLength: cachedArticle.markdown?.length
|
||||
})
|
||||
foundInCache = true
|
||||
const title = cachedArticle.title || 'Untitled Article'
|
||||
setCurrentTitle(title)
|
||||
setReaderContent({
|
||||
@@ -130,12 +133,13 @@ export function useArticleLoader({
|
||||
}
|
||||
|
||||
// Fetch highlights in background (don't block UI)
|
||||
if (mountedRef.current) {
|
||||
// Only fetch highlights if relayPool is available
|
||||
if (mountedRef.current && relayPool) {
|
||||
const dTag = cachedArticle.event.tags.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coord = dTag ? `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` : undefined
|
||||
const eventId = cachedArticle.event.id
|
||||
|
||||
if (coord && eventId && relayPool) {
|
||||
if (coord && eventId) {
|
||||
setHighlightsLoading(true)
|
||||
fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
@@ -175,6 +179,110 @@ export function useArticleLoader({
|
||||
console.warn('[article-loader] Cache check failed:', err)
|
||||
}
|
||||
|
||||
// Check EventStore synchronously (also doesn't need relayPool)
|
||||
let foundInEventStore = false
|
||||
if (eventStore && !foundInCache) {
|
||||
console.log('[article-loader] Checking EventStore...')
|
||||
try {
|
||||
// Decode naddr to get the coordinate
|
||||
const decoded = nip19.decode(naddr)
|
||||
if (decoded.type === 'naddr') {
|
||||
const pointer = decoded.data as AddressPointer
|
||||
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
||||
console.log('[article-loader] Looking for event with coordinate:', coordinate)
|
||||
const storedEvent = eventStore.getEvent?.(coordinate)
|
||||
if (storedEvent) {
|
||||
foundInEventStore = true
|
||||
console.log('[article-loader] ✅ EventStore HIT - found event', {
|
||||
id: storedEvent.id,
|
||||
kind: storedEvent.kind,
|
||||
hasContent: !!storedEvent.content,
|
||||
contentLength: storedEvent.content?.length
|
||||
})
|
||||
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
|
||||
setCurrentTitle(title)
|
||||
const image = Helpers.getArticleImage(storedEvent)
|
||||
const summary = Helpers.getArticleSummary(storedEvent)
|
||||
const published = Helpers.getArticlePublished(storedEvent)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: storedEvent.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(storedEvent.id)
|
||||
setCurrentArticle?.(storedEvent)
|
||||
setReaderLoading(false)
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
|
||||
// Fetch highlights in background if relayPool is available
|
||||
if (relayPool) {
|
||||
const coord = dTag ? `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}` : undefined
|
||||
const eventId = storedEvent.id
|
||||
|
||||
if (coord && eventId) {
|
||||
setHighlightsLoading(true)
|
||||
fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
coord,
|
||||
eventId,
|
||||
(highlight) => {
|
||||
if (!mountedRef.current) return
|
||||
setHighlights((prev: Highlight[]) => {
|
||||
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
|
||||
const next = [highlight, ...prev]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
},
|
||||
settings,
|
||||
false,
|
||||
eventStore || undefined
|
||||
).then(() => {
|
||||
if (mountedRef.current) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}).catch(() => {
|
||||
if (mountedRef.current) {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Return early - we have EventStore content, no need to query relays yet
|
||||
// But we might want to fetch from relays in background if relayPool becomes available
|
||||
console.log('[article-loader] Returning early with EventStore content')
|
||||
return
|
||||
} else {
|
||||
console.log('[article-loader] ❌ EventStore MISS - no event found for coordinate:', coordinate)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore store errors, fall through to relay query
|
||||
console.warn('[article-loader] EventStore check failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Only return early if we have no content AND no relayPool to fetch from
|
||||
if (!relayPool && !foundInCache && !foundInEventStore) {
|
||||
console.log('[article-loader] No relayPool available and no cached content - showing loading skeleton')
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
// If we have relayPool, proceed with async loading
|
||||
if (!relayPool) {
|
||||
console.log('[article-loader] Waiting for relayPool to become available...')
|
||||
return
|
||||
}
|
||||
|
||||
const loadArticle = async () => {
|
||||
const requestId = ++currentRequestIdRef.current
|
||||
console.log('[article-loader] Starting async loadArticle function', { requestId })
|
||||
@@ -191,60 +299,8 @@ export function useArticleLoader({
|
||||
// when we know the article coordinate
|
||||
setHighlightsLoading(false) // Don't show loading yet
|
||||
|
||||
// Check eventStore for instant load (from bookmark cards, explore, etc.)
|
||||
// Cache was already checked synchronously above, so this only handles EventStore
|
||||
if (eventStore) {
|
||||
console.log('[article-loader] Checking EventStore...')
|
||||
try {
|
||||
// Decode naddr to get the coordinate
|
||||
const decoded = nip19.decode(naddr)
|
||||
if (decoded.type === 'naddr') {
|
||||
const pointer = decoded.data as AddressPointer
|
||||
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
|
||||
console.log('[article-loader] Looking for event with coordinate:', coordinate)
|
||||
const storedEvent = eventStore.getEvent?.(coordinate)
|
||||
if (storedEvent) {
|
||||
console.log('[article-loader] ✅ EventStore HIT - found event', {
|
||||
id: storedEvent.id,
|
||||
kind: storedEvent.kind,
|
||||
hasContent: !!storedEvent.content,
|
||||
contentLength: storedEvent.content?.length
|
||||
})
|
||||
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
|
||||
setCurrentTitle(title)
|
||||
const image = Helpers.getArticleImage(storedEvent)
|
||||
const summary = Helpers.getArticleSummary(storedEvent)
|
||||
const published = Helpers.getArticlePublished(storedEvent)
|
||||
setReaderContent({
|
||||
title,
|
||||
markdown: storedEvent.content,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(storedEvent.id)
|
||||
setCurrentArticle?.(storedEvent)
|
||||
setReaderLoading(false)
|
||||
|
||||
// If we found the content in EventStore, we can return early
|
||||
// This prevents unnecessary relay queries when offline
|
||||
console.log('[article-loader] Returning early with EventStore content')
|
||||
return
|
||||
} else {
|
||||
console.log('[article-loader] ❌ EventStore MISS - no event found for coordinate:', coordinate)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore store errors, fall through to relay query
|
||||
console.warn('[article-loader] EventStore check failed:', err)
|
||||
}
|
||||
} else {
|
||||
console.log('[article-loader] No EventStore available, skipping check')
|
||||
}
|
||||
// Note: Cache and EventStore were already checked synchronously above
|
||||
// This async function only runs if we need to fetch from relays
|
||||
|
||||
// At this point, we've checked EventStore and cache - neither had content
|
||||
// Only show loading skeleton if we also don't have preview data
|
||||
@@ -359,7 +415,7 @@ export function useArticleLoader({
|
||||
author: evt.pubkey,
|
||||
event: evt
|
||||
}
|
||||
saveToCache(naddr, articleContent)
|
||||
saveToCache(naddr, articleContent, settings)
|
||||
|
||||
// Preload image to ensure it's cached by Service Worker
|
||||
if (image) {
|
||||
@@ -526,11 +582,13 @@ export function useArticleLoader({
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
// Dependencies intentionally excluded to prevent re-renders when relay/eventStore state changes
|
||||
// This fixes the loading skeleton appearing when going offline (flight mode)
|
||||
// Include relayPool in dependencies so effect re-runs when it becomes available
|
||||
// This fixes the race condition where articles don't load on direct navigation
|
||||
// We guard against unnecessary re-renders by checking cache/EventStore first
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
naddr,
|
||||
previewData
|
||||
previewData,
|
||||
relayPool
|
||||
])
|
||||
}
|
||||
|
||||
@@ -71,8 +71,46 @@ export function getFromCache(naddr: string): ArticleContent | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function saveToCache(naddr: string, content: ArticleContent): void {
|
||||
/**
|
||||
* Caches an article event to localStorage for offline access
|
||||
* @param event - The Nostr event to cache
|
||||
* @param settings - Optional user settings
|
||||
*/
|
||||
export function cacheArticleEvent(event: NostrEvent, settings?: UserSettings): void {
|
||||
try {
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
if (!dTag || event.kind !== 30023) return
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: event.pubkey,
|
||||
identifier: dTag
|
||||
})
|
||||
|
||||
const articleContent: ArticleContent = {
|
||||
title: getArticleTitle(event) || 'Untitled Article',
|
||||
markdown: event.content,
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
summary: getArticleSummary(event),
|
||||
author: event.pubkey,
|
||||
event
|
||||
}
|
||||
|
||||
saveToCache(naddr, articleContent, settings)
|
||||
} catch (err) {
|
||||
// Silently fail cache saves - quota exceeded, invalid data, etc.
|
||||
console.warn('[article-cache] Failed to cache article event:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export function saveToCache(naddr: string, content: ArticleContent, settings?: UserSettings): void {
|
||||
try {
|
||||
// Respect user settings: if image caching is disabled, we could skip article caching too
|
||||
// However, for offline-first design, we default to caching unless explicitly disabled
|
||||
// Future: could add explicit enableArticleCache setting
|
||||
// For now, we cache aggressively but handle errors gracefully
|
||||
|
||||
const cacheKey = getCacheKey(naddr)
|
||||
console.log('[article-cache] 💾 Saving to cache', {
|
||||
key: cacheKey,
|
||||
@@ -87,8 +125,16 @@ export function saveToCache(naddr: string, content: ArticleContent): void {
|
||||
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
||||
console.log('[article-cache] ✅ Successfully saved to cache')
|
||||
} catch (err) {
|
||||
console.warn('[article-cache] Failed to cache article:', err)
|
||||
// Silently fail if storage is full or unavailable
|
||||
// Handle quota exceeded errors specifically
|
||||
if (err instanceof DOMException && (err.code === 22 || err.code === 1014 || err.name === 'QuotaExceededError')) {
|
||||
console.warn('[article-cache] ⚠️ Storage quota exceeded - article not cached:', {
|
||||
title: content.title,
|
||||
error: err.message
|
||||
})
|
||||
} else {
|
||||
console.warn('[article-cache] Failed to cache article:', err)
|
||||
}
|
||||
// Silently fail - don't block the UI if caching fails
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +234,7 @@ export async function fetchArticleByNaddr(
|
||||
}
|
||||
|
||||
// Save to cache before returning
|
||||
saveToCache(naddr, content)
|
||||
saveToCache(naddr, content, settings)
|
||||
|
||||
// Image caching is handled automatically by Service Worker
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers, IEventStore } from 'applesauce-core'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { cacheArticleEvent } from './articleService'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -75,6 +76,9 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
}
|
||||
onPost(post)
|
||||
}
|
||||
|
||||
// Cache article content in localStorage for offline access
|
||||
cacheArticleEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,7 +109,6 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch blog posts:', error)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BlogPostPreview } from './exploreService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { cacheArticleEvent } from './articleService'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -57,6 +58,9 @@ export const fetchNostrverseBlogPosts = async (
|
||||
}
|
||||
onPost(post)
|
||||
}
|
||||
|
||||
// Cache article content in localStorage for offline access
|
||||
cacheArticleEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +83,6 @@ export const fetchNostrverseBlogPosts = async (
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch nostrverse blog posts:', error)
|
||||
|
||||
Reference in New Issue
Block a user