Five ways a web file upload silently fails — and one small, zero-dependency module that fixes all of them.
import {
openPickerHardened,
preventGlobalFileDrop,
wirePasteUpload,
drainEarlyPick,
saveBlobHardened,
} from "file-input-hardening";
function init(input: HTMLInputElement, { signal }: { signal: AbortSignal }) {
preventGlobalFileDrop(signal);
wirePasteUpload((file) => handleFile(file), signal);
input.addEventListener("change", () => {
const file = input.files?.[0];
if (file) handleFile(file);
input.value = ""; // same-file re-pick must fire change again
}, { signal });
const early = drainEarlyPick(input);
if (early) handleFile(early);
uploadButton.addEventListener("click", () => openPickerHardened(input), { signal });
}These are the "user clicked and nothing happened" bugs — impossible to debug from logs because the events simply never fire. All verified in production and against browser bug trackers.
| # | Failure | Fix |
|---|---|---|
| 1 | Picking the same file twice doesn't fire change (Chrome bug 27849 — value didn't change) |
openPickerHardened() clears value before .click() |
| 2 | Dropping a file outside the dropzone navigates the browser to the file — the page blanks, looks like a crash | preventGlobalFileDrop() — global preventDefault, file drags only |
| 3 | Ctrl/Cmd+V of a screenshot does nothing | wirePasteUpload() — document-level paste, skips text fields |
| 4 | File picked before your deferred JS loaded is silently dropped; page looks dead until refresh | drainEarlyPick() recovers it from element state |
| 5 | <a download> silently no-ops on iOS Safari; Chrome's short revoke window cancels slow downloads (bug 1126316) |
saveBlobHardened() — iOS share sheet, window.open fallback, 30 s revoke |
npm install file-input-hardeningZero dependencies. Browser-only (attaches DOM listeners); importing in SSR code is safe — browser APIs are only touched when the functions run.
Call synchronously inside the click gesture — a setTimeout/await
before it drops the transient user activation and iOS Safari silently
refuses to open the picker (WebKit bug 225559).
Once per page/component init.
Calls onImage(file) with the first image on the clipboard. Attached to
document — paste is a global gesture; users don't focus dropzones first.
Call once after wiring your real change listener. Works with no setup
(reads the selection back from input.files — the event notification was
lost, the file wasn't). For the strongest guarantee, also inline this tiny
guard in your HTML <head> so a pre-module change parks the File:
<script>
addEventListener("change", (e) => {
if (window.__fileInputMounted) return;
const t = e.target;
if (t instanceof HTMLInputElement && t.type === "file" && t.files?.[0]) {
t.__earlyPick = t.files[0];
t.setAttribute("data-early-pick", "");
}
}, true);
</script>Returns false only when every save path failed — surface an error to the
user in that case. Never throws.
Download a remote URL (CDN file) without buffering it through fetch →
blob first — the browser streams it and the download starts instantly on
the gesture.
- The AbortSignal is your cleanup: pass the same signal you use for the rest of the component and abort it on unmount. Nothing else to tear down.
saveBlobHardenedintentionally does NOT useshowSaveFilePicker: its transient user activation is consumed by anyawaitbefore the call (Chromium issue 1193489), which makes real tool flows (async encode → save) throw an AbortError indistinguishable from a user cancel. Squoosh and TinyPNG use plain<a download>for the same reason.- Filename renaming via
downloadonly applies same-origin; cross-origin names come fromContent-Disposition.
MIT.
Extracted from the production code of RoundCut — free, in-browser image tools — where these helpers run on every upload surface across 29 languages.