From c20682fbe8b0250d6fa6c4b6c3751f03928ddebc Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 31 Oct 2025 01:24:58 +0100 Subject: [PATCH] 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 --- src/hooks/useArticleLoader.ts | 188 +++++++++++++++++++----------- src/services/articleService.ts | 54 ++++++++- src/services/exploreService.ts | 5 +- src/services/nostrverseService.ts | 5 +- 4 files changed, 181 insertions(+), 71 deletions(-) diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index 64d8d48b..0ab4c08b 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -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 ]) } diff --git a/src/services/articleService.ts b/src/services/articleService.ts index a6c8807b..6fb72829 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -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 diff --git a/src/services/exploreService.ts b/src/services/exploreService.ts index 06253e77..08eea1b8 100644 --- a/src/services/exploreService.ts +++ b/src/services/exploreService.ts @@ -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) diff --git a/src/services/nostrverseService.ts b/src/services/nostrverseService.ts index 537d3ce3..2874f1cc 100644 --- a/src/services/nostrverseService.ts +++ b/src/services/nostrverseService.ts @@ -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)