fix: mount file input in DOM for reliable bucket uploads

Extract openFilePicker() into lib/utils using the same off-screen DOM
pattern as copyToClipboard(). The previous handler called input.remove()
right after click(), so change never fired in some browsers and uploads
silently failed with no toast or network request.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Norman-W 2026-06-20 13:37:28 +08:00
parent ee420fbf29
commit 2e1a9d078f
2 changed files with 50 additions and 15 deletions

View File

@ -29,6 +29,53 @@ export const handleError = (err: unknown) => {
toast.error((err as Error)?.message || "Unknown error"); toast.error((err as Error)?.message || "Unknown error");
}; };
/** Delay before removing an unused file input after the picker closes without a selection. */
const FILE_PICKER_CANCEL_CLEANUP_MS = 500;
export type OpenFilePickerOptions = {
multiple?: boolean;
};
/**
* Opens the native file picker and passes the chosen files to `onSelect`.
* The input is mounted in the document until selection completes; removing it
* immediately after `click()` prevents `change` from firing in some browsers.
*/
export const openFilePicker = (
onSelect: (files: FileList) => void,
options: OpenFilePickerOptions = {}
) => {
const input = document.createElement("input");
input.type = "file";
input.multiple = options.multiple ?? false;
// Keep off-screen but attached, same approach as copyToClipboard().
input.style.position = "absolute";
input.style.left = "-999999px";
const cleanup = () => {
input.remove();
window.removeEventListener("focus", onCancelFocus);
};
const onCancelFocus = () => {
// Cancel closes the picker without firing change; defer so change can run first.
setTimeout(() => {
if (document.body.contains(input)) cleanup();
}, FILE_PICKER_CANCEL_CLEANUP_MS);
};
input.onchange = (e) => {
cleanup();
const files = (e.target as HTMLInputElement).files;
if (files?.length) onSelect(files);
};
document.body.prepend(input);
window.addEventListener("focus", onCancelFocus, { once: true });
input.click();
};
export const copyToClipboard = async (text: string) => { export const copyToClipboard = async (text: string) => {
let textArea: HTMLTextAreaElement | undefined; let textArea: HTMLTextAreaElement | undefined;

View File

@ -2,7 +2,7 @@ import { FolderPlus, UploadIcon } from "lucide-react";
import Button from "@/components/ui/button"; import Button from "@/components/ui/button";
import { usePutObject } from "./hooks"; import { usePutObject } from "./hooks";
import { toast } from "sonner"; import { toast } from "sonner";
import { handleError } from "@/lib/utils"; import { handleError, openFilePicker } from "@/lib/utils";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useBucketContext } from "../context"; import { useBucketContext } from "../context";
import { useDisclosure } from "@/hooks/useDisclosure"; import { useDisclosure } from "@/hooks/useDisclosure";
@ -30,16 +30,7 @@ const Actions = ({ prefix }: Props) => {
}); });
const onUploadFile = () => { const onUploadFile = () => {
const input = document.createElement("input"); openFilePicker((files) => {
input.type = "file";
input.multiple = true;
input.onchange = (e) => {
const files = (e.target as HTMLInputElement).files;
if (!files?.length) {
return;
}
if (files.length > 20) { if (files.length > 20) {
toast.error("You can only upload up to 20 files at a time"); toast.error("You can only upload up to 20 files at a time");
return; return;
@ -49,10 +40,7 @@ const Actions = ({ prefix }: Props) => {
const key = prefix + file.name; const key = prefix + file.name;
putObject.mutate({ key, file }); putObject.mutate({ key, file });
} }
}; }, { multiple: true });
input.click();
input.remove();
}; };
return ( return (