mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user