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:
Gigi
2025-10-31 01:24:58 +01:00
parent cfa6dc4400
commit c20682fbe8
4 changed files with 181 additions and 71 deletions

View File

@@ -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,25 +179,9 @@ export function useArticleLoader({
console.warn('[article-loader] Cache check failed:', err)
}
const loadArticle = async () => {
const requestId = ++currentRequestIdRef.current
console.log('[article-loader] Starting async loadArticle function', { requestId })
if (!mountedRef.current) {
console.log('[article-loader] Component unmounted, aborting')
return
}
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// Don't clear highlights yet - let the smart filtering logic handle it
// 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) {
// 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
@@ -204,6 +192,7 @@ export function useArticleLoader({
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,
@@ -229,9 +218,45 @@ export function useArticleLoader({
setCurrentArticleEventId(storedEvent.id)
setCurrentArticle?.(storedEvent)
setReaderLoading(false)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// If we found the content in EventStore, we can return early
// This prevents unnecessary relay queries when offline
// 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 {
@@ -242,10 +267,41 @@ export function useArticleLoader({
// 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')
}
// 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 })
if (!mountedRef.current) {
console.log('[article-loader] Component unmounted, aborting')
return
}
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// Don't clear highlights yet - let the smart filtering logic handle it
// when we know the article coordinate
setHighlightsLoading(false) // Don't show loading yet
// 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
if (previewData) {
@@ -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
])
}

View File

@@ -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) {
// 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 if storage is full or unavailable
}
// 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

View File

@@ -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)

View File

@@ -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)