fix PR comments

This commit is contained in:
Johnpaul
2025-08-04 19:57:58 +01:00
parent fbc44b31be
commit 6316dde209
3 changed files with 117 additions and 69 deletions

View File

@@ -31,7 +31,7 @@
<script setup lang="ts"> <script setup lang="ts">
import Skeleton from 'primevue/skeleton' import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver' import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useMediaCache } from '@/services/mediaCacheService' import { useMediaCache } from '@/services/mediaCacheService'
@@ -41,7 +41,7 @@ const {
alt = '', alt = '',
imageClass = '', imageClass = '',
imageStyle, imageStyle,
rootMargin = '50px' rootMargin = '300px'
} = defineProps<{ } = defineProps<{
src: string src: string
alt?: string alt?: string
@@ -57,7 +57,7 @@ const isImageLoaded = ref(false)
const hasError = ref(false) const hasError = ref(false)
const cachedSrc = ref<string | undefined>(undefined) const cachedSrc = ref<string | undefined>(undefined)
const { getCachedMedia } = useMediaCache() const { getCachedMedia, acquireUrl, releaseUrl } = useMediaCache()
// Use intersection observer to detect when the image container comes into view // Use intersection observer to detect when the image container comes into view
useIntersectionObserver( useIntersectionObserver(
@@ -75,7 +75,6 @@ useIntersectionObserver(
// Only start loading the image when it's in view // Only start loading the image when it's in view
const shouldLoad = computed(() => isIntersecting.value) const shouldLoad = computed(() => isIntersecting.value)
// Watch for when we should load and handle caching
watch( watch(
shouldLoad, shouldLoad,
async (shouldLoad) => { async (shouldLoad) => {
@@ -85,16 +84,19 @@ watch(
if (cachedMedia.error) { if (cachedMedia.error) {
hasError.value = true hasError.value = true
} else if (cachedMedia.objectUrl) { } else if (cachedMedia.objectUrl) {
cachedSrc.value = cachedMedia.objectUrl const acquiredUrl = acquireUrl(src)
cachedSrc.value = acquiredUrl || cachedMedia.objectUrl
} else { } else {
cachedSrc.value = src cachedSrc.value = src
} }
} catch (error) { } catch (error) {
console.warn('Failed to load cached media:', error) console.warn('Failed to load cached media:', error)
// Fallback to original src
cachedSrc.value = src cachedSrc.value = src
} }
} else if (!shouldLoad) { } else if (!shouldLoad) {
if (cachedSrc.value?.startsWith('blob:')) {
releaseUrl(src)
}
// Hide image when out of view // Hide image when out of view
isImageLoaded.value = false isImageLoaded.value = false
cachedSrc.value = undefined cachedSrc.value = undefined
@@ -113,4 +115,10 @@ const onImageError = () => {
hasError.value = true hasError.value = true
isImageLoaded.value = false isImageLoaded.value = false
} }
onUnmounted(() => {
if (cachedSrc.value?.startsWith('blob:')) {
releaseUrl(src)
}
})
</script> </script>

View File

@@ -1,4 +1,4 @@
import { shallowRef } from 'vue' import { reactive } from 'vue'
export interface CachedMedia { export interface CachedMedia {
src: string src: string
@@ -16,10 +16,11 @@ export interface MediaCacheOptions {
} }
class MediaCacheService { class MediaCacheService {
public cache = shallowRef(new Map<string, CachedMedia>()) public cache = reactive(new Map<string, CachedMedia>())
private readonly maxSize: number private readonly maxSize: number
private readonly maxAge: number private readonly maxAge: number
private cleanupInterval: number | null = null private cleanupInterval: number | null = null
private urlRefCount = new Map<string, number>()
constructor(options: MediaCacheOptions = {}) { constructor(options: MediaCacheOptions = {}) {
this.maxSize = options.maxSize ?? 100 this.maxSize = options.maxSize ?? 100
@@ -41,49 +42,58 @@ class MediaCacheService {
private cleanup() { private cleanup() {
const now = Date.now() const now = Date.now()
const cacheMap = this.cache.value
const keysToDelete: string[] = [] const keysToDelete: string[] = []
// Find expired entries // Find expired entries
for (const [key, entry] of Array.from(cacheMap.entries())) { for (const [key, entry] of Array.from(this.cache.entries())) {
if (now - entry.lastAccessed > this.maxAge) { if (now - entry.lastAccessed > this.maxAge) {
keysToDelete.push(key) // Only revoke object URL if no components are using it
// Revoke object URL to free memory
if (entry.objectUrl) { if (entry.objectUrl) {
URL.revokeObjectURL(entry.objectUrl) const refCount = this.urlRefCount.get(entry.objectUrl) || 0
if (refCount === 0) {
URL.revokeObjectURL(entry.objectUrl)
this.urlRefCount.delete(entry.objectUrl)
keysToDelete.push(key)
}
// Don't delete cache entry if URL is still in use
} else {
keysToDelete.push(key)
} }
} }
} }
// Remove expired entries // Remove expired entries
if (keysToDelete.length > 0) { keysToDelete.forEach((key) => this.cache.delete(key))
const newCache = new Map(cacheMap)
keysToDelete.forEach((key) => newCache.delete(key))
this.cache.value = newCache
}
// If still over size limit, remove oldest entries // If still over size limit, remove oldest entries that aren't in use
if (cacheMap.size > this.maxSize) { if (this.cache.size > this.maxSize) {
const entries = Array.from(cacheMap.entries()) const entries = Array.from(this.cache.entries())
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed) entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
const toRemove = entries.slice(0, cacheMap.size - this.maxSize) let removedCount = 0
const newCache = new Map(cacheMap) const targetRemoveCount = this.cache.size - this.maxSize
for (const [key, entry] of entries) {
if (removedCount >= targetRemoveCount) break
toRemove.forEach(([key, entry]) => {
if (entry.objectUrl) { if (entry.objectUrl) {
URL.revokeObjectURL(entry.objectUrl) const refCount = this.urlRefCount.get(entry.objectUrl) || 0
if (refCount === 0) {
URL.revokeObjectURL(entry.objectUrl)
this.urlRefCount.delete(entry.objectUrl)
this.cache.delete(key)
removedCount++
}
} else {
this.cache.delete(key)
removedCount++
} }
newCache.delete(key) }
})
this.cache.value = newCache
} }
} }
async getCachedMedia(src: string): Promise<CachedMedia> { async getCachedMedia(src: string): Promise<CachedMedia> {
const cacheMap = this.cache.value let entry = this.cache.get(src)
let entry = cacheMap.get(src)
if (entry) { if (entry) {
// Update last accessed time // Update last accessed time
@@ -99,9 +109,7 @@ class MediaCacheService {
} }
// Update cache with loading entry // Update cache with loading entry
const newCache = new Map(cacheMap) this.cache.set(src, entry)
newCache.set(src, entry)
this.cache.value = newCache
try { try {
// Fetch the media // Fetch the media
@@ -122,10 +130,7 @@ class MediaCacheService {
lastAccessed: Date.now() lastAccessed: Date.now()
} }
const finalCache = new Map(this.cache.value) this.cache.set(src, updatedEntry)
finalCache.set(src, updatedEntry)
this.cache.value = finalCache
return updatedEntry return updatedEntry
} catch (error) { } catch (error) {
console.warn('Failed to cache media:', src, error) console.warn('Failed to cache media:', src, error)
@@ -138,44 +143,45 @@ class MediaCacheService {
lastAccessed: Date.now() lastAccessed: Date.now()
} }
const errorCache = new Map(this.cache.value) this.cache.set(src, errorEntry)
errorCache.set(src, errorEntry)
this.cache.value = errorCache
return errorEntry return errorEntry
} }
} }
getCacheStats() { acquireUrl(src: string): string | undefined {
const cacheMap = this.cache.value const entry = this.cache.get(src)
return { if (entry?.objectUrl) {
size: cacheMap.size, const currentCount = this.urlRefCount.get(entry.objectUrl) || 0
maxSize: this.maxSize, this.urlRefCount.set(entry.objectUrl, currentCount + 1)
entries: Array.from(cacheMap.keys()) return entry.objectUrl
}
return undefined
}
releaseUrl(src: string): void {
const entry = this.cache.get(src)
if (entry?.objectUrl) {
const count = (this.urlRefCount.get(entry.objectUrl) || 1) - 1
if (count <= 0) {
URL.revokeObjectURL(entry.objectUrl)
this.urlRefCount.delete(entry.objectUrl)
// Remove from cache as well
this.cache.delete(src)
} else {
this.urlRefCount.set(entry.objectUrl, count)
}
} }
} }
clearCache() { clearCache() {
const cacheMap = this.cache.value
// Revoke all object URLs // Revoke all object URLs
for (const entry of Array.from(cacheMap.values())) { for (const entry of Array.from(this.cache.values())) {
if (entry.objectUrl) { if (entry.objectUrl) {
URL.revokeObjectURL(entry.objectUrl) URL.revokeObjectURL(entry.objectUrl)
} }
} }
this.cache.value = new Map() this.cache.clear()
} this.urlRefCount.clear()
preloadMedia(urls: string[]) {
// Preload media in the background without blocking
urls.forEach((url) => {
if (!this.cache.value.has(url)) {
// Don't await - fire and forget
this.getCachedMedia(url).catch(() => {
// Ignore preload errors
})
}
})
} }
destroy() { destroy() {
@@ -188,7 +194,7 @@ class MediaCacheService {
} }
// Global instance // Global instance
let mediaCacheInstance: MediaCacheService | null = null export let mediaCacheInstance: MediaCacheService | null = null
export function useMediaCache(options?: MediaCacheOptions) { export function useMediaCache(options?: MediaCacheOptions) {
if (!mediaCacheInstance) { if (!mediaCacheInstance) {
@@ -197,16 +203,15 @@ export function useMediaCache(options?: MediaCacheOptions) {
const getCachedMedia = (src: string) => const getCachedMedia = (src: string) =>
mediaCacheInstance!.getCachedMedia(src) mediaCacheInstance!.getCachedMedia(src)
const getCacheStats = () => mediaCacheInstance!.getCacheStats()
const clearCache = () => mediaCacheInstance!.clearCache() const clearCache = () => mediaCacheInstance!.clearCache()
const preloadMedia = (urls: string[]) => const acquireUrl = (src: string) => mediaCacheInstance!.acquireUrl(src)
mediaCacheInstance!.preloadMedia(urls) const releaseUrl = (src: string) => mediaCacheInstance!.releaseUrl(src)
return { return {
getCachedMedia, getCachedMedia,
getCacheStats,
clearCache, clearCache,
preloadMedia, acquireUrl,
releaseUrl,
cache: mediaCacheInstance.cache cache: mediaCacheInstance.cache
} }
} }

View File

@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from 'vitest'
import { useMediaCache } from '../../../src/services/mediaCacheService'
// Mock fetch
global.fetch = vi.fn()
global.URL = {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn()
} as any
describe('mediaCacheService', () => {
describe('URL reference counting', () => {
it('should handle URL acquisition for non-existent cache entry', () => {
const { acquireUrl } = useMediaCache()
const url = acquireUrl('non-existent.jpg')
expect(url).toBeUndefined()
})
it('should handle URL release for non-existent cache entry', () => {
const { releaseUrl } = useMediaCache()
// Should not throw error
expect(() => releaseUrl('non-existent.jpg')).not.toThrow()
})
it('should provide acquireUrl and releaseUrl methods', () => {
const cache = useMediaCache()
expect(typeof cache.acquireUrl).toBe('function')
expect(typeof cache.releaseUrl).toBe('function')
})
})
})