The trickiest Elm bug I’ve ever seen

A tale of two Models and one stale Msg

Coury Ditch
Real Kinetic Blog

--

This week I encountered one of the most interesting bugs I’ve yet to witness in my near half-decade of programming in Elm. This is a potential code smell you’ll definitely want to watch out for.

We tend to think of the Model in Elm as our single source of truth. We look at our view function view : Model -> Html Msg and expect the Elm runtime to display the latest and greatest Model. It turns out this is not entirely true.

https://comic.browserling.com/53

A Simple Pattern

Say you’re writing a stock trading application. It’s extremely important that you get the buy/sell form working properly. When the user types in a given quantity to buy or sell and hits enter, they expect the trade to get executed. There are two ways you could write the Msg for such a form.

type StaleMsg
= UpdateField String
| Submit String

or

type FreshMsg
= UpdateField String
| Submit

StaleMsg implies that the view will be telling update what data to submit. On the other hand, FreshMsg implies that the view is telling update “It’s time to submit, but I’ll let you decide what data to actually submit.”

Experienced Elm developers would know that the first example is a smell, as we strive to be as declarative as possible with our Msg types. Despite this, I still find myself engaging in patterns like the first one more often than I should.

For instance, we have a calendar view in our app, where the user can go forward and back throughout a given week/day view. Here are two ways we might handle navigating throughout the calendar year:

Declarative, but verbose:

type Msg
= NextDay
| PreviousDay
| CurrentDay

Generic, but requires calculation in the view code using an offset stored in our Model:

type Msg
= SetOffset Int

Until recently, I thought the latter was relatively innocent. I’ve now begun to reflect on the sins of my past.

What sin am I committing here exactly?

Seeing is believing

This example showcases how StaleMsg might be problematic. When you hit Enter while the input field is focused the current state of the form will be “Submitted”. Play around with it. You type something, press Enter, and see what you’ve just typed as “Submitted”.

Now try typing very fast. For example, “abcd” Enter. You can see your keypresses in Logs or Debug (tabs in the upper right of Ellie) to ensure you’re hitting them in the right order. If you’re lucky, or rather, unlucky, you’ll eventually see a ‘Submission’ that differs from the text field.

If we look at our Debug history we can see our Model was updated in the correct order, yet somehow an old state of the Model was submitted.

How did this happen?

Enter Animation Frames

Elm’s Virtual DOM (VDOM) syncs itself with the browser’s natural refresh rate to ensure a smooth feel to the human eye and avoid unnecessary DOM mutations¹. Browsers typically render at about 60 frames per second, or in other terms, produce a new animation frame every 16.67 milliseconds. If the browser (or CPU) is working hard, the times between animation frames will tend to increase².

How is this related to the above bug/race condition?

The key insight: In some sense, our Elm application has two Models rather than one. We have the Model our update function is working with, and we have the Model currently displayed by our view.

Usually this isn’t a problem, but if like in the above example we’re sending data from our view’s Model into our update function — rather than accessing the Model directly in the update function — we’re setting ourselves up to potentially receive stale data.

You see, the browser’s event handlers can listen for events at a rate much faster than its animation rate. This means multiple keypresses can occur before the next view is generated.

If you open the developer console in the example above you’ll see that a number is logged on every animation frame (milliseconds since page load). You can also see a log for each keystroke. At around the 4.4 second mark there are two keypress events sandwiched between two animation frames. This would result in “as” being submitted rather than “asd”, as the view has yet to render the latest Model with our newly added “d” and thus the Submit Msg in our event listener is using an old version of the Model, sending stale data to our update function.

Animation ticks in blue

The Takeaway

Be very intentional about how you send data to your update function from within your view.

This example app showcases ‘the fix’ where we’ve switched over to using FreshMsg. Notice how Submit is no longer parameterized. The key difference is how the update function handles the concern of what to submit rather than the view. This obviates the potential issue³ of dealing with stale data in the view, in the instance where Elm’s VDOM is still waiting for the next animation frame to generate a DOM based on the latest Model.

Most of the time this won’t be an issue⁴ — the user can’t click or type fast enough to produce such errant behavior. But if you have a sufficiently complex Elm app like ours, which is performing a decent amount of computation while the user is typing into a custom element contenteditable WYSIWYG with custom event handlers using partially applied functions, and your app users are churning their CPUs with a Zoom meeting while typing into the editor… you might find yourself spending many frustrating hours debugging some intermittent and deeply mysterious behavior.

¹ Inner workings of Elm’s VDOM

² See here and here if you want to read more about the browser’s animation performance and APIs.

³ There may even be scenarios where sending data from the view is the correct behavior. E.g., say a network request comes through during the current animation frame and in the same frame the user presses "save a copy of the data I’m currently looking at”. SaveCurrentData data might be more appropriate than SaveCurrentData .

⁴ See Note 3 in VirtualDom.Handler

--

--