From 2a60778e609ef3f0c334fa2f01d4113d4fa4e6d5 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sun, 26 Jul 2026 16:55:26 +0200 Subject: [PATCH 1/2] fix(media): revoke decrypted media blob URLs that outlive their component --- .changeset/fix-encrypted-media-blob-leak.md | 5 ++ .../message/content/AudioContent.tsx | 12 ++--- .../message/content/FileContent.tsx | 18 +++---- .../message/content/ImageContent.tsx | 12 ++--- .../message/content/ThumbnailContent.tsx | 12 ++--- .../message/content/VideoContent.tsx | 12 ++--- src/app/hooks/useObjectURL.test.tsx | 52 +++++++++++++++++++ src/app/hooks/useObjectURL.ts | 35 ++++++++++--- 8 files changed, 115 insertions(+), 43 deletions(-) create mode 100644 .changeset/fix-encrypted-media-blob-leak.md create mode 100644 src/app/hooks/useObjectURL.test.tsx diff --git a/.changeset/fix-encrypted-media-blob-leak.md b/.changeset/fix-encrypted-media-blob-leak.md new file mode 100644 index 0000000000..9c8549f148 --- /dev/null +++ b/.changeset/fix-encrypted-media-blob-leak.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +# Free decrypted images and media from memory instead of holding them until reload diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index 0889b1864f..eb437c9524 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -28,7 +28,7 @@ import { } from '$utils/matrix'; import { setMediaEncryption } from '$utils/tauriMediaEncryption'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; -import { useRevokeObjectURL } from '$hooks/useObjectURL'; +import { useCreateObjectURL } from '$hooks/useObjectURL'; import { MEDIA_VOLUME_KEY } from '$components/media'; import { hasControllingServiceWorker } from '$utils/platform'; @@ -60,6 +60,8 @@ export function AudioContent({ const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); + const createObjectURL = useCreateObjectURL(); + const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); @@ -74,15 +76,13 @@ export function AudioContent({ const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo) ); - return URL.createObjectURL(fileContent); + return createObjectURL(fileContent); } const fileContent = await downloadMedia(mediaUrl); - return URL.createObjectURL(fileContent); - }, [mx, url, useAuthentication, mimeType, encInfo]) + return createObjectURL(fileContent); + }, [mx, url, useAuthentication, mimeType, encInfo, createObjectURL]) ); - useRevokeObjectURL(srcState.status === AsyncStatus.Success ? srcState.data : undefined); - const audioRef = useRef(null); useEffect(() => { diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx index 53f448b617..60f6b696c3 100644 --- a/src/app/components/message/content/FileContent.tsx +++ b/src/app/components/message/content/FileContent.tsx @@ -15,7 +15,7 @@ import { } from '$utils/mimeTypes'; import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '$utils/matrix'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; -import { useRevokeObjectURL } from '$hooks/useObjectURL'; +import { useCreateObjectURL } from '$hooks/useObjectURL'; import { useDismissOnBack } from '$utils/androidBack'; import { ModalWide } from '$styles/Modal.css'; import { getDownloadFilename, saveFileToDevice } from '$utils/download'; @@ -154,6 +154,8 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read // Android back closes the PDF viewer instead of navigating away. useDismissOnBack(() => setPdfViewer(false), pdfViewer); + const createObjectURL = useCreateObjectURL(); + const [pdfState, loadPdf] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); @@ -162,12 +164,10 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) : await downloadMedia(mediaUrl); setPdfViewer(true); - return URL.createObjectURL(fileContent); - }, [mx, url, useAuthentication, mimeType, encInfo]) + return createObjectURL(fileContent); + }, [mx, url, useAuthentication, mimeType, encInfo, createObjectURL]) ); - useRevokeObjectURL(pdfState.status === AsyncStatus.Success ? pdfState.data : undefined); - return ( <> {pdfState.status === AsyncStatus.Success && ( @@ -223,6 +223,8 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); + const createObjectURL = useCreateObjectURL(); + const [downloadState, download] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); @@ -231,14 +233,12 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) : await downloadMedia(mediaUrl); - const fileURL = URL.createObjectURL(fileContent); + const fileURL = createObjectURL(fileContent); await saveFileToDevice(fileContent, getDownloadFilename(body), mimeType); return fileURL; - }, [mx, url, useAuthentication, mimeType, encInfo, body]) + }, [mx, url, useAuthentication, mimeType, encInfo, body, createObjectURL]) ); - useRevokeObjectURL(downloadState.status === AsyncStatus.Success ? downloadState.data : undefined); - return downloadState.status === AsyncStatus.Error ? ( renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`) ) : ( diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 7d1f2e3ea5..e8ab0d204b 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -52,7 +52,7 @@ import { } from '../../../../unstable/prefixes'; import { useFavoriteGifs } from '$hooks/useFavoriteGifs'; import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; -import { useRevokeObjectURL } from '$hooks/useObjectURL'; +import { useCreateObjectURL } from '$hooks/useObjectURL'; import { ModalOverlay } from '$components/modal-overlay/ModalOverlay'; export function checkIfGif(url: string, mimetype?: string, body?: string) { @@ -155,6 +155,8 @@ export const ImageContent = as<'div', ImageContentProps>( const resolvedMediaUrl = useRenderableMediaUrl(encInfo ? undefined : rawMediaUrl); + const createObjectURL = useCreateObjectURL(); + const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { if (encInfo) { @@ -166,10 +168,10 @@ export const ImageContent = as<'div', ImageContentProps>( const fileContent = await downloadEncryptedMedia(rawMediaUrl, (encBuf) => decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo) ); - return URL.createObjectURL(fileContent); + return createObjectURL(fileContent); } return resolvedMediaUrl ?? rawMediaUrl ?? url; - }, [rawMediaUrl, resolvedMediaUrl, url, mimeType, encInfo]) + }, [rawMediaUrl, resolvedMediaUrl, url, mimeType, encInfo, createObjectURL]) ); useEffect(() => { @@ -213,10 +215,6 @@ export const ImageContent = as<'div', ImageContentProps>( if (autoPlay) loadSrc().catch(() => undefined); }, [autoPlay, loadSrc]); - useRevokeObjectURL( - encInfo && srcState.status === AsyncStatus.Success ? srcState.data : undefined - ); - const imageW = info?.w; const imageH = info?.h; const hasDimensions = typeof imageW === 'number' && typeof imageH === 'number'; diff --git a/src/app/components/message/content/ThumbnailContent.tsx b/src/app/components/message/content/ThumbnailContent.tsx index 6da06f36aa..ace78a999a 100644 --- a/src/app/components/message/content/ThumbnailContent.tsx +++ b/src/app/components/message/content/ThumbnailContent.tsx @@ -14,7 +14,7 @@ import { setMediaEncryption } from '$utils/tauriMediaEncryption'; import { isTauri } from '@tauri-apps/api/core'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; -import { useRevokeObjectURL } from '$hooks/useObjectURL'; +import { useCreateObjectURL } from '$hooks/useObjectURL'; import { FALLBACK_MIMETYPE } from '$utils/mimeTypes'; export type ThumbnailContentProps = { @@ -35,6 +35,8 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) { const resolvedMediaUrl = useRenderableMediaUrl(encInfo ? undefined : rawMediaUrl); + const createObjectURL = useCreateObjectURL(); + const [thumbSrcState, loadThumbSrc] = useAsyncCallback( useCallback(async () => { const thumbInfo = info.thumbnail_info; @@ -52,10 +54,10 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) { const fileContent = await downloadEncryptedMedia(rawMediaUrl, (encBuf) => decryptFile(encBuf, thumbInfo?.mimetype ?? FALLBACK_MIMETYPE, encInfo) ); - return URL.createObjectURL(fileContent); + return createObjectURL(fileContent); } return resolvedMediaUrl ?? rawMediaUrl ?? thumbMxcUrl; - }, [info, thumbMxcUrl, rawMediaUrl, resolvedMediaUrl, encInfo]) + }, [info, thumbMxcUrl, rawMediaUrl, resolvedMediaUrl, encInfo, createObjectURL]) ); useEffect(() => { @@ -64,10 +66,6 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) { loadThumbSrc().catch(() => undefined); }, [loadThumbSrc]); - useRevokeObjectURL( - encInfo && thumbSrcState.status === AsyncStatus.Success ? thumbSrcState.data : undefined - ); - if (thumbSrcState.status === AsyncStatus.Success) return renderImage(thumbSrcState.data); if (thumbSrcState.status === AsyncStatus.Loading) { diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index da6cc81e0a..5ef44c4576 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -32,7 +32,7 @@ import { } from '$utils/matrix'; import { setMediaEncryption } from '$utils/tauriMediaEncryption'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; -import { useRevokeObjectURL } from '$hooks/useObjectURL'; +import { useCreateObjectURL } from '$hooks/useObjectURL'; import { validBlurHash } from '$utils/blurHash'; import * as css from './style.css'; import { MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME } from '../../../../unstable/prefixes'; @@ -86,6 +86,8 @@ export const VideoContent = as<'div', VideoContentProps>( const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); const [isHovered, setIsHovered] = useState(false); + const createObjectURL = useCreateObjectURL(); + const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { if (url.startsWith('http')) return url; @@ -102,11 +104,11 @@ export const VideoContent = as<'div', VideoContentProps>( const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo) ); - return URL.createObjectURL(fileContent); + return createObjectURL(fileContent); } const fileContent = await downloadMedia(mediaUrl); - return URL.createObjectURL(fileContent); - }, [mx, url, useAuthentication, mimeType, encInfo]) + return createObjectURL(fileContent); + }, [mx, url, useAuthentication, mimeType, encInfo, createObjectURL]) ); // When the source download succeeds, reset video-element error state so the @@ -145,8 +147,6 @@ export const VideoContent = as<'div', VideoContentProps>( if (autoPlay) loadSrc().catch(() => undefined); }, [autoPlay, loadSrc]); - useRevokeObjectURL(srcState.status === AsyncStatus.Success ? srcState.data : undefined); - return ( { + let created: number; + + beforeEach(() => { + vi.restoreAllMocks(); + created = 0; + vi.spyOn(URL, 'createObjectURL').mockImplementation(() => { + created += 1; + return `blob:url-${created}`; + }); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + }); + + it('revokes the owned url on unmount', () => { + const { result, unmount } = renderHook(() => useCreateObjectURL()); + result.current(new Blob(['a'])); + + expect(URL.revokeObjectURL).not.toHaveBeenCalled(); + unmount(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:url-1'); + }); + + it('revokes the previous url when a new one replaces it', () => { + const { result } = renderHook(() => useCreateObjectURL()); + result.current(new Blob(['a'])); + result.current(new Blob(['b'])); + + expect(URL.revokeObjectURL).toHaveBeenCalledExactlyOnceWith('blob:url-1'); + }); + + it('revokes immediately when the blob resolves after unmount', () => { + const { result, unmount } = renderHook(() => useCreateObjectURL()); + unmount(); + + result.current(new Blob(['late'])); + + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:url-1'); + }); + + it('keeps a stable identity so it can sit in effect dependencies', () => { + const { result, rerender } = renderHook(() => useCreateObjectURL()); + const first = result.current; + rerender(); + + expect(result.current).toBe(first); + }); +}); diff --git a/src/app/hooks/useObjectURL.ts b/src/app/hooks/useObjectURL.ts index 7f74abd367..d8b0205c63 100644 --- a/src/app/hooks/useObjectURL.ts +++ b/src/app/hooks/useObjectURL.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; export const useObjectURL = (object?: Blob): string | undefined => { const url = useMemo(() => { @@ -16,11 +16,30 @@ export const useObjectURL = (object?: Blob): string | undefined => { return url; }; -export const useRevokeObjectURL = (url?: string): void => { - useEffect( - () => () => { - if (url?.startsWith('blob:')) URL.revokeObjectURL(url); - }, - [url] - ); +// For blob URLs created inside an async callback, which can resolve after unmount +// with no effect left to revoke them. Owns one URL at a time per caller. +export const useCreateObjectURL = (): ((object: Blob) => string) => { + const urlRef = useRef(undefined); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (urlRef.current) URL.revokeObjectURL(urlRef.current); + urlRef.current = undefined; + }; + }, []); + + return useCallback((object: Blob) => { + const url = URL.createObjectURL(object); + if (urlRef.current) URL.revokeObjectURL(urlRef.current); + if (!mountedRef.current) { + URL.revokeObjectURL(url); + urlRef.current = undefined; + return url; + } + urlRef.current = url; + return url; + }, []); }; From de744505d388bc3c4f0a8098c58c5c9393eb790e Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sun, 26 Jul 2026 21:40:21 -0500 Subject: [PATCH 2/2] fix race conditions --- .../message/content/AudioContent.tsx | 8 ++-- .../message/content/FileContent.tsx | 16 ++++---- .../message/content/ImageContent.tsx | 7 ++-- .../message/content/ThumbnailContent.tsx | 7 ++-- .../message/content/VideoContent.tsx | 8 ++-- src/app/hooks/useObjectURL.test.tsx | 40 +++++++++++++++---- src/app/hooks/useObjectURL.ts | 19 +++++---- 7 files changed, 67 insertions(+), 38 deletions(-) diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index eb437c9524..d4f3c4f22c 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -73,13 +73,11 @@ export function AudioContent({ await setMediaEncryption(mediaUrl, encInfo, mimeType); return rewriteAuthenticatedMediaUrl(mediaUrl)!; } - const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => - decryptFile(encBuf, mimeType, encInfo) + return createObjectURL( + downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ); - return createObjectURL(fileContent); } - const fileContent = await downloadMedia(mediaUrl); - return createObjectURL(fileContent); + return createObjectURL(downloadMedia(mediaUrl)); }, [mx, url, useAuthentication, mimeType, encInfo, createObjectURL]) ); diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx index 60f6b696c3..fe44951e5e 100644 --- a/src/app/components/message/content/FileContent.tsx +++ b/src/app/components/message/content/FileContent.tsx @@ -161,10 +161,11 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); if (!mediaUrl) throw new Error('Invalid media URL'); const fileContent = encInfo - ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) - : await downloadMedia(mediaUrl); + ? downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) + : downloadMedia(mediaUrl); + const fileURL = await createObjectURL(fileContent); setPdfViewer(true); - return createObjectURL(fileContent); + return fileURL; }, [mx, url, useAuthentication, mimeType, encInfo, createObjectURL]) ); @@ -229,11 +230,12 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); if (!mediaUrl) throw new Error('Invalid media URL'); - const fileContent = encInfo - ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) - : await downloadMedia(mediaUrl); + const fileContentPromise = encInfo + ? downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) + : downloadMedia(mediaUrl); + const fileURL = await createObjectURL(fileContentPromise); + const fileContent = await fileContentPromise; - const fileURL = createObjectURL(fileContent); await saveFileToDevice(fileContent, getDownloadFilename(body), mimeType); return fileURL; }, [mx, url, useAuthentication, mimeType, encInfo, body, createObjectURL]) diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index e8ab0d204b..cc7309b0bf 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -165,10 +165,11 @@ export const ImageContent = as<'div', ImageContentProps>( await setMediaEncryption(rawMediaUrl, encInfo, mimeType ?? FALLBACK_MIMETYPE); return rewriteAuthenticatedMediaUrl(rawMediaUrl)!; } - const fileContent = await downloadEncryptedMedia(rawMediaUrl, (encBuf) => - decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo) + return createObjectURL( + downloadEncryptedMedia(rawMediaUrl, (encBuf) => + decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo) + ) ); - return createObjectURL(fileContent); } return resolvedMediaUrl ?? rawMediaUrl ?? url; }, [rawMediaUrl, resolvedMediaUrl, url, mimeType, encInfo, createObjectURL]) diff --git a/src/app/components/message/content/ThumbnailContent.tsx b/src/app/components/message/content/ThumbnailContent.tsx index ace78a999a..e19177a248 100644 --- a/src/app/components/message/content/ThumbnailContent.tsx +++ b/src/app/components/message/content/ThumbnailContent.tsx @@ -51,10 +51,11 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) { await setMediaEncryption(rawMediaUrl, encInfo, thumbInfo?.mimetype ?? FALLBACK_MIMETYPE); return rewriteAuthenticatedMediaUrl(rawMediaUrl)!; } - const fileContent = await downloadEncryptedMedia(rawMediaUrl, (encBuf) => - decryptFile(encBuf, thumbInfo?.mimetype ?? FALLBACK_MIMETYPE, encInfo) + return createObjectURL( + downloadEncryptedMedia(rawMediaUrl, (encBuf) => + decryptFile(encBuf, thumbInfo?.mimetype ?? FALLBACK_MIMETYPE, encInfo) + ) ); - return createObjectURL(fileContent); } return resolvedMediaUrl ?? rawMediaUrl ?? thumbMxcUrl; }, [info, thumbMxcUrl, rawMediaUrl, resolvedMediaUrl, encInfo, createObjectURL]) diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index 5ef44c4576..79d5ad03f4 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -101,13 +101,11 @@ export const VideoContent = as<'div', VideoContentProps>( await setMediaEncryption(mediaUrl, encInfo, mimeType); return rewriteAuthenticatedMediaUrl(mediaUrl)!; } - const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => - decryptFile(encBuf, mimeType, encInfo) + return createObjectURL( + downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ); - return createObjectURL(fileContent); } - const fileContent = await downloadMedia(mediaUrl); - return createObjectURL(fileContent); + return createObjectURL(downloadMedia(mediaUrl)); }, [mx, url, useAuthentication, mimeType, encInfo, createObjectURL]) ); diff --git a/src/app/hooks/useObjectURL.test.tsx b/src/app/hooks/useObjectURL.test.tsx index e73c4c92a8..856548dea9 100644 --- a/src/app/hooks/useObjectURL.test.tsx +++ b/src/app/hooks/useObjectURL.test.tsx @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useCreateObjectURL } from './useObjectURL'; @@ -16,32 +16,56 @@ describe('useCreateObjectURL', () => { vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); }); - it('revokes the owned url on unmount', () => { + it('revokes the owned url on unmount', async () => { const { result, unmount } = renderHook(() => useCreateObjectURL()); - result.current(new Blob(['a'])); + await act(async () => { + await result.current(new Blob(['a'])); + }); expect(URL.revokeObjectURL).not.toHaveBeenCalled(); unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:url-1'); }); - it('revokes the previous url when a new one replaces it', () => { + it('revokes the previous url when a new one replaces it', async () => { const { result } = renderHook(() => useCreateObjectURL()); - result.current(new Blob(['a'])); - result.current(new Blob(['b'])); + await act(async () => { + await result.current(new Blob(['a'])); + }); + await act(async () => { + await result.current(new Blob(['b'])); + }); expect(URL.revokeObjectURL).toHaveBeenCalledExactlyOnceWith('blob:url-1'); }); - it('revokes immediately when the blob resolves after unmount', () => { + it('revokes immediately when the blob resolves after unmount', async () => { const { result, unmount } = renderHook(() => useCreateObjectURL()); unmount(); - result.current(new Blob(['late'])); + await result.current(new Blob(['late'])); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:url-1'); }); + it('does not revoke the current url when an older request resolves last', async () => { + let resolveOlder!: (blob: Blob) => void; + const olderBlob = new Promise((resolve) => { + resolveOlder = resolve; + }); + const { result } = renderHook(() => useCreateObjectURL()); + + const olderUrl = result.current(olderBlob); + await act(async () => { + await result.current(new Blob(['newer'])); + }); + resolveOlder(new Blob(['older'])); + await olderUrl; + + expect(URL.revokeObjectURL).toHaveBeenCalledExactlyOnceWith('blob:url-2'); + expect(URL.revokeObjectURL).not.toHaveBeenCalledWith('blob:url-1'); + }); + it('keeps a stable identity so it can sit in effect dependencies', () => { const { result, rerender } = renderHook(() => useCreateObjectURL()); const first = result.current; diff --git a/src/app/hooks/useObjectURL.ts b/src/app/hooks/useObjectURL.ts index d8b0205c63..1262402f5e 100644 --- a/src/app/hooks/useObjectURL.ts +++ b/src/app/hooks/useObjectURL.ts @@ -17,10 +17,12 @@ export const useObjectURL = (object?: Blob): string | undefined => { }; // For blob URLs created inside an async callback, which can resolve after unmount -// with no effect left to revoke them. Owns one URL at a time per caller. -export const useCreateObjectURL = (): ((object: Blob) => string) => { +// with no effect left to revoke them. Passing the pending blob reserves ownership +// so an older request that resolves late cannot replace or revoke the current URL. +export const useCreateObjectURL = (): ((object: Blob | Promise) => Promise) => { const urlRef = useRef(undefined); const mountedRef = useRef(true); + const requestRef = useRef(0); useEffect(() => { mountedRef.current = true; @@ -31,14 +33,17 @@ export const useCreateObjectURL = (): ((object: Blob) => string) => { }; }, []); - return useCallback((object: Blob) => { - const url = URL.createObjectURL(object); - if (urlRef.current) URL.revokeObjectURL(urlRef.current); - if (!mountedRef.current) { + return useCallback(async (object: Blob | Promise) => { + const request = ++requestRef.current; + const resolvedObject = await object; + const url = URL.createObjectURL(resolvedObject); + + if (!mountedRef.current || request !== requestRef.current) { URL.revokeObjectURL(url); - urlRef.current = undefined; return url; } + + if (urlRef.current) URL.revokeObjectURL(urlRef.current); urlRef.current = url; return url; }, []);