Merge pull request #30 from dergigi/long-form-loading-shenanigans

fix: article loading race condition and improve caching
This commit is contained in:
Gigi
2025-10-31 01:58:02 +01:00
committed by GitHub
15 changed files with 511 additions and 81 deletions

47
public/sw-dev.js Normal file
View File

@@ -0,0 +1,47 @@
// Development Service Worker - simplified version for testing image caching
// This is served in dev mode when vite-plugin-pwa doesn't serve the injectManifest SW
self.addEventListener('install', (event) => {
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim())
})
// Image caching - simple version for dev testing
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
const isImage = event.request.destination === 'image' ||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
if (isImage) {
event.respondWith(
caches.open('boris-images-dev').then((cache) => {
return cache.match(event.request).then((cachedResponse) => {
// Try to fetch from network
return fetch(event.request).then((response) => {
// If fetch succeeds, cache it and return
if (response.ok) {
cache.put(event.request, response.clone()).catch(() => {
// Ignore cache put errors
})
}
return response
}).catch((error) => {
// If fetch fails (network error, CORS, etc.), return cached response if available
if (cachedResponse) {
return cachedResponse
}
// No cache available, reject the promise so browser handles it
return Promise.reject(error)
})
})
}).catch(() => {
// If cache operations fail, try to fetch directly without caching
return fetch(event.request)
})
)
}
})

View File

