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
This commit is contained in:
ryan-crabbe-berri 2026-07-21 16:15:29 -07:00
parent 2b2ae4ca49
commit cfb3a6a0e9
2 changed files with 53 additions and 0 deletions

View file

@ -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(
<ChatUI
accessToken="1234567890"
token="1234567890"
userRole="user"
userID="1234567890"
disabledPersonalKeyCreation={false}
/>,
);
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;
}
});
});

View file

@ -254,6 +254,16 @@ const ChatUI: React.FC<ChatUIProps> = ({
const [chatUploadedImage, setChatUploadedImage] = useState<File | null>(null);
const [chatImagePreviewUrl, setChatImagePreviewUrl] = useState<string | null>(null);
const [uploadedAudio, setUploadedAudio] = useState<File | null>(null);
const pendingPreviewUrlsRef = useRef<string[]>([]);
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");