From 2e1a9d078fd39261f2870eb304cdcc5366027969 Mon Sep 17 00:00:00 2001 From: Norman-W <85535885@qq.com> Date: Sat, 20 Jun 2026 13:37:28 +0800 Subject: [PATCH] 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 --- src/lib/utils.ts | 47 +++++++++++++++++++++ src/pages/buckets/manage/browse/actions.tsx | 18 ++------ 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5ebd62c..65f882b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -29,6 +29,53 @@ export const handleError = (err: unknown) => { 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) => { let textArea: HTMLTextAreaElement | undefined; diff --git a/src/pages/buckets/manage/browse/actions.tsx b/src/pages/buckets/manage/browse/actions.tsx index fea7b84..9b616a5 100644 --- a/src/pages/buckets/manage/browse/actions.tsx +++ b/src/pages/buckets/manage/browse/actions.tsx @@ -2,7 +2,7 @@ import { FolderPlus, UploadIcon } from "lucide-react"; import Button from "@/components/ui/button"; import { usePutObject } from "./hooks"; import { toast } from "sonner"; -import { handleError } from "@/lib/utils"; +import { handleError, openFilePicker } from "@/lib/utils"; import { useQueryClient } from "@tanstack/react-query"; import { useBucketContext } from "../context"; import { useDisclosure } from "@/hooks/useDisclosure"; @@ -30,16 +30,7 @@ const Actions = ({ prefix }: Props) => { }); const onUploadFile = () => { - const input = document.createElement("input"); - input.type = "file"; - input.multiple = true; - - input.onchange = (e) => { - const files = (e.target as HTMLInputElement).files; - if (!files?.length) { - return; - } - + openFilePicker((files) => { if (files.length > 20) { toast.error("You can only upload up to 20 files at a time"); return; @@ -49,10 +40,7 @@ const Actions = ({ prefix }: Props) => { const key = prefix + file.name; putObject.mutate({ key, file }); } - }; - - input.click(); - input.remove(); + }, { multiple: true }); }; return (