The trickiest Elm bug I’ve ever seen
A tale of two Models and one stale Msg
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.
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 Model
s 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.
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