mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +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 CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const MAX_CACHE_ENTRIES = 20;
|
||||||
|
|
||||||
const trafficCache = new Map<string, { data: TrafficUsageResponse; timestamp: number }>();
|
const trafficCache = new Map<string, { data: TrafficUsageResponse; timestamp: number }>();
|
||||||
|
|
||||||
@@ -106,6 +107,16 @@ export const adminTrafficApi = {
|
|||||||
|
|
||||||
trafficCache.set(key, { data, timestamp: Date.now() });
|
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;
|
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 { useNavigate } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -135,6 +135,14 @@ export default function AdminBroadcastCreate() {
|
|||||||
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
|
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
|
||||||
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
|
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
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
|
// Email-specific state
|
||||||
const [emailSubject, setEmailSubject] = useState('');
|
const [emailSubject, setEmailSubject] = useState('');
|
||||||
@@ -271,7 +279,10 @@ export default function AdminBroadcastCreate() {
|
|||||||
|
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
setMediaType('photo');
|
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/')) {
|
} else if (file.type.startsWith('video/')) {
|
||||||
setMediaType('video');
|
setMediaType('video');
|
||||||
setMediaPreview(null);
|
setMediaPreview(null);
|
||||||
@@ -295,6 +306,10 @@ export default function AdminBroadcastCreate() {
|
|||||||
|
|
||||||
// Remove media
|
// Remove media
|
||||||
const handleRemoveMedia = () => {
|
const handleRemoveMedia = () => {
|
||||||
|
if (mediaPreviewRef.current) {
|
||||||
|
URL.revokeObjectURL(mediaPreviewRef.current);
|
||||||
|
mediaPreviewRef.current = null;
|
||||||
|
}
|
||||||
setMediaFile(null);
|
setMediaFile(null);
|
||||||
setMediaPreview(null);
|
setMediaPreview(null);
|
||||||
setUploadedFileId(null);
|
setUploadedFileId(null);
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ export default function AdminPinnedMessageCreate() {
|
|||||||
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
|
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [existingMediaType, setExistingMediaType] = useState<'photo' | 'video' | null>(null);
|
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
|
// Load existing message for editing
|
||||||
const { data: existingMessage, isLoading: isLoadingMessage } = useQuery({
|
const { data: existingMessage, isLoading: isLoadingMessage } = useQuery({
|
||||||
@@ -138,7 +146,10 @@ export default function AdminPinnedMessageCreate() {
|
|||||||
let detectedType: 'photo' | 'video' = 'photo';
|
let detectedType: 'photo' | 'video' = 'photo';
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
detectedType = 'photo';
|
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/')) {
|
} else if (file.type.startsWith('video/')) {
|
||||||
detectedType = 'video';
|
detectedType = 'video';
|
||||||
setMediaPreview(null);
|
setMediaPreview(null);
|
||||||
@@ -159,6 +170,10 @@ export default function AdminPinnedMessageCreate() {
|
|||||||
|
|
||||||
// Remove media
|
// Remove media
|
||||||
const handleRemoveMedia = () => {
|
const handleRemoveMedia = () => {
|
||||||
|
if (mediaPreviewRef.current) {
|
||||||
|
URL.revokeObjectURL(mediaPreviewRef.current);
|
||||||
|
mediaPreviewRef.current = null;
|
||||||
|
}
|
||||||
setMediaFile(null);
|
setMediaFile(null);
|
||||||
setMediaPreview(null);
|
setMediaPreview(null);
|
||||||
setUploadedFileId(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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
@@ -151,7 +151,7 @@ export default function Support() {
|
|||||||
log.debug('Component loaded');
|
log.debug('Component loaded');
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAdmin } = useAuthStore();
|
const isAdmin = useAuthStore((state) => state.isAdmin);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { openTelegramLink, openLink } = usePlatform();
|
const { openTelegramLink, openLink } = usePlatform();
|
||||||
const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null);
|
const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null);
|
||||||
@@ -166,6 +166,34 @@ export default function Support() {
|
|||||||
const [replyAttachment, setReplyAttachment] = useState<MediaAttachment | null>(null);
|
const [replyAttachment, setReplyAttachment] = useState<MediaAttachment | null>(null);
|
||||||
const createFileInputRef = useRef<HTMLInputElement>(null);
|
const createFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const replyFileInputRef = 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
|
// Get support configuration
|
||||||
const { data: supportConfig, isLoading: configLoading } = useQuery({
|
const { data: supportConfig, isLoading: configLoading } = useQuery({
|
||||||
@@ -213,8 +241,11 @@ export default function Support() {
|
|||||||
return;
|
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);
|
const preview = URL.createObjectURL(file);
|
||||||
|
previewRef.current = preview;
|
||||||
setAttachment({ file, preview, uploading: true });
|
setAttachment({ file, preview, uploading: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -250,7 +281,7 @@ export default function Support() {
|
|||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
setNewTitle('');
|
setNewTitle('');
|
||||||
setNewMessage('');
|
setNewMessage('');
|
||||||
setCreateAttachment(null);
|
clearCreateAttachment();
|
||||||
setSelectedTicket(ticket);
|
setSelectedTicket(ticket);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -268,7 +299,7 @@ export default function Support() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] });
|
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] });
|
||||||
setReplyMessage('');
|
setReplyMessage('');
|
||||||
setReplyAttachment(null);
|
clearReplyAttachment();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -444,7 +475,7 @@ export default function Support() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCreateForm(true);
|
setShowCreateForm(true);
|
||||||
setSelectedTicket(null);
|
setSelectedTicket(null);
|
||||||
setCreateAttachment(null);
|
clearCreateAttachment();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
@@ -469,7 +500,7 @@ export default function Support() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedTicket(ticket as unknown as TicketDetail);
|
setSelectedTicket(ticket as unknown as TicketDetail);
|
||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
setReplyAttachment(null);
|
clearReplyAttachment();
|
||||||
}}
|
}}
|
||||||
className={`w-full rounded-bento border p-4 text-left transition-all ${
|
className={`w-full rounded-bento border p-4 text-left transition-all ${
|
||||||
selectedTicket?.id === ticket.id
|
selectedTicket?.id === ticket.id
|
||||||
@@ -574,7 +605,7 @@ export default function Support() {
|
|||||||
{createAttachment ? (
|
{createAttachment ? (
|
||||||
<AttachmentPreview
|
<AttachmentPreview
|
||||||
attachment={createAttachment}
|
attachment={createAttachment}
|
||||||
onRemove={() => setCreateAttachment(null)}
|
onRemove={() => clearCreateAttachment()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -608,7 +639,7 @@ export default function Support() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
setCreateAttachment(null);
|
clearCreateAttachment();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
@@ -715,7 +746,7 @@ export default function Support() {
|
|||||||
{replyAttachment ? (
|
{replyAttachment ? (
|
||||||
<AttachmentPreview
|
<AttachmentPreview
|
||||||
attachment={replyAttachment}
|
attachment={replyAttachment}
|
||||||
onRemove={() => setReplyAttachment(null)}
|
onRemove={() => clearReplyAttachment()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user