feat: add Service Worker for robust offline image caching

- Implement Service Worker to intercept and cache image requests
- Service Worker persists across hard reloads unlike Cache API alone
- Simplify useImageCache hook to work with Service Worker
- Images now work offline even after hard reload
- Service Worker handles transparent cache-first serving for images
This commit is contained in:
Gigi
2025-10-09 18:17:27 +01:00
parent b20a67d4d0
commit 1e8182d984
3 changed files with 102 additions and 68 deletions

56
public/sw.js Normal file
View File

@@ -0,0 +1,56 @@
// Service Worker for Boris - handles offline image caching
const CACHE_NAME = 'boris-image-cache-v1'
// Install event - activate immediately
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...')
self.skipWaiting()
})
// Activate event - take control immediately
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...')
event.waitUntil(self.clients.claim())
})
// Fetch event - intercept image requests
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// Only intercept image requests
const isImage = event.request.destination === 'image' ||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
if (!isImage) {
return // Let other requests pass through
}
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(cachedResponse => {
if (cachedResponse) {
console.log('[SW] Serving cached image:', url.pathname)
return cachedResponse
}
// Not in cache, try to fetch
return fetch(event.request)
.then(response => {
// Only cache successful responses
if (response && response.status === 200) {
// Clone the response before caching
cache.put(event.request, response.clone())
console.log('[SW] Cached new image:', url.pathname)
}
return response
})
.catch(error => {
console.error('[SW] Fetch failed for:', url.pathname, error)
// Return a fallback or let it fail
throw error
})
})
})
)
})

View File

@@ -1,94 +1,46 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { cacheImage, getCachedImage, loadCachedImage } from '../services/imageCacheService' import { cacheImage, getCachedImage } from '../services/imageCacheService'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
/** /**
* Hook to cache and retrieve images using Cache API * Hook to pre-cache images and return the URL for display
* With Service Worker active, images are automatically cached and served offline
* This hook ensures proactive caching for better offline experience
* *
* @param imageUrl - The URL of the image to cache * @param imageUrl - The URL of the image to cache
* @param settings - User settings to determine if caching is enabled * @param settings - User settings to determine if caching is enabled
* @returns The cached blob URL or the original URL * @returns The image URL (Service Worker handles caching transparently)
*/ */
export function useImageCache( export function useImageCache(
imageUrl: string | undefined, imageUrl: string | undefined,
settings: UserSettings | undefined settings: UserSettings | undefined
): string | undefined { ): string | undefined {
const [cachedUrl, setCachedUrl] = useState<string | undefined>(imageUrl) const [displayUrl, setDisplayUrl] = useState<string | undefined>(imageUrl)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => { useEffect(() => {
if (!imageUrl) { if (!imageUrl) {
setCachedUrl(undefined) setDisplayUrl(undefined)
return return
} }
// If caching is disabled, just use the original URL // Always show the original URL - Service Worker will serve from cache if available
const enableCache = settings?.enableImageCache ?? true // Default to enabled setDisplayUrl(imageUrl)
if (!enableCache) {
setCachedUrl(imageUrl) // If caching is disabled, don't pre-cache
const enableCache = settings?.enableImageCache ?? true
if (!enableCache || !navigator.onLine) {
return return
} }
// Store imageUrl in local variable for closure // Pre-cache the image for offline availability
const urlToCache = imageUrl // Service Worker will handle serving it, but we ensure it's cached
const isOffline = !navigator.onLine
// When online: show original URL first for immediate display
// When offline: don't show anything until we load from cache
if (!isOffline) {
setCachedUrl(urlToCache)
}
// Try to load from cache asynchronously
loadCachedImage(urlToCache)
.then(blobUrl => {
if (blobUrl) {
console.log('📦 Using cached image:', urlToCache.substring(0, 50))
setCachedUrl(blobUrl)
} else if (!isOffline) {
// Not cached and online - cache it now
if (!isLoading) {
setIsLoading(true)
const maxSize = settings?.imageCacheSizeMB ?? 210 const maxSize = settings?.imageCacheSizeMB ?? 210
cacheImage(imageUrl, maxSize).catch(err => {
cacheImage(urlToCache, maxSize) console.warn('Failed to pre-cache image:', err)
.then(newBlobUrl => {
// Only update if we got a blob URL back
if (newBlobUrl && newBlobUrl.startsWith('blob:')) {
setCachedUrl(newBlobUrl)
}
}) })
.catch(err => {
console.error('Failed to cache image:', err)
// Keep using original URL on error
})
.finally(() => {
setIsLoading(false)
})
}
} else {
// Offline and not cached - no image available
console.warn('⚠️ Image not available offline:', urlToCache.substring(0, 50))
setCachedUrl(undefined)
}
})
.catch(err => {
console.error('Failed to load cached image:', err)
// If online, fall back to original URL
if (!isOffline) {
setCachedUrl(urlToCache)
}
})
// Cleanup: revoke blob URLs when component unmounts or URL changes
return () => {
if (cachedUrl && cachedUrl.startsWith('blob:')) {
URL.revokeObjectURL(cachedUrl)
}
}
}, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB]) }, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB])
return cachedUrl return displayUrl
} }
/** /**

View File

@@ -3,6 +3,32 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
import './index.css' import './index.css'
// Register Service Worker for offline image caching
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then(registration => {
console.log('✅ Service Worker registered:', registration.scope)
// Update service worker when a new version is available
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
console.log('🔄 Service Worker updated, page may need reload')
}
})
}
})
})
.catch(error => {
console.error('❌ Service Worker registration failed:', error)
})
})
}
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />