The Trouble With Editing And Uploading Files In The Browser
With the introduction of the File API we gained the ability to edit files in the browser. We could finally resize images, unzip files, and generate new files based on interactions in the browser. One caveat though, you couldn’t upload these files.
Well you could, but you had to resort to either XMLHttpRequest
or fetch
, see, it’s not possible to set the value of a file input element. This means you can’t submit a custom file along with a classic form submit, you have to asynchronously upload the file. This really put the brakes on any progressive enhancement solutions to file editing. If you decide to modify a file on the client, then you also have to make sure you make changes on the server so you can receive the modified file.
As a product developer building image editing products this really grinds my gears. I’d love to offer my products as client-side only solutions. But that’s impossible because asynchronous file uploads require server-side modifications. WordPress, Netlify, Shopify, Bubble.io, they all offer default file input elements, but there’s no straightforward way to support them without writing a client-side and server-side plugin. In the case of WordPress this means offering a plugin for each and every form builder out there. Not very realistic.
But a couple months ago something changed.
Setting a custom file to a file input
It’s really quite logical that we can’t set the value
of the file input element. Doing so would allow us to point it at files on the visitors file system.
<input type="file" />
<script>
document.querySelector('input').value = 'some/file/i/want/to/have';
</script>
Obviously this would be a huge security risk.
Setting the file input value
property is off the table.
What about the file input files
property? If we could somehow update the files property or update the files in it, that would solve the issue.
The files
property holds a reference to a FileList
. Great! Let’s create a new FileList()
and overwrite the one on the file input. Unfortunately there’s no FileList
constructor. There is also no “add a file” method exposed on the FileList
instance. On top of that the File
object doesn’t have a method to update the file data in place, so we can’t update the individual file objects in the files
list.
Well that’s it then.
And it was untill a couple months a go Hidde de Vries pointed me to this issue on the WHATWG, Turns out there’s a different API we can use to achieve our goal.
Firefox, Chrome, and Safari have recently added support for the DataTransfer
constructor. The DataTransfer
Class is most commonly used when dragging and dropping files from the user device to the webpage.
It has a files
property of type FileList
🎉
It also has an items.add
method for adding items to this list 🎉
Oh la la!
<input type="file" />
<script>
// Create a DataTransfer instance and add a newly created file
const dataTransfer = new DataTransfer();
dataTransfer.items.add(new File(['hello world'], 'This_Works.txt'));
// Assign the DataTransfer files list to the file input
document.querySelector('input').files = dataTransfer.files;
</script>
If you’re browser can run the code above the file input below will show “This_Works.txt” as the input value. Starting from version 14.1 Safari also supports setting the files
property in this manner but it won’t update the label next to the input.
It just works. Fantastic! We now have a method to send files created on the client to the server without having to make any changes to the server-side API.
This doesn’t work on IE, non Chromium Edge, and Safari versions before 14.1.
Alternatives for other browsers
If we want to submit our file data along with the form post, what can we offer users on these other browsers? There are currently two alternate solutions I can think of. One requires changes on the server, the other might be buggy depending on your use case.
Let’s take alook.
Encode the file data
We can encode the file data as a base64 string or dataURL, store the resulting string in a hidden input element and then send it on its way when the form is submitted. This will require changes to the server, the server will have to be aware an encoded file might be submitted as well. The server will also have to decode the dataURL and turn it back into a File object.
We can use the FileReader
API to turn a File
into a dataURL
.
<input type="file" />
<input type="hidden" />
<script>
document.querySelector('input[type="file"]').onchange = (e) => {
const reader = new FileReader();
reader.onloadend = () => {
document.querySelector('input[type="hidden"]').value =
reader.result;
};
reader.readAsDataURL(e.target.files[0]);
};
</script>
A couple of issues my clients reported when using this method.
- Security related scripts running on the server that monitor traffic might flag the form post as suspicious as it contains a lot of string based data.
- When submitting large files, that means files above 1MB, it’s highly likely the browser will crash with a “ran out of memory” error. This differs per browser, but I’ve seen it happen on both mobile and desktop browsers.
- You don’t see a change in the file input. So it’s a good idea to reset, disable, or hide it when submitting the form.
Encoding files is a fine solution if you’re dealing with small images, anything bigger than 1MB and I’d steer clear.
Capture the form submit
We can add custom files when submitting a form asynchronously. So another solution is capturing the entire form submit and asynchronously submitting the form to the same end point (action
attribute) using XMLHttpRequest
or fetch
.
This is what I’ve tried to do with Poost (this is very much a prototype, also I’m bad at naming things on the spot). Poost captures the form submit, and then posts the form asynchronously instead. This allows us to build a custom FormData
object, adding our custom file (stored in the _value
property) instead of the files in the files
property.
<input type="file" />
<script>
// Create a new File object
const myFile = new File(['Hello World!'], 'myFile.txt', {
type: 'text/plain',
lastModified: new Date(),
});
// Assign File to _value property
const target = document.querySelector('input[type="file"]');
target._value = [myFile];
</script>
This actually works quite well. We post the same data to the same end point. Things start to get tricky when you realise that the returned page also needs to be rendered to the screen (normally the browser navigates to it). Where are we going to render it, what to do with the navigator history, how to deal with script tags on the page, what about IE (no surprise there).
- Again, when setting
_value
you don’t see a change in the file input. So it’s a good idea to reset, disable, or hide it when submitting the form. - We’re taking over a lot of default browser behavior, that is always a recipe for disaster.
Still, for very basic forms this works nicely. You don’t have to modify the server and it could even be loaded conditionally as a fallback for when a browser doesn’t support new DataTransfer()
.
The state of things
So our file upload situation, while it has improved, is still all but fantastic.
We’re still stuck with these bandaid solutions because of IE, Edge, and Safari. If you have the luxury it’s probably easier to make changes on the server to facilitate async transfers. If you’re in a situation where that’s impossible I hope the solutions offered above might just fit your situation perfectly and help you out.