From cfb3a6a0e9b15d9b3cbc9986d701af79a1cd9072 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 21 Jul 2026 16:15:29 -0700 Subject: [PATCH] fix(ui): revoke abandoned upload preview object URLs when the playground unmounts Image and PDF previews in the playground are object URLs created on file select. Sending or removing the file revokes them, but navigating away with a pending upload dropped the state without revoking, pinning the file blobs until full page unload. Mirror the current preview URLs into a ref and revoke them in an unmount cleanup --- .../components/chat_ui/ChatUI.test.tsx | 43 +++++++++++++++++++ .../playground/components/chat_ui/ChatUI.tsx | 10 +++++ 2 files changed, 53 insertions(+) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx index 9da3e3a4a08..dc9cc7ba373 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx @@ -413,4 +413,47 @@ describe("ChatUI", () => { } } }); + + it("revokes pending upload preview object URLs on unmount", async () => { + const createSpy = vi.fn(() => "blob:preview-1"); + const revokeSpy = vi.fn(); + const originalCreate = URL.createObjectURL; + const originalRevoke = URL.revokeObjectURL; + URL.createObjectURL = createSpy; + URL.revokeObjectURL = revokeSpy; + + try { + const { container, unmount } = render( + , + ); + + await waitFor(() => { + expect(container.querySelector('input[type="file"]')).toBeInTheDocument(); + }); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(["image data"], "photo.png", { type: "image/png" }); + await act(async () => { + fireEvent.change(fileInput, { target: { files: [file] } }); + }); + + await waitFor(() => { + expect(createSpy).toHaveBeenCalledWith(file); + }); + expect(revokeSpy).not.toHaveBeenCalledWith("blob:preview-1"); + + unmount(); + + expect(revokeSpy).toHaveBeenCalledWith("blob:preview-1"); + } finally { + URL.createObjectURL = originalCreate; + URL.revokeObjectURL = originalRevoke; + } + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx index d2cf27e0c8b..1e0cdab45d8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx @@ -254,6 +254,16 @@ const ChatUI: React.FC = ({ const [chatUploadedImage, setChatUploadedImage] = useState(null); const [chatImagePreviewUrl, setChatImagePreviewUrl] = useState(null); const [uploadedAudio, setUploadedAudio] = useState(null); + const pendingPreviewUrlsRef = useRef([]); + + useEffect(() => { + pendingPreviewUrlsRef.current = [...imagePreviewUrls, responsesImagePreviewUrl, chatImagePreviewUrl].filter( + (url): url is string => !!url, + ); + }, [imagePreviewUrls, responsesImagePreviewUrl, chatImagePreviewUrl]); + + useEffect(() => () => pendingPreviewUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)), []); + const [isGetCodeModalVisible, setIsGetCodeModalVisible] = useState(false); const [generatedCode, setGeneratedCode] = useState(""); const [selectedSdk, setSelectedSdk] = useState<"openai" | "azure">("openai");