-
Notifications
You must be signed in to change notification settings - Fork 528
feat: lazy loading for evidences preview with caching (faster) #2762
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,8 +1,9 @@ | ||||||||||||||||||||||||||||
| <script lang="ts"> | ||||||||||||||||||||||||||||
| import { run } from 'svelte/legacy'; | ||||||||||||||||||||||||||||
| import { onMount } from 'svelte'; | ||||||||||||||||||||||||||||
| import { onMount, onDestroy } from 'svelte'; | ||||||||||||||||||||||||||||
| import { m } from '$paraglide/messages'; | ||||||||||||||||||||||||||||
| import { attachmentCache, generateAttachmentCacheKey } from '$lib/stores/attachmentCache'; | ||||||||||||||||||||||||||||
| interface Props { | ||||||||||||||||||||||||||||
| cell: any; | ||||||||||||||||||||||||||||
|
|
@@ -18,30 +19,109 @@ | |||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| let attachment: Attachment | undefined = $state(); | ||||||||||||||||||||||||||||
| let containerElement: HTMLElement | undefined = $state(); | ||||||||||||||||||||||||||||
| let isVisible = $state(false); | ||||||||||||||||||||||||||||
| let observer: IntersectionObserver | undefined; | ||||||||||||||||||||||||||||
| let cacheKey = $state(''); | ||||||||||||||||||||||||||||
| const fetchAttachment = async () => { | ||||||||||||||||||||||||||||
| const res = await fetch( | ||||||||||||||||||||||||||||
| `/${meta.evidence ? 'evidence-revisions' : 'evidences'}/${meta.id}/attachment` | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| const blob = await res.blob(); | ||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||
| type: blob.type, | ||||||||||||||||||||||||||||
| url: URL.createObjectURL(blob), | ||||||||||||||||||||||||||||
| fileExists: res.ok | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| // Generate cache key | ||||||||||||||||||||||||||||
| cacheKey = generateAttachmentCacheKey(meta.id, meta.attachment); | ||||||||||||||||||||||||||||
| // Check cache first | ||||||||||||||||||||||||||||
| if (attachmentCache.has(cacheKey)) { | ||||||||||||||||||||||||||||
| const cached = attachmentCache.get(cacheKey); | ||||||||||||||||||||||||||||
| if (cached) { | ||||||||||||||||||||||||||||
| return cached; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| // Fetch from server if not in cache | ||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||
| const res = await fetch( | ||||||||||||||||||||||||||||
| `/${meta.evidence ? 'evidence-revisions' : 'evidences'}/${meta.id}/attachment` | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| if (!res.ok) { | ||||||||||||||||||||||||||||
| const miss = { type: '', url: '', fileExists: false } satisfies Attachment; | ||||||||||||||||||||||||||||
| attachmentCache.set(cacheKey, miss); | ||||||||||||||||||||||||||||
| return miss; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| const blob = await res.blob(); | ||||||||||||||||||||||||||||
| const result = { | ||||||||||||||||||||||||||||
| type: blob.type, | ||||||||||||||||||||||||||||
| url: URL.createObjectURL(blob), | ||||||||||||||||||||||||||||
| fileExists: true | ||||||||||||||||||||||||||||
| } satisfies Attachment; | ||||||||||||||||||||||||||||
| attachmentCache.set(cacheKey, result); | ||||||||||||||||||||||||||||
| return result; | ||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||
| if ((err as any)?.name === 'AbortError') { | ||||||||||||||||||||||||||||
| const miss = { type: '', url: '', fileExists: false } satisfies Attachment; | ||||||||||||||||||||||||||||
| return miss; | ||||||||||||||||||||||||||||
|
Comment on lines
+58
to
+60
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inconsistent error caching on AbortError. The AbortError path returns a miss without caching it (lines 58-60), unlike the generic error path which caches the miss (lines 62-64). This inconsistency could trigger unnecessary refetches if the user scrolls away during a fetch and then returns. However, I notice there's no } catch (err) {
- if ((err as any)?.name === 'AbortError') {
- const miss = { type: '', url: '', fileExists: false } satisfies Attachment;
- return miss;
- }
const miss = { type: '', url: '', fileExists: false } satisfies Attachment;
attachmentCache.set(cacheKey, miss);
return miss;Alternatively, if you plan to add abort support later, make the caching consistent: } catch (err) {
if ((err as any)?.name === 'AbortError') {
const miss = { type: '', url: '', fileExists: false } satisfies Attachment;
+ attachmentCache.set(cacheKey, miss);
return miss;
}
const miss = { type: '', url: '', fileExists: false } satisfies Attachment;
attachmentCache.set(cacheKey, miss);
return miss;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| const miss = { type: '', url: '', fileExists: false } satisfies Attachment; | ||||||||||||||||||||||||||||
| attachmentCache.set(cacheKey, miss); | ||||||||||||||||||||||||||||
| return miss; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| let mounted = $state(false); | ||||||||||||||||||||||||||||
| onMount(async () => { | ||||||||||||||||||||||||||||
| attachment = meta.attachment ? await fetchAttachment() : undefined; | ||||||||||||||||||||||||||||
| onMount(() => { | ||||||||||||||||||||||||||||
| mounted = true; | ||||||||||||||||||||||||||||
| // Set up Intersection Observer for lazy loading | ||||||||||||||||||||||||||||
| if (containerElement && meta.attachment) { | ||||||||||||||||||||||||||||
| observer = new IntersectionObserver( | ||||||||||||||||||||||||||||
| (entries) => { | ||||||||||||||||||||||||||||
| entries.forEach((entry) => { | ||||||||||||||||||||||||||||
| if (entry.isIntersecting && !isVisible) { | ||||||||||||||||||||||||||||
| isVisible = true; | ||||||||||||||||||||||||||||
| // Fetch attachment when element becomes visible | ||||||||||||||||||||||||||||
| fetchAttachment().then((_attachment) => { | ||||||||||||||||||||||||||||
| attachment = _attachment; | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| // Disconnect after first load | ||||||||||||||||||||||||||||
| observer?.disconnect(); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| rootMargin: '50px' // Start loading slightly before element is visible | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| observer.observe(containerElement); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| onDestroy(() => { | ||||||||||||||||||||||||||||
| // Clean up observer | ||||||||||||||||||||||||||||
| if (observer) { | ||||||||||||||||||||||||||||
| observer.disconnect(); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| // Note: We do NOT revoke blob URLs here because the attachmentCache | ||||||||||||||||||||||||||||
| // is shared across multiple components. The cache itself handles | ||||||||||||||||||||||||||||
| // URL revocation when URLs are replaced or when cache.clear() is called. | ||||||||||||||||||||||||||||
| // Revoking URLs here would break other components using the same attachment. | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| run(() => { | ||||||||||||||||||||||||||||
| if (mounted && meta.attachment) { | ||||||||||||||||||||||||||||
| fetchAttachment().then((_attachment) => { | ||||||||||||||||||||||||||||
| attachment = _attachment; | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| // Check cache first when meta changes | ||||||||||||||||||||||||||||
| const key = generateAttachmentCacheKey(meta.id, meta.attachment); | ||||||||||||||||||||||||||||
| if (attachmentCache.has(key)) { | ||||||||||||||||||||||||||||
| const cached = attachmentCache.get(key); | ||||||||||||||||||||||||||||
| if (cached) { | ||||||||||||||||||||||||||||
| attachment = cached; | ||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| // If visible, fetch immediately | ||||||||||||||||||||||||||||
| if (isVisible) { | ||||||||||||||||||||||||||||
| fetchAttachment().then((_attachment) => { | ||||||||||||||||||||||||||||
| attachment = _attachment; | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||
| attachment = undefined; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
@@ -54,7 +134,7 @@ | |||||||||||||||||||||||||||
| const embedElementClasses = 'w-[50%] h-[90%]'; | ||||||||||||||||||||||||||||
| </script> | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| {#snippet displayPreview()} | ||||||||||||||||||||||||||||
| {#snippet displayPreview(att: Attachment)} | ||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||
| role="button" | ||||||||||||||||||||||||||||
| tabindex="0" | ||||||||||||||||||||||||||||
|
|
@@ -69,38 +149,40 @@ | |||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||
| {#if attachment.type.startsWith('image')} | ||||||||||||||||||||||||||||
| {#if att.type.startsWith('image')} | ||||||||||||||||||||||||||||
| <img | ||||||||||||||||||||||||||||
| src={attachment.url} | ||||||||||||||||||||||||||||
| src={att.url} | ||||||||||||||||||||||||||||
| alt="attachment" | ||||||||||||||||||||||||||||
| class="h-24 object-contain {display ? imageElementClasses : ''}" | ||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||
| {:else if attachment.type === 'application/pdf'} | ||||||||||||||||||||||||||||
| {:else if att.type === 'application/pdf'} | ||||||||||||||||||||||||||||
| {#if !display} | ||||||||||||||||||||||||||||
| <!-- This div prevents the <embed> element from stopping the click event propagation. --> | ||||||||||||||||||||||||||||
| <div class="absolute w-full h-full top-0 left-0"></div> | ||||||||||||||||||||||||||||
| {/if} | ||||||||||||||||||||||||||||
| <embed | ||||||||||||||||||||||||||||
| src={attachment.url} | ||||||||||||||||||||||||||||
| src={att.url} | ||||||||||||||||||||||||||||
| type="application/pdf" | ||||||||||||||||||||||||||||
| class="h-24 object-contain {display ? embedElementClasses : ''}" | ||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||
| {/if} | ||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||
| {/snippet} | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| {#if cell} | ||||||||||||||||||||||||||||
| {#if attachment} | ||||||||||||||||||||||||||||
| {#if attachment.type.startsWith('image') || attachment.type === 'application/pdf'} | ||||||||||||||||||||||||||||
| {@render displayPreview(attachment)} | ||||||||||||||||||||||||||||
| {:else if !attachment.fileExists} | ||||||||||||||||||||||||||||
| <p class="text-error-500 font-bold">{m.couldNotFindAttachmentMessage()}</p> | ||||||||||||||||||||||||||||
| <div bind:this={containerElement}> | ||||||||||||||||||||||||||||
| {#if cell} | ||||||||||||||||||||||||||||
| {#if attachment} | ||||||||||||||||||||||||||||
| {#if attachment.type.startsWith('image') || attachment.type === 'application/pdf'} | ||||||||||||||||||||||||||||
| {@render displayPreview(attachment)} | ||||||||||||||||||||||||||||
| {:else if !attachment.fileExists} | ||||||||||||||||||||||||||||
| <p class="text-error-500 font-bold">{m.couldNotFindAttachmentMessage()}</p> | ||||||||||||||||||||||||||||
| {:else} | ||||||||||||||||||||||||||||
| <p>{m.NoPreviewMessage()}</p> | ||||||||||||||||||||||||||||
| {/if} | ||||||||||||||||||||||||||||
| {:else} | ||||||||||||||||||||||||||||
| <p>{m.NoPreviewMessage()}</p> | ||||||||||||||||||||||||||||
| <span data-testid="loading-field"> | ||||||||||||||||||||||||||||
| {m.loading()}... | ||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||
| {/if} | ||||||||||||||||||||||||||||
| {:else} | ||||||||||||||||||||||||||||
| <span data-testid="loading-field"> | ||||||||||||||||||||||||||||
| {m.loading()}... | ||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||
| {/if} | ||||||||||||||||||||||||||||
| {/if} | ||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,82 @@ | ||||||||||||||||||||||||||||||||||||||
| import { writable, get } from 'svelte/store'; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||
| * Global cache for attachment blob URLs | ||||||||||||||||||||||||||||||||||||||
| * Prevents duplicate downloads of the same evidence attachment | ||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| interface CachedAttachment { | ||||||||||||||||||||||||||||||||||||||
| type: string; | ||||||||||||||||||||||||||||||||||||||
| url: string; | ||||||||||||||||||||||||||||||||||||||
| fileExists: boolean; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| interface AttachmentCache { | ||||||||||||||||||||||||||||||||||||||
| [key: string]: CachedAttachment; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| function createAttachmentCache() { | ||||||||||||||||||||||||||||||||||||||
| const { subscribe, set, update } = writable<AttachmentCache>({}); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||
| subscribe, | ||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||
| * Get a cached attachment by key | ||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||
| get: (key: string): CachedAttachment | undefined => { | ||||||||||||||||||||||||||||||||||||||
| const cache = get({ subscribe }); | ||||||||||||||||||||||||||||||||||||||
| return cache[key]; | ||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||
| * Store an attachment in the cache | ||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||
| set: (key: string, value: CachedAttachment) => { | ||||||||||||||||||||||||||||||||||||||
| update((cache) => { | ||||||||||||||||||||||||||||||||||||||
| const prev = cache[key]; | ||||||||||||||||||||||||||||||||||||||
| if (prev?.url && prev.url !== value.url) { | ||||||||||||||||||||||||||||||||||||||
| URL.revokeObjectURL(prev.url); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| return { ...cache, [key]: value }; | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||
| * Remove an attachment from the cache and revoke its blob URL | ||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||
| remove: (key: string) => { | ||||||||||||||||||||||||||||||||||||||
| update((cache) => { | ||||||||||||||||||||||||||||||||||||||
| if (cache[key]) { | ||||||||||||||||||||||||||||||||||||||
| URL.revokeObjectURL(cache[key].url); | ||||||||||||||||||||||||||||||||||||||
| delete cache[key]; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| return cache; | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+45
to
+53
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Fix immutability: The Apply this diff to use destructuring for immutable removal: remove: (key: string) => {
update((cache) => {
if (cache[key]) {
URL.revokeObjectURL(cache[key].url);
- delete cache[key];
}
- return cache;
+ const { [key]: _, ...rest } = cache;
+ return rest;
});
},📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||
| * Clear all cached attachments and revoke all blob URLs | ||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||
| clear: () => { | ||||||||||||||||||||||||||||||||||||||
| update((cache) => { | ||||||||||||||||||||||||||||||||||||||
| Object.values(cache).forEach((attachment) => { | ||||||||||||||||||||||||||||||||||||||
| URL.revokeObjectURL(attachment.url); | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| return {}; | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||
| * Check if an attachment is in the cache | ||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||
| has: (key: string): boolean => { | ||||||||||||||||||||||||||||||||||||||
| const cache = get({ subscribe }); | ||||||||||||||||||||||||||||||||||||||
| return key in cache; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export const attachmentCache = createAttachmentCache(); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||
| * Generate a cache key for an attachment | ||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||
| export function generateAttachmentCacheKey(id: string, attachmentName: string): string { | ||||||||||||||||||||||||||||||||||||||
| return `${id}-${attachmentName}`; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Revoke blob URLs to prevent memory leaks.
Blob URLs created on line 52 are never revoked, causing memory leaks as attachments accumulate. Implement cleanup:
onDestroyExample cleanup in onDestroy:
onDestroy(() => { if (observer) { observer.disconnect(); } // Revoke blob URL to free memory if (attachment?.url) { URL.revokeObjectURL(attachment.url); } });🤖 Prompt for AI Agents