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
This commit is contained in:
Fringg
2026-02-23 17:15:11 +03:00
parent 17b2f2e903
commit 7cf72735ec
4 changed files with 85 additions and 13 deletions

View File

@@ -63,6 +63,7 @@ export type TrafficParams = {
};
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const MAX_CACHE_ENTRIES = 20;
const trafficCache = new Map<string, { data: TrafficUsageResponse; timestamp: number }>();
@@ -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;
},

View File

@@ -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<string | null>(null);
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const mediaPreviewRef = useRef<string | null>(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);

View File

@@ -86,6 +86,14 @@ export default function AdminPinnedMessageCreate() {
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [existingMediaType, setExistingMediaType] = useState<'photo' | 'video' | null>(null);
const mediaPreviewRef = useRef<string | null>(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);

View File

@@ -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<TicketDetail | null>(null);
@@ -166,6 +166,34 @@ export default function Support() {
const [replyAttachment, setReplyAttachment] = useState<MediaAttachment | null>(null);
const createFileInputRef = useRef<HTMLInputElement>(null);
const replyFileInputRef = useRef<HTMLInputElement>(null);
const createPreviewRef = useRef<string | null>(null);
const replyPreviewRef = useRef<string | null>(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();
}}
>
<PlusIcon />
@@ -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 ? (
<AttachmentPreview
attachment={createAttachment}
onRemove={() => setCreateAttachment(null)}
onRemove={() => clearCreateAttachment()}
/>
) : (
<button
@@ -608,7 +639,7 @@ export default function Support() {
variant="secondary"
onClick={() => {
setShowCreateForm(false);
setCreateAttachment(null);
clearCreateAttachment();
}}
>
{t('common.cancel')}
@@ -715,7 +746,7 @@ export default function Support() {
{replyAttachment ? (
<AttachmentPreview
attachment={replyAttachment}
onRemove={() => setReplyAttachment(null)}
onRemove={() => clearReplyAttachment()}
/>
) : (
<button