Compare commits

...

2 Commits

Author SHA1 Message Date
Gigi
8f2ecd5fe1 chore: bump version to 0.3.2 2025-10-09 17:49:08 +01:00
Gigi
d6be6f364b refactor: migrate image cache from localStorage to Cache API
BREAKING CHANGE: Image cache now uses Cache API instead of localStorage

Benefits:
- Support for actual 210MB cache size (localStorage limited to 5-10MB)
- Store native Response objects (no base64 overhead)
- Asynchronous, non-blocking operations
- Better suited for large binary blobs like images
- Can handle hundreds of MB to several GB

Changes:
- Rewrite imageCacheService to use Cache API for image storage
- Keep metadata in localStorage for LRU tracking (small footprint)
- Update useImageCache hook to handle async Cache API
- Add blob URL cleanup to prevent memory leaks
- Update clearImageCache to async function

The cache now works as advertised and won't hit quota limits.
2025-10-09 17:48:59 +01:00
4 changed files with 130 additions and 145 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "boris", "name": "boris",
"version": "0.3.1", "version": "0.3.2",
"description": "A minimal nostr client for bookmark management", "description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/", "homepage": "https://read.withboris.com/",
"type": "module", "type": "module",

View File

@@ -20,9 +20,9 @@ const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onU
navigate(`/r/${encodeURIComponent(url)}`) navigate(`/r/${encodeURIComponent(url)}`)
} }
const handleClearCache = () => { const handleClearCache = async () => {
if (confirm('Are you sure you want to clear all cached images?')) { if (confirm('Are you sure you want to clear all cached images?')) {
clearImageCache() await clearImageCache()
setCacheStats(getImageCacheStats()) setCacheStats(getImageCacheStats())
} }
} }

View File

