[elm/file] Selecting files using File.Select.file(s) "sometimes" fails


#1

This is seemingly a known, but not-definitely-resolved? problem.
Reference: javascript - Event onChange won’t trigger after files are selected from code-generated INPUT element - Stack Overflow

Background

File.Select.file and File.Select.files do the following (source):

  1. Generate a input[type="file"] element behind the scene (not rendered)
  2. Set change event handler on the element, handling a new file selection
  3. Dispatch an artificial click event to the element, triggering file selector window

This works, and is actually a method well-known even MDN documentation has it (so-called “hidden file input” trick).
As the linked MDN page says, this trick is employed for replacing “ugly” browser-default file input element with your own styled buttons/elements.

(And as the elm/file documentation states, for security reasons, the above listed sequences must be executed directly upon some user inputs (like actual clicks). Though this restriction is already considered and properly handled by kernel codes, so at least in my experience, it’s working reliably)

Here’s working example (Ellie):

module Main exposing (main)

import Browser
import File exposing (File)
import File.Select
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick)


type alias Model =
    { file : Maybe File }


init : () -> ( Model, Cmd Msg )
init () =
    ( { file = Nothing }, Cmd.none )


type Msg
    = ReqFile
    | GotFile File


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ReqFile ->
            ( model, File.Select.file [ "*/*" ] GotFile )

        GotFile f ->
            ( { model | file = Just f }, Cmd.none )


view : Model -> Html Msg
view model =
    div []
        [ button [ onClick ReqFile ] [ text "File Select" ]
        , div []
            [ text <|
                case model.file of
                    Just f ->
                        File.name f ++ " (" ++ File.mime f ++ ")"

                    Nothing ->
                        "(empty)"
            ]
        ]


main : Program () Model Msg
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = always Sub.none
        }

Symptom

Again, it works, but not reliably. In a small example like the one above, it should almost always work. But when this pattern of codes are introduced in real projects, it “sometimes” fails.

The actual symptom is that after you selected a file (or files) and hit OK in the file selector window, change event is “sometimes” not fired (OR, fired but not caught), thus not registering the file selection!
I encountered this on Chrome (both Windows 10 and mac), Safari. But NOT in Firefox (mac).

By quoting “sometimes” I emphasize this behavior happens very unpredictably. My gut feeling tells in larger and busier apps that run many event handling other than file selections, this happens more, even to the level of “almost never works”. To support this, my app has Time.every ticks for triggering background polling tasks, in that app I see the problem very often. But while disabling Time.every ticks, the problem almost ceases to happen (but still happens occasionally.)

Since I myself have not thoroughly investigated conditions for reproduction, I am a bit hesitant to open an issue, but after some searching I found that this was actually a known issue around this “hidden file input” trick. That is the link I put at the top of the issue.
javascript - Event onChange won’t trigger after files are selected from code-generated INPUT element - Stack Overflow
Also there is another one: javascript - input[type=file] change event are lost occasionally on Chrome - Stack Overflow
The situation described in these SO posts are basically equivalent to my case with File.Select.file although both are 2-year-old threads.

Speculations by posters includes:

  • race condition around call stack
  • node is gone (perhaps GCed?) before file selection
  • issue around registering event handler (node is gone BEFORE handler is registered?), this I doubt though

I actually managed to reproduce the behavior just once on Ellie with the example app above, but not consistently after that. At successful(?) reproduction, I searched around directories in file selector window for several minutes with occasional waits in between (taking time can contribute?).

ToDos/Requests

  • Since I do not have concrete condition to reproduce the problem, I want to hear from folks who happen to have seen similar symptoms.
  • Also, if you have reliable workaround for this issue (preferably using File.Select.file OR other means to use styled file input trigger element.)
  • Hints for narrowing down the problem, since I do have a project and test data that can reliably reproduce the symptom, I can try your suggestions.
    • Actually, the code is available here (linking to related commit) but not yet advertised openly

Thank you for your attention!


#2

We are in the process of switching to new new file and http packages for file uploads. I have not played around with File.Select.file yet, we’ll see how it work. We currently have a label element, rendered as a button, that is linked to a hidden file input via for and id attributes. This allows for custom styling. We then decode the file on file input change manually. Maybe this is workaround for you as well?


#3

That was actually a next thing I wanted to try, since it is also mentioned in MDN.
My concern is that whether the workaround is compatible with elm-ui. Will find out.


