Cross-browser compatibility fix for File.Select.file(s) in elm/file

We’re using the new elm/file package at work, and as we are completing our first project that uses it, I noticed that file selection fails in some situations (Elm fails to receive the file selected by the OS). Searching for this led me to this Discourse thread: [elm/file] Selecting files using File.Select.file(s) "sometimes" fails and this GitHub issue.

I am reposting my reply to the GitHub issue here in order to gather more feedback from more people.


I just did some testing based off of what @evancz suggested in the Discourse topic, to quote him:

Can someone test this with just JS code to see:

  1. If the bug reproduces with the exact same JS and no Elm code.
  2. If so, does adding it to the DOM and then removing it in the event handler make it more reliable?

The existing version of the Elm.Kernel.File function is:

function _File_uploadOne(mimes)
{
	return __Scheduler_binding(function(callback)
	{
		var node = document.createElementNS('http://www.w3.org/1999/xhtml', 'input');
		node.setAttribute('type', 'file');
		node.setAttribute('accept', A2(__String_join, ',', mimes));
		node.addEventListener('change', function(event)
		{
			callback(__Scheduler_succeed(event.target.files[0]));
		});
		node.dispatchEvent(new MouseEvent('click'));
	});
}

The issue with this code is that it completely fails on Safari on certain versions of iOS, and seems to be unreliable on Android (as reported in the original Discourse thread).

However, I made a modification to make the function append the node to the DOM and then remove it:

function _File_uploadOne(mimes)
{
    return _Scheduler_binding(function(callback)
    {
        var node = document.createElement('input');
        node.setAttribute('type', 'file');
        node.setAttribute('accept', A2(elm$core$String$join, ',', mimes));
        node.addEventListener('change', function(event)
        {  
            // node is removed after change event fires
            document.body.removeChild(node);
            callback(_Scheduler_succeed(event.target.files[0]));
        });   
        // node is appended before click event is dispatched
        document.body.appendChild(node);
        node.dispatchEvent(new MouseEvent('click'));
    });   
}

This change fixes the issue on Safari on iOS 10 (fixing both ‘Take Photo’ and ‘Photo Library’). This also seems to work 100% reliably on Chrome on Android (which failed sporadically before), and continues to work on Chrome/Firefox on Linux without any issue.

It turns out that Browser.Elm.Kernel.Debugger uses the exact same technique for its own upload function.

One of the concerns that Evan mentions is modifying the DOM may cause issues with the virtual-dom:

I do not really like (2) because I do not want to mess with the DOM and potentially disrupt virtual-dom. The click should be synchronous, so it may work alright. Not super confident in that approach without testing it out a bunch.

One alternative might be to append the temporary element to the outside of the <body> by using document.documentElement.appendChild() instead of document.body.appendChild() as in my example above. This places the <input> element outside of the <body> and right before the closing </html> tag at the end of the document, if this helps mitigate the risk of interfering with the virtual-dom.

A cost of this approach is that if the user cancels file selection, the <input> node will stay orphaned at the end of the browser. There probably isn’t a reliable way to mitigate this issue given that It is quite difficult to reliably detect if someone has clicked the Cancel button across browsers, but this seems like a small price to pay in exchange for file selection working reliably on all platforms.

Happy to hear any thoughts or other experiences anyone has had with this.

2 Likes

For what it’s worth:

I’ve implemented a “file upload” feature in 0.18 (so no elm/file package of course). The <input type="file"> is always in the DOM, but invisible. I’m using a port to trigger a click on the element.

I’m not sure how feasible “always keep the input in the DOM” is, but then we wouldn’t have to worry about removing the input when the dialog is canceled.

Based on what people are saying, maybe a way to avoid building up tons of nodes when people cancel is like this:

var node = document.createElement('input');
node.setAttribute('type', 'file');
var listener;

function _File_uploadOne(mimes)
{
    return _Scheduler_binding(function(callback)
    {
        node.setAttribute('accept', A2(elm$core$String$join, ',', mimes));
        node.removeEventListener('change', listener);
        listener = function(event){  
            document.body.removeChild(node);
            callback(_Scheduler_succeed(event.target.files[0]));
        };
        node.addEventListener('change', listener);   
        document.body.appendChild(node);
        node.dispatchEvent(new MouseEvent('click'));
    });   
}

Concerns include:

  • Doesn’t it need to be invisible now? Do things still work when that is added?
  • Does adding to the end of <body> disrupt virtual-dom or not? I am not certain.
  • Would Browser.element programs disrupt the surrounding context by using <body>? Maybe a surrounding React or jQuery or Angular project would care?

I think a good way to proceed independently is to create gists that contain a single HTML file with JS embedded in a <script> tag. The idea is that you could address these concerns with examples that can easily be tested in a bunch of browsers. That lowers the verification burden. Ideally those examples would be as small as possible to illustrate the point.

