Coury Ditch reported on the trickiest Elm bug he's ever seen. It's a good read and a good caution against holding stateful information into your Elm messages. When I was very new to Elm one of the first things I had to do at a new company was figure out a bug that basically had the 'Stale message' anti-pattern as main reason behind it. So I'm going to described the bug and the solution.
The company's flagship product included a registration form, but being a financial application the registration form was a bit more onerous than simple email and password. It had around eight fields or so. The problem was that Chrome's autofill feature wasn't working, it was filling in the last field only. Why?
So, you can think of the registration form, although it had at least 8 fields, as something like this:
1type alias RegForm =
2 { firstName : String
3 , lastName : String
4 , email : String
5 , password : String
6 , passwordRepeat : String
7 }
This was just stored at the top level in the main Model
record type. Now, whoever had initially developed the app had thought it would be a neat shortcut to avoid creating a message for each input field, and just create one message, with the new registration form:
1type Msg
2 = ...
3 | UpdateRegForm RegistrationForm
4 ...
5
6type alias Model =
7 { regForm : RegistrationForm
8 , ...
9 }
10
11update : Msg -> Model -> (Model, Cmd Msg)
12update message model =
13 case message of
14 ...
15 UpdateRegForm newForm ->
16 ( { model | regForm = newForm }
17 , Cmd.none
18 )
19 ...
20
Now when viewing a field of the registration form, the entire form was updated. So for example the firstName
field might have been something like:
1import Html exposing (Html)
2import Html.Attributes as Attributes
3import Html.Events as Events
4
5
6viewRegForm : { a | regForm : RegistrationForm } -> Html Msg
7viewRegForm { regForm } =
8 let
9 firstName =
10 Html.div
11 [ Attributes.class "first-name-field" ]
12 [ Html.label [] [ Html.text "First name" ]
13 , Html.input
14 [ Events.onInput (\input -> { regForm | firstName = input })
15 , Attributes.value regForm.firstName
16 ]
17 []
18 ]
19
20 ...
In reality it was a little more complicated and I think there was a helper function to draw a text field input. The main point however is the argument to the Events.onInput
, this, as always, is a function that takes the new value of the input field. The difference here is that the result of the function is the entire registration form that is used to replace the existing on on the model in the update
function.
The problem was, that because Chrome was filling out several fields, essentially simultaneously (because they were all in the same animation frame), then each update of a field over-wrote the previous update. In other words you got a bunch of messages such as:
1UpdateRegForm { currentRegForm | firstName = "Billy" }
2UpdateRegForm { currentRegForm | lastName = "Shears" }
3UpdateRegForm { currentRegForm | email = "billy@sergeantpeppers.com" }
4UpdateRegForm { currentRegForm | password = "1234" }
5UpdateRegForm { currentRegForm | passwordRepeat = "1234" }
This is because each update message is calculated from the same model, and because in the update
function you're replacing the entire registration form, all the previous updates are lost.
The proper solution would have been to suck it up and create a message for each field, as in:
1type Msg
2 = ...
3 | UpdateRegFormFirstName String
4 | UpdateRegFormLastName String
5 ...
That way because each message only contains the information for its own associated field, it only updates that part of the registration form, and hence subsequent ones, even if done in the same animation frame (ie. without an Elm render in between) will not overwrite previous messages' changes:
1update : Msg -> Model -> (Model, Cmd Msg)
2update message model =
3 case message of
4 ...
5 UpdateRegFormFirstName input ->
6 let
7 oldForm =
8 model.regForm
9 newForm =
10 { oldForm | firstName = firstName }
11 in
12 ( { model | regForm = newForm }
13 , Cmd.none
14 )
15 ...
Autofill now works perfectly. However, if you like the idea of a single message to update the registration form, you can still have that. You just need to put the function in the message. The result is that your message is still saying how to update the registration form, rather than what to update the registration form with:
1type Msg
2 = ...
3 | UpdateRegForm (RegistrationForm -> RegistrationForm)
4 ...
5
6type alias Model =
7 { regForm : RegistrationForm
8 , ...
9 }
10
11update : Msg -> Model -> (Model, Cmd Msg)
12update message model =
13 case message of
14 ...
15 UpdateRegForm transformRegForm ->
16 ( { model | regForm = transformRegForm model.regForm }
17 , Cmd.none
18 )
19 ...
Then in the view function you just have to slightly update the onInput
attribute:
1...
2 firstName =
3 ...
4 , Html.input
5 [ Events.onInput (\input oldRegForm -> { oldRegForm | firstName = input })
6 ...
7 ...
The disadvantage is that now you have a function in your message type. I'm not sure if the debugger still has a problem with that, but it is generally seen as desirable to keep functions out of your message type.
The main point here though is that you need to try to avoid having in your message type parts of the model. Because the model may have changed since the view was rendered and hence the message constructed.