@@ -18,6 +18,12 @@ interface BlogPostCardProps {
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress, hideBotByName = true }) => {
const profile = useEventModel(Models.ProfileModel, [post.author])
// Note: Images are lazy-loaded (loading="lazy" below), so they'll be fetched
// when they come into view. The Service Worker will cache them automatically.
// No need to preload all images at once - this causes ERR_INSUFFICIENT_RESOURCES
// when there are many blog posts.
const displayName = profile?.name || profile?.display_name ||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
const rawName = (profile?.name || profile?.display_name || '').toLowerCase()
@@ -41,7 +47,7 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
}
// Debug log - reading progress shown as visual indicator
if (readingProgress !== undefined) {
// Reading progress display

View File

@@ -263,6 +263,23 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const restoreKey = `${articleIdentifier}-${isTrackingEnabled}`
const hasAttemptedRestoreRef = useRef<string | null>(null)
// Reset scroll position and restore ref when article changes
useEffect(() => {
if (!articleIdentifier) return
// Suppress saves during navigation to prevent saving 0% position
// The 500ms suppression covers the scroll reset and initial render
if (suppressSavesForRef.current) {
suppressSavesForRef.current(500)
}
// Reset scroll to top when article identifier changes
// This prevents showing wrong scroll position from previous article
window.scrollTo({ top: 0, behavior: 'instant' })
// Reset restore attempt tracking for new article
hasAttemptedRestoreRef.current = null
}, [articleIdentifier])
useEffect(() => {
if (!isTextContent || !activeAccount || !articleIdentifier) {
return

View File

@@ -80,7 +80,13 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
<>
<div className="reader-hero-image">
{cachedImage ? (
<img src={cachedImage} alt={title || 'Article image'} />
<img
src={cachedImage}
alt={title || 'Article image'}
onError={(e) => {
console.error('[reader-header] Image failed to load:', cachedImage, e)
}}
/>
) : (
<div className="reader-hero-placeholder">
<FontAwesomeIcon icon={faNewspaper} />

View File

@@ -7,6 +7,7 @@ import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import IconButton from './IconButton'
import { faBooks } from '../icons/customIcons'
import { preloadImage } from '../hooks/useImageCache'
interface SidebarHeaderProps {
onToggleCollapse: () => void
@@ -36,6 +37,13 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
const profileImage = getProfileImage()
// Preload profile image for offline access
useEffect(() => {
if (profileImage) {
preloadImage(profileImage)
}
}, [profileImage])
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {

View File

@@ -6,8 +6,9 @@ import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { Helpers } from 'applesauce-core'
import { queryEvents } from '../services/dataFetch'
import { fetchArticleByNaddr } from '../services/articleService'
import { fetchArticleByNaddr, getFromCache, saveToCache } from '../services/articleService'
import { fetchHighlightsForArticle } from '../services/highlightService'
import { preloadImage } from './useImageCache'
import { ReadableContent } from '../services/readerService'
import { Highlight } from '../types/highlights'
import { NostrEvent } from 'nostr-tools'
@@ -72,11 +73,201 @@ export function useArticleLoader({
useEffect(() => {
mountedRef.current = true
if (!relayPool || !naddr) return
// First check: naddr is required
if (!naddr) {
setReaderContent(undefined)
return
}
// Clear readerContent immediately to prevent showing stale content from previous article
// This ensures images from previous articles don't flash briefly
setReaderContent(undefined)
// Synchronously check cache sources BEFORE checking relayPool
// This prevents showing loading skeletons when content is immediately available
// and fixes the race condition where relayPool isn't ready yet
let foundInCache = false
try {
// Check localStorage cache first (synchronous, doesn't need relayPool)
const cachedArticle = getFromCache(naddr)
if (cachedArticle) {
foundInCache = true
const title = cachedArticle.title || 'Untitled Article'
setCurrentTitle(title)
setReaderContent({
title,
markdown: cachedArticle.markdown,
image: cachedArticle.image,
summary: cachedArticle.summary,
published: cachedArticle.published,
url: `nostr:${naddr}`
})
const dTag = cachedArticle.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${cachedArticle.event.kind}:${cachedArticle.author}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(cachedArticle.event.id)
setCurrentArticle?.(cachedArticle.event)
setReaderLoading(false)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// Preload image if available to ensure it's cached by Service Worker
// This ensures images are available when offline
if (cachedArticle.image) {
preloadImage(cachedArticle.image)
}
// Store in EventStore for future lookups
if (eventStore) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventStore.add?.(cachedArticle.event as unknown as any)
} catch {
// Silently ignore store errors
}
}
// Fetch highlights in background (don't block UI)
// 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) {
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 cached content, no need to query relays
return
}
} catch (err) {
// If cache check fails, fall through to async loading
console.warn('[article-loader] Cache check failed:', err)
}
// Check EventStore synchronously (also doesn't need relayPool)
let foundInEventStore = false
if (eventStore && !foundInCache) {
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}`
const storedEvent = eventStore.getEvent?.(coordinate)
if (storedEvent) {
foundInEventStore = true
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
return
}
}
} 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) {
setReaderLoading(true)
setReaderContent(undefined)
return
}
// If we have relayPool, proceed with async loading
if (!relayPool) {
return
}
const loadArticle = async () => {
const requestId = ++currentRequestIdRef.current
if (!mountedRef.current) return
if (!mountedRef.current) {
return
}
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
@@ -85,62 +276,28 @@ export function useArticleLoader({
// when we know the article coordinate
setHighlightsLoading(false) // Don't show loading yet
// Check eventStore first for instant load (from bookmark cards, explore, etc.)
let foundInStore = false
if (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}`
const storedEvent = eventStore.getEvent?.(coordinate)
if (storedEvent) {
foundInStore = true
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
return
}
}
} catch (err) {
// Ignore store errors, fall through to relay query
}
}
// Note: Cache and EventStore were already checked synchronously above
// This async function only runs if we need to fetch from relays
// If we have preview data from navigation, show it immediately (no skeleton!)
// 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) {
// If we have preview data from navigation, show it immediately (no skeleton!)
setCurrentTitle(previewData.title)
setReaderContent({
title: previewData.title,
markdown: '', // Will be loaded from store or relay
markdown: '', // Will be loaded from relay
image: previewData.image,
summary: previewData.summary,
published: previewData.published,
url: `nostr:${naddr}`
})
setReaderLoading(false) // Turn off loading immediately - we have the preview!
} else if (!foundInStore) {
// Only show loading if we didn't find content in store and no preview data
// Don't preload image here - it should already be cached from BlogPostCard
// Preloading again would be redundant and could cause unnecessary network requests
} else {
// No cache, no EventStore, no preview data - need to load from relays
setReaderLoading(true)
setReaderContent(undefined)
}
@@ -164,8 +321,12 @@ export function useArticleLoader({
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
const events = await queryEvents(relayPool, filter, {
onEvent: (evt) => {
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
if (!mountedRef.current) {
return
}
if (currentRequestIdRef.current !== requestId) {
return
}
// Store in event store for future local reads
try {
@@ -184,10 +345,11 @@ export function useArticleLoader({
if (!firstEmitted) {
firstEmitted = true
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(evt)
const summary = Helpers.getArticleSummary(evt)
const published = Helpers.getArticlePublished(evt)
setCurrentTitle(title)
setReaderContent({
title,
markdown: evt.content,
@@ -202,20 +364,41 @@ export function useArticleLoader({
setCurrentArticleEventId(evt.id)
setCurrentArticle?.(evt)
setReaderLoading(false)
// Save to cache immediately when we get the first event
// Don't wait for queryEvents to complete in case it hangs
const articleContent = {
title,
markdown: evt.content,
image,
summary,
published,
author: evt.pubkey,
event: evt
}
saveToCache(naddr, articleContent, settings)
// Preload image to ensure it's cached by Service Worker
if (image) {
preloadImage(image)
}
}
}
})
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
if (!mountedRef.current || currentRequestIdRef.current !== requestId) {
return
}
// Finalize with newest version if it's newer than what we first rendered
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
if (finalEvent) {
const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(finalEvent)
const summary = Helpers.getArticleSummary(finalEvent)
const published = Helpers.getArticlePublished(finalEvent)
setCurrentTitle(title)
setReaderContent({
title,
markdown: finalEvent.content,
@@ -230,6 +413,23 @@ export function useArticleLoader({
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(finalEvent.id)
setCurrentArticle?.(finalEvent)
// Save to cache for future loads (if we haven't already saved from first event)
// Only save if this is a different/newer event than what we first rendered
// Note: We already saved from first event, so only save if this is different
if (!firstEmitted) {
// First event wasn't emitted, so save now
const articleContent = {
title,
markdown: finalEvent.content,
image,
summary,
published,
author: finalEvent.pubkey,
event: finalEvent
}
saveToCache(naddr, articleContent)
}
} else {
// As a last resort, fall back to the legacy helper (which includes cache)
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
@@ -315,11 +515,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

@@ -10,6 +10,8 @@ export function useImageCache(
imageUrl: string | undefined
): string | undefined {
// Service Worker handles everything - just return the URL as-is
// The Service Worker will intercept fetch requests and cache them
// Make sure images use standard <img src> tags for SW interception
return imageUrl
}
@@ -26,3 +28,26 @@ export function useCacheImageOnLoad(
void imageUrl
}
/**
* Preload an image URL to ensure it's cached by the Service Worker
* This is useful when loading content from cache - we want to ensure
* images are cached before going offline
*/
export function preloadImage(imageUrl: string | undefined): void {
if (!imageUrl) {
return
}
// Create a link element with rel=prefetch or use Image object to trigger fetch
// Service Worker will intercept and cache the request
const img = new Image()
img.src = imageUrl
// Also try using fetch to explicitly trigger Service Worker
// This ensures the image is cached even if <img> tag hasn't rendered yet
fetch(imageUrl, { mode: 'no-cors' }).catch(() => {
// Ignore errors - image might not be CORS-enabled, but SW will still cache it
// The Image() approach above will work for most cases
})
}

View File

@@ -5,16 +5,60 @@ import './styles/tailwind.css'
import './index.css'
import 'react-loading-skeleton/dist/skeleton.css'
// Register Service Worker for PWA functionality (production only)
if ('serviceWorker' in navigator && import.meta.env.PROD) {
// Register Service Worker for PWA functionality
// With injectRegister: null, we need to register manually
// With devOptions.enabled: true, vite-plugin-pwa serves SW in dev mode too
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
const swPath = '/sw.js'
// Check if already registered/active first
navigator.serviceWorker.getRegistrations().then(async (registrations) => {
if (registrations.length > 0) {
return registrations[0]
}
// Not registered yet, try to register
// In dev mode, use the dev Service Worker for testing
if (import.meta.env.DEV) {
const devSwPath = '/sw-dev.js'
try {
// Check if dev SW exists
const response = await fetch(devSwPath)
const contentType = response.headers.get('content-type') || ''
const isJavaScript = contentType.includes('javascript') || contentType.includes('application/javascript')
if (response.ok && isJavaScript) {
return await navigator.serviceWorker.register(devSwPath, { scope: '/' })
} else {
console.warn('[sw-registration] Development Service Worker not available')
return null
}
} catch (err) {
console.warn('[sw-registration] Could not load development Service Worker:', err)
return null
}
} else {
// In production, just register directly
return await navigator.serviceWorker.register(swPath)
}
})
.then(registration => {
// Check for updates periodically
setInterval(() => {
registration.update()
}, 60 * 60 * 1000) // Check every hour
if (!registration) return
// Wait for Service Worker to activate
if (registration.installing) {
registration.installing.addEventListener('statechange', () => {
// Service Worker state changed
})
}
// Check for updates periodically (production only)
if (import.meta.env.PROD) {
setInterval(() => {
registration.update()
}, 60 * 60 * 1000) // Check every hour
}
// Handle service worker updates
registration.addEventListener('updatefound', () => {
@@ -31,9 +75,22 @@ if ('serviceWorker' in navigator && import.meta.env.PROD) {
})
})
.catch(error => {
console.error('❌ Service Worker registration failed:', error)
console.error('[sw-registration] ❌ Service Worker registration failed:', error)
console.error('[sw-registration] Error details:', {
message: error.message,
name: error.name,
stack: error.stack
})
// In dev mode, this is expected if vite-plugin-pwa isn't serving the SW
if (import.meta.env.DEV) {
console.warn('[sw-registration] ⚠️ This is expected in dev mode if vite-plugin-pwa is not serving the SW file')
console.warn('[sw-registration] Image caching will not work in dev mode - test in production build')
}
})
})
} else {
console.warn('[sw-registration] ⚠️ Service Workers not supported in this browser')
}
ReactDOM.createRoot(document.getElementById('root')!).render(

View File

@@ -34,11 +34,13 @@ function getCacheKey(naddr: string): string {
return `${CACHE_PREFIX}${naddr}`
}
function getFromCache(naddr: string): ArticleContent | null {
export function getFromCache(naddr: string): ArticleContent | null {
try {
const cacheKey = getCacheKey(naddr)
const cached = localStorage.getItem(cacheKey)
if (!cached) return null
if (!cached) {
return null
}
const { content, timestamp }: CachedArticle = JSON.parse(cached)
const age = Date.now() - timestamp
@@ -49,12 +51,51 @@ function getFromCache(naddr: string): ArticleContent | null {
}
return content
} catch {
} catch (err) {
// Silently handle cache read errors
return null
}
}
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.
}
}
export function saveToCache(naddr: string, content: ArticleContent, settings?: UserSettings): void {
// 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
// Note: settings parameter reserved for future use
void settings // Mark as intentionally unused for now
try {
const cacheKey = getCacheKey(naddr)
const cached: CachedArticle = {
@@ -63,8 +104,8 @@ function saveToCache(naddr: string, content: ArticleContent): void {
}
localStorage.setItem(cacheKey, JSON.stringify(cached))
} catch (err) {
console.warn('Failed to cache article:', err)
// Silently fail if storage is full or unavailable
// Silently fail - don't block the UI if caching fails
// Handles quota exceeded, invalid data, and other errors gracefully
}
}
@@ -164,7 +205,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,7 +5,8 @@
* Service Worker automatically caches images on fetch
*/
const CACHE_NAME = 'boris-image-cache-v1'
// Must match the cache name in src/sw.ts
const CACHE_NAME = 'boris-images'
/**
* Clear all cached images

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)

View File

@@ -65,6 +65,10 @@ export const fetchProfiles = async (
const profiles = Array.from(profilesByPubkey.values())
// Note: We don't preload all profile images here to avoid ERR_INSUFFICIENT_RESOURCES
// Profile images will be cached by Service Worker when they're actually displayed.
// Only the logged-in user's profile image is preloaded (in SidebarHeader).
// Rebroadcast profiles to local/all relays based on settings
if (profiles.length > 0) {
await rebroadcastEvents(profiles, relayPool, settings)

View File

@@ -23,13 +23,15 @@ sw.skipWaiting()
clientsClaim()
// Runtime cache: Cross-origin images
// This preserves the existing image caching behavior
// Runtime cache: All images (cross-origin and same-origin)
// Cache both external images and any internal image assets
registerRoute(
({ request, url }) => {
const isImage = request.destination === 'image' ||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
return isImage && url.origin !== sw.location.origin
// Cache all images, not just cross-origin ones
// This ensures article images from any source get cached
return isImage
},
new StaleWhileRevalidate({
cacheName: 'boris-images',
@@ -41,6 +43,11 @@ registerRoute(
new CacheableResponsePlugin({
statuses: [0, 200],
}),
{
cacheWillUpdate: async ({ response }) => {
return response.ok ? response : null
}
}
],
})
)

View File

@@ -139,7 +139,10 @@ export default defineConfig({
},
devOptions: {
enabled: true,
type: 'module'
type: 'module',
// Use generateSW strategy for dev mode to enable SW testing
// This creates a working SW in dev mode, while injectManifest is used in production
navigateFallback: 'index.html'
}
})
],