diff --git a/apps/desktop/src/components/AttachmentImage.tsx b/apps/desktop/src/components/AttachmentImage.tsx index f5c32e7..4c36ed5 100644 --- a/apps/desktop/src/components/AttachmentImage.tsx +++ b/apps/desktop/src/components/AttachmentImage.tsx @@ -3,7 +3,7 @@ import { downloadAndDecryptAttachment, downloadAndDecryptAttachmentThumb, } from '@chat-app/shared/chat'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache'; import { supabase } from '../lib/supabase'; @@ -166,11 +166,21 @@ export function AttachmentImage({ handle, mine = false }: Props) { // skipped the eager full-blob download above). Resolves into the same // `fullUrl` state that the Lightbox consumes; the thumb URL keeps // backing the bubble until the lightbox actually mounts. + // + // CRITICAL: do NOT revoke the just-created blob URL in this effect's + // cleanup. Setting `fullUrl` re-triggers the effect (state change → re- + // run → previous cleanup fires → URL revoked → Lightbox renders + // referenced-but-revoked URL → "ERR_FILE_NOT_FOUND"). The dedicated + // unmount-only effect below tracks the current URL via ref and revokes + // it once when the component truly leaves the tree. + // + // Deps locked to `handle.id` (not `handle`) — handles are immutable per + // attachment id, so object-identity churn from parent re-renders must + // not re-trigger the fetch. useEffect(() => { if (!lightboxOpen) return; if (fullUrl) return; let cancelled = false; - const created: string[] = []; void (async () => { const cached = await getCachedAttachment(handle.id); let blob: Blob; @@ -187,14 +197,27 @@ export function AttachmentImage({ handle, mine = false }: Props) { } if (cancelled) return; const u = URL.createObjectURL(blob); - created.push(u); setFullUrl(u); })(); return () => { cancelled = true; - for (const u of created) URL.revokeObjectURL(u); }; - }, [lightboxOpen, fullUrl, handle]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lightboxOpen, handle.id]); + + // Track the currently-published fullUrl in a ref so the unmount-only + // cleanup below can revoke whatever URL is live at teardown time + // without subscribing to fullUrl changes (which would re-trigger and + // revoke prematurely — see the comment above the fetch effect). + const fullUrlRef = useRef(null); + useEffect(() => { + fullUrlRef.current = fullUrl; + }, [fullUrl]); + useEffect(() => { + return () => { + if (fullUrlRef.current) URL.revokeObjectURL(fullUrlRef.current); + }; + }, []); const blobUrl = thumbUrl ?? fullUrl;