Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 114 additions & 32 deletions frontend/src/lib/components/ModelTable/EvidenceFilePreview.svelte
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;
Expand All @@ -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;
Comment on lines +49 to +56
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Revoke blob URLs to prevent memory leaks.

Blob URLs created on line 52 are never revoked, causing memory leaks as attachments accumulate. Implement cleanup:

  1. Revoke URLs in onDestroy
  2. Revoke old URL before caching a replacement
  3. Consider tracking URLs for batch cleanup

Example 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
In frontend/src/lib/components/ModelTable/EvidenceFilePreview.svelte around
lines 49 to 56, the created blob URL (URL.createObjectURL) is never revoked
causing memory leaks; update the component to revoke URLs: before caching a new
result, call URL.revokeObjectURL on any previous cached attachment.url for the
same cacheKey, store/tracked created URLs so they can be revoked in batch, and
add an onDestroy handler that disconnects any observer and revokes the current
attachment.url (and any tracked URLs) to free memory.

} catch (err) {
if ((err as any)?.name === 'AbortError') {
const miss = { type: '', url: '', fileExists: false } satisfies Attachment;
return miss;
Comment on lines +58 to +60
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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 AbortController in this code. If abort functionality isn't needed, consider removing the special AbortError handling:

 	} 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ((err as any)?.name === 'AbortError') {
const miss = { type: '', url: '', fileExists: false } satisfies Attachment;
return miss;
} 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;
}
🤖 Prompt for AI Agents
In frontend/src/lib/components/ModelTable/EvidenceFilePreview.svelte around
lines 58 to 60, the AbortError branch returns a miss without caching it while
the generic error path caches the miss; either remove the AbortError-specific
branch if abort behavior isn't used (so all errors follow the same cache path),
or update the AbortError path to mirror the generic error handling by creating
the same miss object and writing it to the cache before returning; if you intend
to support aborts later, also add an AbortController to the fetch call and wire
it into the caller so aborts are intentional and handled consistently.

}
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;
}
Expand All @@ -54,7 +134,7 @@
const embedElementClasses = 'w-[50%] h-[90%]';
</script>

{#snippet displayPreview()}
{#snippet displayPreview(att: Attachment)}
<div
role="button"
tabindex="0"
Expand All @@ -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>
82 changes: 82 additions & 0 deletions frontend/src/lib/stores/attachmentCache.ts
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Fix immutability: remove mutates cache in-place.

The remove method uses delete cache[key] and returns the mutated object, inconsistent with set which returns a new object via spread. This breaks the immutability pattern and may cause Svelte reactivity issues.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
remove: (key: string) => {
update((cache) => {
if (cache[key]) {
URL.revokeObjectURL(cache[key].url);
delete cache[key];
}
return cache;
});
},
remove: (key: string) => {
update((cache) => {
if (cache[key]) {
URL.revokeObjectURL(cache[key].url);
}
const { [key]: _, ...rest } = cache;
return rest;
});
},
🤖 Prompt for AI Agents
In frontend/src/lib/stores/attachmentCache.ts around lines 45 to 53, the remove
function currently mutates the cache in-place using delete; change it to be
immutable by creating a new object without the removed key (using object
destructuring to omit the key or by shallow-cloning and deleting from the clone)
and return that new object from update; make sure to call URL.revokeObjectURL
for the removed entry's url before returning the new cache so revocation still
occurs but the original cache object is not mutated.

/**
* 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}`;
}
Loading