diff --git a/src/components/_shared/view-icon/PageIcon.tsx b/src/components/_shared/view-icon/PageIcon.tsx index 06abbc12..faa1ae4b 100644 --- a/src/components/_shared/view-icon/PageIcon.tsx +++ b/src/components/_shared/view-icon/PageIcon.tsx @@ -8,6 +8,7 @@ import { ReactComponent as CalendarSvg } from '@/assets/icons/calendar.svg'; import { ReactComponent as GridSvg } from '@/assets/icons/grid.svg'; import { ReactComponent as DocumentSvg } from '@/assets/icons/page.svg'; import { cn } from '@/lib/utils'; +import { getImageUrl, revokeBlobUrl } from '@/utils/authenticated-image'; import { renderColor } from '@/utils/color'; import { getIcon, isFlagEmoji } from '@/utils/emoji'; @@ -24,6 +25,7 @@ function PageIcon({ iconSize?: number; }) { const [iconContent, setIconContent] = React.useState(undefined); + const [imgSrc, setImgSrc] = React.useState(undefined); const emoji = useMemo(() => { if (view.icon && view.icon.ty === ViewIconType.Emoji && view.icon.value) { @@ -33,17 +35,36 @@ function PageIcon({ return null; }, [view]); - const img = useMemo(() => { + useEffect(() => { + let currentBlobUrl: string | undefined; + if (view.icon && view.icon.ty === ViewIconType.URL && view.icon.value) { + void getImageUrl(view.icon.value).then((url) => { + currentBlobUrl = url; + setImgSrc(url); + }); + } else { + setImgSrc(undefined); + } + + return () => { + if (currentBlobUrl) { + revokeBlobUrl(currentBlobUrl); + } + }; + }, [view.icon]); + + const img = useMemo(() => { + if (imgSrc) { return ( - icon + icon ); } return null; - }, [className, view.icon]); + }, [className, imgSrc]); const isFlag = useMemo(() => { return emoji ? isFlagEmoji(emoji) : false; diff --git a/src/utils/authenticated-image.ts b/src/utils/authenticated-image.ts index 4d0d24c3..f9be661b 100644 --- a/src/utils/authenticated-image.ts +++ b/src/utils/authenticated-image.ts @@ -81,10 +81,42 @@ export async function getImageUrl(url: string | undefined): Promise { } /** - * Cleans up a blob URL created by fetchAuthenticatedImage - * Should be called when the component unmounts or the URL is no longer needed + * Cleans up a blob URL created by fetchAuthenticatedImage. * - * @param url - The blob URL to revoke + * ## Why this is needed + * + * When `fetchAuthenticatedImage` fetches an image with auth headers, it creates + * a Blob URL using `URL.createObjectURL()`. This URL holds a reference to the + * binary image data in browser memory. + * + * The browser keeps this data alive as long as the Blob URL exists - even if: + * - The `` element is removed from the DOM + * - The React component unmounts + * - The URL is no longer referenced anywhere in code + * + * Without calling `revokeBlobUrl`, each authenticated image fetch causes a + * memory leak. For example, browsing 100 pages with 1MB icon images would + * accumulate ~100MB in memory that is never freed until page reload. + * + * ## Usage + * + * Call this function in a useEffect cleanup when the component unmounts + * or when the image URL changes: + * + * ```tsx + * useEffect(() => { + * let blobUrl: string | undefined; + * getImageUrl(url).then((result) => { + * blobUrl = result; + * setImgSrc(result); + * }); + * return () => { + * if (blobUrl) revokeBlobUrl(blobUrl); + * }; + * }, [url]); + * ``` + * + * @param url - The blob URL to revoke. Safe to call with non-blob URLs (no-op). */ export function revokeBlobUrl(url: string): void { if (url && url.startsWith('blob:')) {