@@ -1,13 +1,13 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { cacheImage, getCachedImage } from '../services/imageCacheService' import { cacheImage, getCachedImage, loadCachedImage } from '../services/imageCacheService'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
/** /**
* Hook to cache and retrieve images from localStorage * Hook to cache and retrieve images using Cache API
* *
* @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 data URL or the original URL * @returns The cached blob URL or the original URL
*/ */
export function useImageCache( export function useImageCache(
imageUrl: string | undefined, imageUrl: string | undefined,
@@ -29,33 +29,59 @@ export function useImageCache(
return return
} }
// Check if already cached // Store imageUrl in local variable for closure
const cached = getCachedImage(imageUrl) const urlToCache = imageUrl
if (cached) {
console.log('📦 Using cached image:', imageUrl.substring(0, 50))
setCachedUrl(cached)
return
}
// Otherwise, show original URL while caching in background // Check if image is in cache metadata (fast synchronous check)
setCachedUrl(imageUrl) const isCached = getCachedImage(urlToCache)
// Cache image in background if (isCached) {
if (!isLoading) { // Load the cached image asynchronously
setIsLoading(true) loadCachedImage(urlToCache)
const maxSize = settings?.imageCacheSizeMB ?? 210 .then(blobUrl => {
if (blobUrl) {
cacheImage(imageUrl, maxSize) console.log('📦 Using cached image:', urlToCache.substring(0, 50))
.then(dataUrl => { setCachedUrl(blobUrl)
setCachedUrl(dataUrl) } else {
// Not actually cached, fall through to caching logic
setCachedUrl(urlToCache)
cacheInBackground()
}
}) })
.catch(err => { .catch(err => {
console.error('Failed to cache image:', err) console.error('Failed to load cached image:', err)
// Keep using original URL on error setCachedUrl(urlToCache)
})
.finally(() => {
setIsLoading(false)
}) })
} else {
// Not cached, show original and cache in background
setCachedUrl(urlToCache)
cacheInBackground()
}
function cacheInBackground() {
if (!isLoading) {
setIsLoading(true)
const maxSize = settings?.imageCacheSizeMB ?? 210
cacheImage(urlToCache, maxSize)
.then(blobUrl => {
setCachedUrl(blobUrl)
})
.catch(err => {
console.error('Failed to cache image:', err)
// Keep using original URL on error
})
.finally(() => {
setIsLoading(false)
})
}
}
// 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])
@@ -64,7 +90,7 @@ export function useImageCache(
/** /**
* Simpler hook variant that just caches on mount if enabled * Simpler hook variant that just caches on mount if enabled
* Useful for article cover images * Useful for preloading article cover images
*/ */
export function useCacheImageOnLoad( export function useCacheImageOnLoad(
imageUrl: string | undefined, imageUrl: string | undefined,
@@ -76,9 +102,9 @@ export function useCacheImageOnLoad(
const enableCache = settings?.enableImageCache ?? true const enableCache = settings?.enableImageCache ?? true
if (!enableCache) return if (!enableCache) return
// Check if already cached // Check if already cached (fast metadata check)
const cached = getCachedImage(imageUrl) const isCached = getCachedImage(imageUrl)
if (cached) return if (isCached) return
// Cache in background // Cache in background
const maxSize = settings?.imageCacheSizeMB ?? 210 const maxSize = settings?.imageCacheSizeMB ?? 210

View File

@@ -1,23 +1,22 @@
/** /**
* Image Cache Service * Image Cache Service
* *
* Caches images in localStorage for offline access. * Caches images using the Cache API for offline access.
* Uses LRU (Least Recently Used) eviction when cache size limit is exceeded. * Uses LRU (Least Recently Used) eviction when cache size limit is exceeded.
*/ */
const CACHE_PREFIX = 'img_cache_' const CACHE_NAME = 'boris-image-cache-v1'
const CACHE_METADATA_KEY = 'img_cache_metadata' const CACHE_METADATA_KEY = 'img_cache_metadata'
interface CacheMetadata { interface CacheMetadata {
[url: string]: { [url: string]: {
key: string
size: number size: number
lastAccessed: number lastAccessed: number
} }
} }
/** /**
* Get cache metadata * Get cache metadata from localStorage
*/ */
function getMetadata(): CacheMetadata { function getMetadata(): CacheMetadata {
try { try {
@@ -29,7 +28,7 @@ function getMetadata(): CacheMetadata {
} }
/** /**
* Save cache metadata * Save cache metadata to localStorage
*/ */
function saveMetadata(metadata: CacheMetadata): void { function saveMetadata(metadata: CacheMetadata): void {
try { try {
@@ -61,24 +60,10 @@ function mbToBytes(mb: number): number {
return mb * 1024 * 1024 return mb * 1024 * 1024
} }
/**
* Generate cache key for URL
*/
function getCacheKey(url: string): string {
// Use a simple hash of the URL
let hash = 0
for (let i = 0; i < url.length; i++) {
const char = url.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash
}
return `${CACHE_PREFIX}${Math.abs(hash)}`
}
/** /**
* Evict least recently used images until cache is under limit * Evict least recently used images until cache is under limit
*/ */
function evictLRU(maxSizeBytes: number): void { async function evictLRU(maxSizeBytes: number): Promise<void> {
const metadata = getMetadata() const metadata = getMetadata()
const entries = Object.entries(metadata) const entries = Object.entries(metadata)
@@ -86,12 +71,13 @@ function evictLRU(maxSizeBytes: number): void {
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed) entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
let currentSize = getTotalCacheSize() let currentSize = getTotalCacheSize()
const cache = await caches.open(CACHE_NAME)
for (const [url, item] of entries) { for (const [url, item] of entries) {
if (currentSize <= maxSizeBytes) break if (currentSize <= maxSizeBytes) break
try { try {
localStorage.removeItem(item.key) await cache.delete(url)
delete metadata[url] delete metadata[url]
currentSize -= item.size currentSize -= item.size
console.log(`🗑️ Evicted image from cache: ${url.substring(0, 50)}...`) console.log(`🗑️ Evicted image from cache: ${url.substring(0, 50)}...`)
@@ -104,50 +90,32 @@ function evictLRU(maxSizeBytes: number): void {
} }
/** /**
* Fetch image and convert to data URL * Cache an image using Cache API
*/
async function fetchImageAsDataUrl(url: string): Promise<string> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`)
}
const blob = await response.blob()
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error('Failed to convert image to data URL'))
}
}
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
/**
* Cache an image
*/ */
export async function cacheImage( export async function cacheImage(
url: string, url: string,
maxCacheSizeMB: number = 50 maxCacheSizeMB: number = 210
): Promise<string> { ): Promise<string> {
try { try {
// Check if already cached // Check if already cached
const cached = getCachedImage(url) const cached = await getCachedImageUrl(url)
if (cached) { if (cached) {
console.log('✅ Image already cached:', url.substring(0, 50)) console.log('✅ Image already cached:', url.substring(0, 50))
return cached return cached
} }
// Fetch and convert to data URL // Fetch the image
console.log('📥 Caching image:', url.substring(0, 50)) console.log('📥 Caching image:', url.substring(0, 50))
const dataUrl = await fetchImageAsDataUrl(url) const response = await fetch(url)
const size = dataUrl.length
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`)
}
// Clone the response so we can read it twice (once for size, once for cache)
const responseClone = response.clone()
const blob = await response.blob()
const size = blob.size
// Check if image alone exceeds cache limit // Check if image alone exceeds cache limit
if (bytesToMB(size) > maxCacheSizeMB) { if (bytesToMB(size) > maxCacheSizeMB) {
@@ -160,43 +128,25 @@ export async function cacheImage(
// Evict old images if necessary // Evict old images if necessary
const currentSize = getTotalCacheSize() const currentSize = getTotalCacheSize()
if (currentSize + size > maxSizeBytes) { if (currentSize + size > maxSizeBytes) {
evictLRU(maxSizeBytes - size) await evictLRU(maxSizeBytes - size)
} }
// Store image // Store in Cache API
const key = getCacheKey(url) const cache = await caches.open(CACHE_NAME)
await cache.put(url, responseClone)
// Update metadata
const metadata = getMetadata() const metadata = getMetadata()
metadata[url] = {
try { size,
localStorage.setItem(key, dataUrl) lastAccessed: Date.now()
metadata[url] = {
key,
size,
lastAccessed: Date.now()
}
saveMetadata(metadata)
console.log(`💾 Cached image (${bytesToMB(size).toFixed(2)}MB). Total cache: ${bytesToMB(getTotalCacheSize()).toFixed(2)}MB`)
return dataUrl
} catch (err) {
// If storage fails, try evicting more and retry once
console.warn('Storage full, evicting more items...')
evictLRU(maxSizeBytes / 2) // Free up half the cache
try {
localStorage.setItem(key, dataUrl)
metadata[url] = {
key,
size,
lastAccessed: Date.now()
}
saveMetadata(metadata)
return dataUrl
} catch {
console.error('Failed to cache image after eviction')
return url // Return original URL on failure
}
} }
saveMetadata(metadata)
console.log(`💾 Cached image (${bytesToMB(size).toFixed(2)}MB). Total cache: ${bytesToMB(getTotalCacheSize()).toFixed(2)}MB`)
// Return blob URL for immediate use
return URL.createObjectURL(blob)
} catch (err) { } catch (err) {
console.error('Failed to cache image:', err) console.error('Failed to cache image:', err)
return url // Return original URL on error return url // Return original URL on error
@@ -204,50 +154,53 @@ export async function cacheImage(
} }
/** /**
* Get cached image * Get cached image URL (creates blob URL from cached response)
*/ */
export function getCachedImage(url: string): string | null { async function getCachedImageUrl(url: string): Promise<string | null> {
try { try {
const metadata = getMetadata() const cache = await caches.open(CACHE_NAME)
const item = metadata[url] const response = await cache.match(url)
if (!item) return null if (!response) {
const dataUrl = localStorage.getItem(item.key)
if (!dataUrl) {
// Clean up stale metadata
delete metadata[url]
saveMetadata(metadata)
return null return null
} }
// Update last accessed time // Update last accessed time in metadata
item.lastAccessed = Date.now() const metadata = getMetadata()
metadata[url] = item if (metadata[url]) {
saveMetadata(metadata) metadata[url].lastAccessed = Date.now()
saveMetadata(metadata)
}
return dataUrl // Convert response to blob URL
const blob = await response.blob()
return URL.createObjectURL(blob)
} catch { } catch {
return null return null
} }
} }
/**
* Get cached image (synchronous wrapper that returns null, actual loading happens async)
* This maintains backward compatibility with the hook's synchronous check
*/
export function getCachedImage(url: string): string | null {
// Check if we have metadata for this URL
const metadata = getMetadata()
return metadata[url] ? url : null // Return URL if in metadata, let hook handle async loading
}
/** /**
* Clear all cached images * Clear all cached images
*/ */
export function clearImageCache(): void { export async function clearImageCache(): Promise<void> {
try { try {
const metadata = getMetadata() // Clear from Cache API
await caches.delete(CACHE_NAME)
for (const item of Object.values(metadata)) {
try {
localStorage.removeItem(item.key)
} catch (err) {
console.warn('Failed to remove cached image:', err)
}
}
// Clear metadata from localStorage
localStorage.removeItem(CACHE_METADATA_KEY) localStorage.removeItem(CACHE_METADATA_KEY)
console.log('🗑️ Cleared all cached images') console.log('🗑️ Cleared all cached images')
} catch (err) { } catch (err) {
console.error('Failed to clear image cache:', err) console.error('Failed to clear image cache:', err)
@@ -276,3 +229,9 @@ export function getImageCacheStats(): {
} }
} }
/**
* Load cached image asynchronously (for use in hooks/components)
*/
export async function loadCachedImage(url: string): Promise<string | null> {
return getCachedImageUrl(url)
}