Also, what if we reuse the node like this without putting it in the DOM? Or does that still act weird? I think it would be like this:

var node = document.createElement('input');
node.setAttribute('type', 'file');
var listener;

function _File_uploadOne(mimes)
{
    return _Scheduler_binding(function(callback)
    {
        node.setAttribute('accept', A2(elm$core$String$join, ',', mimes));
        node.removeEventListener('change', listener);
        listener = function(event){  
            callback(_Scheduler_succeed(event.target.files[0]));
        };
        node.addEventListener('change', listener);   
        node.dispatchEvent(new MouseEvent('click'));
    });   
}

If you make a gist of HTML to test, please share which browsers you tested it with and what happened. Maybe something like:

  • Reusing node in body (link to gist)
    • Chrome X.Y.Z :white_check_mark:
    • Safari L.M.N :white_check_mark:
  • Reusing node not in body (link to gist)
    • Chrome X.Y.Z :white_check_mark:
    • FireFox A.B.C :white_check_mark:
    • Safari on iOS U.V.W :x:

Etc.

1 Like

I haven’t had a chance to set up the HTML gists, but I tested the variants you supplied here by swapping them into my application, and the last one (the one that doesn’t append the node but keeps it outside of the scope of the function), seems to work on all of my devices (Chrome on Android 8, Safari on iOS 10, Chrome/Firefox on Linux).

This looks very promising!

That approach does not have any huge risks as far as I can tell, so I gave it a shot with a 1.0.2 patch release with roughly the same code as above, shown here.

It uses the same backing <input> node for each command. Assuming the root problem is that some browsers GC even though the function may still be called, this resolves that by keeping the <input> alive in the global scope of the program. It does not get added to the DOM though, which avoids all the risks I was asking about.

Based on the GC theory, I made a program that will allocate a bunch of models very quickly to try to get GC to run more often. The result was:

  • Example 1.0.1 which fails pretty reliably in Safari 12, especially if you wait a bit on the file selection dialog.
  • Example 1.0.2 which I could not get to fail in Safari 12 or FireFox 64.

Thanks for getting some brainstorming going, and hopefully this is at least better than what we had before. If folks try it out in different browsers, please comment here confirming “works in Chrome X.Y.Z” or whatever!

12 Likes

Seems to work reliably on mobile Safari on iOS 12.1.3, even as a webview inside the discourse app

1 Like

The only problem with this new approach occurs when a user selects the same file twice in a row. If I choose file “X” the first time and then file “Y” the second time, the change event, and therefore the attached message is fired both times. However, if I select “X” twice in a row from the file picker, the change event doesn’t fire the second time because the input element hasn’t changed, and therefore the message isn’t triggered.

I would try to offer advice on how to fix this, but I’m not entirely sure how. The only thing I can think of would be to change the File.select function create a new input each time and then to return some sort of opaque type. That would be then be passed in to another function to destroy the input that was created. That puts the onus on the developer to make sure that a ton of file inputs don’t get left hanging around, but it should resolve the GC issue as well as the double input problem.

1 Like

Ah, good point about the event not triggering when the selection “doesn’t change”.

I wonder if it can be resolved by generating a new input field each time, but being sure to save it on the global variable. I don’t think GC will be clever enough to collect it because the global variable will be “used again” in the next function call. Like this:

var node;

function _File_uploadOne(mimes)
{
    return _Scheduler_binding(function(callback)
    {
        node = document.createElement('input');
        node.setAttribute('type', 'file');
        node.setAttribute('accept', A2(elm$core$String$join, ',', mimes));
        node.addEventListener('change', function(event) {  
            callback(_Scheduler_succeed(event.target.files[0]));
        });
        node.dispatchEvent(new MouseEvent('click'));
    });   
}

Can someone check if this solves the prior flakiness problem and solves the “selecting the same file means no change event” problem?

This program demonstrates the problem that @Titus_Anderson mentions. The counter should reset each time someone chooses files, and if you choose the same file twice in a row, it does not reset.

I gave that approach I mentioned a try in this commit. It is working with my example, but it’d be cool to get independent verification before publishing.

1 Like

How would I go about testing? I’m happy to try it out, I’m just not sure how.

Just make an HTML file with JS inside of it. See how it works in different settings.

I can confirm that this new approach works on:

  • Firefox 66 on Linux
  • Chromium 71 on Linux
  • Chrome 71 on Android 8
  • Safari on iOS 10.3.3
1 Like

I’ve tested Chrome 71, Firefox 65, and Edge 42 in Windows and it seems to work fine.

Thanks @christian and @Titus_Anderson for trying it out. That fix is now available in 1.0.3 :slight_smile:

6 Likes

@evancz

Recent updates to elm/file and elm/json have broken this module in IE11. I’ve created PRs to fix it. I’d love if these could get merged and I’ll be happy to help explain or talk about this.


This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.