#4

I have been experiencing same issue so far.

Here is my testing code taken from my project which uploads multiple files by selecting individual file. You can try it on Ellie and experience the problem if you are lucky enough :stuck_out_tongue: It should be reproduced better on local environment (elm repl) with Chrome.

https://ellie-app.com/45mGKjBvbMJa1

My observations are

  • This happen on Chrome like once in ten times without using Time.every. And interestingly most time, the third or fourth upload attempt fails. Sometimes not happen at all.
  • I could observe it happen on Ellie with Chrome, though the possiblity is relatively low like once in hundred times.
  • On Safari, I could notice the failure but the occurrence rate was lower than Chrome.
  • On Firefox, I have not experienced this failure though not tested much…
  • Tested on Mac OS X only with latest browsers.

This may be not Elm issue but pretty annoying :frowning:


#5

I have seen this as well, but I wasn’t sure what was going on.

The current approach creates an <input> node but does not put it into the DOM. You can see how it works here. The idea was that it’d get garbage collected later at some point, but maybe that is where the problem is.

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?

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.


#6

Glad there are at least multiple cases this is observed! Now it became a clearly known issue.

For now my workaround is reducing main thread work load by suppressing recurring tasks via turning off Time.every while file selector is open. Even in my rather large SPA, this can greatly increase reliability of file selection.

Using <label> and visually-hidden file input could work, but after a few attempt, I found it cumbersome to do that with elm-ui. It is definitely possible with Element.html and Html.label, however we need to “manually” style non-elm-ui part , and carefully keep it inline with other elm-ui-controlled parts.

My first thoought was node getting GCed before callback is called, so yes, adding it to the DOM until it is actually “used” may remedy the situation. But my concern is, if the file selector is cancelled or the same file is selected (the case known for not firing new change event), there seems to be no good chance of removing the node from the DOM. In that case zombie input node can remain in the DOM. Will try that if I got a time, though.


#7

Maybe it is illogical way and contrived method but I think I found a condition that cause this issue almost all time under Chrome on Mac OS X.

The following code is based on the elm/file as mentioned by @evancz .

<!doctype html>
<html>
    <head>
        <title>Title</title>
    </head>
    <body>
        <button onClick="upload()">Upload</button>
        <script>
            let counter = 0;
            function upload() {
              var node = document.createElementNS('http://www.w3.org/1999/xhtml', 'input');
              node.setAttribute('type', 'file');
              node.setAttribute('accept', ['image/*']);
              node.addEventListener('change', function(event) {
                cb(event.target.files[0]);
              });
              node.dispatchEvent(new MouseEvent('click'));
              // if comment out the following line I could not observe the failure
              // console.log(node)
            }
            function cb(file) {
              console.log(`${++counter} : ${file.name}`)
            }
        </script>
    </body>
</html>
  1. When the code was opened from Chrome as a local file, the issue was also confirmed.
  2. Interesting thing is, if you resize the window size while opening the selection dialog box, the file selection fail almost all time. In Chrome on Mac OS X, you can resize the window by double clicking outside of the dialog box.
  3. But if uncomment the console.log(node) below dispatchEvent, the file selection do not fail.
  4. All of the above results also apply to the JavaScript code compiled by Elm.

I am not expert so I can not see what is going on under the hood, but hope this may help.


#8

Does seem like a GC issue. I suspect console.log interferes because the browser console UI retains a reference to it for you to inspect.

You may not like this solution, but it keeps node in scope and fixes the symptoms

 function _File_uploadOne(mimes)
 {
+       var node
        return _Scheduler_binding(function(callback)
        {
-               var node = document.createElementNS('http://www.w3.org/1999/xhtml', 'input');
+               node = document.createElementNS('http://www.w3.org/1999/xhtml', 'input');
                node.setAttribute('type', 'file');

It keeps 1x node around but mitigates the “cancel” scenario (the next click overwrites this node variable and hopefully the old value GCs then…)

CORRECTION: actually the node should be outside even _File_uploadOne so no matter how many uploads were setup, there’s only 1x dangling node

Can someone test this with just JS code to see

Here’s a minimal version with 1 edit that fixes it https://gist.github.com/choonkeat/f0439ca5b95518cd142613224da5e100/revisions

NOTE: my tests were all on Chrome