From 7cf72735ece0510acc7a4e6af8997e8e7acdc9d8 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Feb 2026 17:15:11 +0300 Subject: [PATCH] fix: plug memory leaks in blob URLs and traffic cache - Add URL.revokeObjectURL on unmount and media removal in AdminBroadcastCreate, AdminPinnedMessageCreate, Support - Track blob URLs via refs to revoke before creating new ones - Add clearCreateAttachment/clearReplyAttachment helpers in Support to properly revoke blob URLs when clearing attachments - Cap adminTraffic cache at 20 entries with FIFO eviction to prevent unbounded memory growth from different filter combinations --- src/api/adminTraffic.ts | 11 ++++++ src/pages/AdminBroadcastCreate.tsx | 19 +++++++++- src/pages/AdminPinnedMessageCreate.tsx | 17 ++++++++- src/pages/Support.tsx | 51 +++++++++++++++++++++----- 4 files changed, 85 insertions(+), 13 deletions(-) diff --git a/src/api/adminTraffic.ts b/src/api/adminTraffic.ts index 437e126..130a95c 100644 --- a/src/api/adminTraffic.ts +++ b/src/api/adminTraffic.ts @@ -63,6 +63,7 @@ export type TrafficParams = { }; const CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const MAX_CACHE_ENTRIES = 20; const trafficCache = new Map(); @@ -106,6 +107,16 @@ export const adminTrafficApi = { trafficCache.set(key, { data, timestamp: Date.now() }); + // Evict oldest entries to prevent unbounded memory growth + if (trafficCache.size > MAX_CACHE_ENTRIES) { + const iterator = trafficCache.keys(); + while (trafficCache.size > MAX_CACHE_ENTRIES) { + const oldest = iterator.next(); + if (oldest.done) break; + trafficCache.delete(oldest.value); + } + } + return data; }, diff --git a/src/pages/AdminBroadcastCreate.tsx b/src/pages/AdminBroadcastCreate.tsx index 6e514f8..5e6073b 100644 --- a/src/pages/AdminBroadcastCreate.tsx +++ b/src/pages/AdminBroadcastCreate.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useMemo } from 'react'; +import { useState, useRef, useMemo, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -135,6 +135,14 @@ export default function AdminBroadcastCreate() { const [mediaPreview, setMediaPreview] = useState(null); const [uploadedFileId, setUploadedFileId] = useState(null); const [isUploading, setIsUploading] = useState(false); + const mediaPreviewRef = useRef(null); + + // Revoke blob URLs on unmount to prevent memory leaks + useEffect(() => { + return () => { + if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current); + }; + }, []); // Email-specific state const [emailSubject, setEmailSubject] = useState(''); @@ -271,7 +279,10 @@ export default function AdminBroadcastCreate() { if (file.type.startsWith('image/')) { setMediaType('photo'); - setMediaPreview(URL.createObjectURL(file)); + if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current); + const url = URL.createObjectURL(file); + mediaPreviewRef.current = url; + setMediaPreview(url); } else if (file.type.startsWith('video/')) { setMediaType('video'); setMediaPreview(null); @@ -295,6 +306,10 @@ export default function AdminBroadcastCreate() { // Remove media const handleRemoveMedia = () => { + if (mediaPreviewRef.current) { + URL.revokeObjectURL(mediaPreviewRef.current); + mediaPreviewRef.current = null; + } setMediaFile(null); setMediaPreview(null); setUploadedFileId(null); diff --git a/src/pages/AdminPinnedMessageCreate.tsx b/src/pages/AdminPinnedMessageCreate.tsx index b405da7..4398c4b 100644 --- a/src/pages/AdminPinnedMessageCreate.tsx +++ b/src/pages/AdminPinnedMessageCreate.tsx @@ -86,6 +86,14 @@ export default function AdminPinnedMessageCreate() { const [uploadedFileId, setUploadedFileId] = useState(null); const [isUploading, setIsUploading] = useState(false); const [existingMediaType, setExistingMediaType] = useState<'photo' | 'video' | null>(null); + const mediaPreviewRef = useRef(null); + + // Revoke blob URLs on unmount to prevent memory leaks + useEffect(() => { + return () => { + if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current); + }; + }, []); // Load existing message for editing const { data: existingMessage, isLoading: isLoadingMessage } = useQuery({ @@ -138,7 +146,10 @@ export default function AdminPinnedMessageCreate() { let detectedType: 'photo' | 'video' = 'photo'; if (file.type.startsWith('image/')) { detectedType = 'photo'; - setMediaPreview(URL.createObjectURL(file)); + if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current); + const url = URL.createObjectURL(file); + mediaPreviewRef.current = url; + setMediaPreview(url); } else if (file.type.startsWith('video/')) { detectedType = 'video'; setMediaPreview(null); @@ -159,6 +170,10 @@ export default function AdminPinnedMessageCreate() { // Remove media const handleRemoveMedia = () => { + if (mediaPreviewRef.current) { + URL.revokeObjectURL(mediaPreviewRef.current); + mediaPreviewRef.current = null; + } setMediaFile(null); setMediaPreview(null); setUploadedFileId(null); diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index a1bd3d4..822d512 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; @@ -151,7 +151,7 @@ export default function Support() { log.debug('Component loaded'); const { t } = useTranslation(); - const { isAdmin } = useAuthStore(); + const isAdmin = useAuthStore((state) => state.isAdmin); const queryClient = useQueryClient(); const { openTelegramLink, openLink } = usePlatform(); const [selectedTicket, setSelectedTicket] = useState(null); @@ -166,6 +166,34 @@ export default function Support() { const [replyAttachment, setReplyAttachment] = useState(null); const createFileInputRef = useRef(null); const replyFileInputRef = useRef(null); + const createPreviewRef = useRef(null); + const replyPreviewRef = useRef(null); + + // Revoke blob URLs on unmount to prevent memory leaks + useEffect(() => { + const createRef = createPreviewRef; + const replyRef = replyPreviewRef; + return () => { + if (createRef.current) URL.revokeObjectURL(createRef.current); + if (replyRef.current) URL.revokeObjectURL(replyRef.current); + }; + }, []); + + const clearCreateAttachment = () => { + if (createPreviewRef.current) { + URL.revokeObjectURL(createPreviewRef.current); + createPreviewRef.current = null; + } + clearCreateAttachment(); + }; + + const clearReplyAttachment = () => { + if (replyPreviewRef.current) { + URL.revokeObjectURL(replyPreviewRef.current); + replyPreviewRef.current = null; + } + clearReplyAttachment(); + }; // Get support configuration const { data: supportConfig, isLoading: configLoading } = useQuery({ @@ -213,8 +241,11 @@ export default function Support() { return; } - // Create preview + // Revoke old blob URL before creating new one + const previewRef = setAttachment === setCreateAttachment ? createPreviewRef : replyPreviewRef; + if (previewRef.current) URL.revokeObjectURL(previewRef.current); const preview = URL.createObjectURL(file); + previewRef.current = preview; setAttachment({ file, preview, uploading: true }); try { @@ -250,7 +281,7 @@ export default function Support() { setShowCreateForm(false); setNewTitle(''); setNewMessage(''); - setCreateAttachment(null); + clearCreateAttachment(); setSelectedTicket(ticket); }, }); @@ -268,7 +299,7 @@ export default function Support() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] }); setReplyMessage(''); - setReplyAttachment(null); + clearReplyAttachment(); }, }); @@ -444,7 +475,7 @@ export default function Support() { onClick={() => { setShowCreateForm(true); setSelectedTicket(null); - setCreateAttachment(null); + clearCreateAttachment(); }} > @@ -469,7 +500,7 @@ export default function Support() { onClick={() => { setSelectedTicket(ticket as unknown as TicketDetail); setShowCreateForm(false); - setReplyAttachment(null); + clearReplyAttachment(); }} className={`w-full rounded-bento border p-4 text-left transition-all ${ selectedTicket?.id === ticket.id @@ -574,7 +605,7 @@ export default function Support() { {createAttachment ? ( setCreateAttachment(null)} + onRemove={() => clearCreateAttachment()} /> ) : (