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(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true
|
mountedRef.current = true
|
||||||
|
|
||||||
if (!relayPool || !naddr) {
|
// First check: naddr is required
|
||||||
console.log('[article-loader] Skipping load - missing relayPool or naddr', { hasRelayPool: !!relayPool, hasNaddr: !!naddr })
|
if (!naddr) {
|
||||||
|
console.log('[article-loader] Skipping load - missing naddr')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[article-loader] Starting load for naddr:', naddr)
|
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
|
// 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 {
|
try {
|
||||||
console.log('[article-loader] Checking localStorage cache...')
|
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)
|
const cachedArticle = getFromCache(naddr)
|
||||||
if (cachedArticle) {
|
if (cachedArticle) {
|
||||||
console.log('[article-loader] ✅ Cache HIT - loading from localStorage', {
|
console.log('[article-loader] ✅ Cache HIT - loading from localStorage', {
|
||||||
@@ -93,6 +95,7 @@ export function useArticleLoader({
|
|||||||
hasMarkdown: !!cachedArticle.markdown,
|
hasMarkdown: !!cachedArticle.markdown,
|
||||||
markdownLength: cachedArticle.markdown?.length
|
markdownLength: cachedArticle.markdown?.length
|
||||||
})
|
})
|
||||||
|
foundInCache = true
|
||||||
const title = cachedArticle.title || 'Untitled Article'
|
const title = cachedArticle.title || 'Untitled Article'
|
||||||
setCurrentTitle(title)
|
setCurrentTitle(title)
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
@@ -130,12 +133,13 @@ export function useArticleLoader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch highlights in background (don't block UI)
|
// 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 dTag = cachedArticle.event.tags.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
const coord = dTag ? `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` : undefined
|
const coord = dTag ? `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}` : undefined
|
||||||
const eventId = cachedArticle.event.id
|
const eventId = cachedArticle.event.id
|
||||||
|
|
||||||
if (coord && eventId && relayPool) {
|
if (coord && eventId) {
|
||||||
setHighlightsLoading(true)
|
setHighlightsLoading(true)
|
||||||
fetchHighlightsForArticle(
|
fetchHighlightsForArticle(
|
||||||
relayPool,
|
relayPool,
|
||||||
@@ -175,25 +179,9 @@ export function useArticleLoader({
|
|||||||
console.warn('[article-loader] Cache check failed:', err)
|
console.warn('[article-loader] Cache check failed:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadArticle = async () => {
|
// Check EventStore synchronously (also doesn't need relayPool)
|
||||||
const requestId = ++currentRequestIdRef.current
|
let foundInEventStore = false
|
||||||
console.log('[article-loader] Starting async loadArticle function', { requestId })
|
if (eventStore && !foundInCache) {
|
||||||
|
|
||||||
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) {
|
|
||||||
console.log('[article-loader] Checking EventStore...')
|
console.log('[article-loader] Checking EventStore...')
|
||||||
try {
|
try {
|
||||||
// Decode naddr to get the coordinate
|
// Decode naddr to get the coordinate
|
||||||
@@ -204,6 +192,7 @@ export function useArticleLoader({
|
|||||||
console.log('[article-loader] Looking for event with coordinate:', coordinate)
|
console.log('[article-loader] Looking for event with coordinate:', coordinate)
|
||||||
const storedEvent = eventStore.getEvent?.(coordinate)
|
const storedEvent = eventStore.getEvent?.(coordinate)
|
||||||
if (storedEvent) {
|
if (storedEvent) {
|
||||||
|
foundInEventStore = true
|
||||||
console.log('[article-loader] ✅ EventStore HIT - found event', {
|
console.log('[article-loader] ✅ EventStore HIT - found event', {
|
||||||
id: storedEvent.id,
|
id: storedEvent.id,
|
||||||
kind: storedEvent.kind,
|
kind: storedEvent.kind,
|
||||||
@@ -229,9 +218,45 @@ export function useArticleLoader({
|
|||||||
setCurrentArticleEventId(storedEvent.id)
|
setCurrentArticleEventId(storedEvent.id)
|
||||||
setCurrentArticle?.(storedEvent)
|
setCurrentArticle?.(storedEvent)
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
setSelectedUrl(`nostr:${naddr}`)
|
||||||
|
setIsCollapsed(true)
|
||||||
|
|
||||||
// If we found the content in EventStore, we can return early
|
// Fetch highlights in background if relayPool is available
|
||||||
// This prevents unnecessary relay queries when offline
|
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')
|
console.log('[article-loader] Returning early with EventStore content')
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
@@ -242,10 +267,41 @@ export function useArticleLoader({
|
|||||||
// Ignore store errors, fall through to relay query
|
// Ignore store errors, fall through to relay query
|
||||||
console.warn('[article-loader] EventStore check failed:', err)
|
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
|
// At this point, we've checked EventStore and cache - neither had content
|
||||||
// Only show loading skeleton if we also don't have preview data
|
// Only show loading skeleton if we also don't have preview data
|
||||||
if (previewData) {
|
if (previewData) {
|
||||||
@@ -359,7 +415,7 @@ export function useArticleLoader({
|
|||||||
author: evt.pubkey,
|
author: evt.pubkey,
|
||||||
event: evt
|
event: evt
|
||||||
}
|
}
|
||||||
saveToCache(naddr, articleContent)
|
saveToCache(naddr, articleContent, settings)
|
||||||
|
|
||||||
// Preload image to ensure it's cached by Service Worker
|
// Preload image to ensure it's cached by Service Worker
|
||||||
if (image) {
|
if (image) {
|
||||||
@@ -526,11 +582,13 @@ export function useArticleLoader({
|
|||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false
|
mountedRef.current = false
|
||||||
}
|
}
|
||||||
// Dependencies intentionally excluded to prevent re-renders when relay/eventStore state changes
|
// Include relayPool in dependencies so effect re-runs when it becomes available
|
||||||
// This fixes the loading skeleton appearing when going offline (flight mode)
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
naddr,
|
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 {
|
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)
|
const cacheKey = getCacheKey(naddr)
|
||||||
console.log('[article-cache] 💾 Saving to cache', {
|
console.log('[article-cache] 💾 Saving to cache', {
|
||||||
key: cacheKey,
|
key: cacheKey,
|
||||||
@@ -87,8 +125,16 @@ export function saveToCache(naddr: string, content: ArticleContent): void {
|
|||||||
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
||||||
console.log('[article-cache] ✅ Successfully saved to cache')
|
console.log('[article-cache] ✅ Successfully saved to cache')
|
||||||
} catch (err) {
|
} 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)
|
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
|
// Save to cache before returning
|
||||||
saveToCache(naddr, content)
|
saveToCache(naddr, content, settings)
|
||||||
|
|
||||||
// Image caching is handled automatically by Service Worker
|
// Image caching is handled automatically by Service Worker
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NostrEvent } from 'nostr-tools'
|
|||||||
import { Helpers, IEventStore } from 'applesauce-core'
|
import { Helpers, IEventStore } from 'applesauce-core'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { cacheArticleEvent } from './articleService'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
@@ -75,6 +76,9 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
}
|
}
|
||||||
onPost(post)
|
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 timeB - timeA // Most recent first
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
return blogPosts
|
return blogPosts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch blog posts:', error)
|
console.error('Failed to fetch blog posts:', error)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { BlogPostPreview } from './exploreService'
|
|||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { cacheArticleEvent } from './articleService'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
@@ -57,6 +58,9 @@ export const fetchNostrverseBlogPosts = async (
|
|||||||
}
|
}
|
||||||
onPost(post)
|
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 timeB - timeA // Most recent first
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
return blogPosts
|
return blogPosts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch nostrverse blog posts:', error)
|
console.error('Failed to fetch nostrverse blog posts:', error)
|
||||||
|
|||||||
Reference in New Issue
Block a user