In the material-components-web-elm package there is an element, the Menu, that emits an event when it is destroyed. Normally this isn’t a problem, but there is an interaction with Elm’s diffing that results in Elm code crashing. An example is the MCVE below, which crashes when the back arrow is pressed (check the browser console or https://i.imgur.com/0MtoJ20.png).
MCVE
module Main exposing (main)
import Browser
import Html as H
import Material.IconButton as IconButton
import Material.Menu as Menu
type Model = A | B
type Msg = Switch | AMsg AMsgs | BMsg BMsgs
type BMsgs = X { a : { a : Int } }
type AMsgs = MenuChange Bool
main = Browser.sandbox { init = A, update = update, view = view }
update : Msg -> Model -> Model
update msg m =
case Debug.log "" ( msg, m ) of
( Switch, _ ) -> B
( BMsg submsg, B ) ->
let
pm = case submsg of
X d -> let _ = d.a.a in m
in
B
( AMsg submsg, A ) -> A
( _, _ ) -> m
view : Model -> H.Html Msg
view m =
H.div []
[ IconButton.iconButton (IconButton.config |> IconButton.setOnClick (Switch)) "arrow_back"
, case m of
B -> H.text "" |> H.map BMsg
A -> Menu.menu (Menu.config |> Menu.setOnClose (MenuChange False)) [] |> H.map AMsg
]
The cause of this is rather complex, so I’ve included a diagram. The MCVE includes quite a lot of setup, but it all boils down to a transition between the old and new DOM where the Menu is replaced with the empty Html.text by the view function. Based on my debugging, each state is represented by two nodes: A node representing the Html.map and a child node representing the actual element (top of diagram):
The elm diffing algorithm considers the two Html.map nodes to be the equivalent, and so the resulting diff has two items: Change the tagger function (transformation function) of the map node, and replace its child. The render algorithm then applies this diff, modifies the map node and then replaces the Menu node (bottom of diagram).
When the Menu node is removed, it emits an event, which results in the MenuChange message being created. However, the event system then traverses the node tree upwards and wraps this message with BMsg, despite this being completely invalid in the type system. This message is delivered to update, and can lead to arbitrarily weird errors.
There are three possible solutions (off the top of my head):
- Don’t emit events from DOM nodes when rendering is happening: This is a rather drastic measure and is rather hard to enforce or check.
- Change the diffing algorithm somehow so that this does not happen: Not sure what the correct order of events should be though. Clearly the removal needs to be done before the map node change in this case, but other situations (such as events emitted on construction) may require the opposite order. Possible order: Perform all removals, then all changes, then all additions.
- Always wrap map nodes in a key, so that Elm does not try to modify the map nodes: Unless done automatically, this is going to be annoying, and will lead to a loss of efficiency.
TLDR: The MCVE crashes because an event is emitted during DOM mutation, is this the library’s fault for emitting the event or Elm’s fault for putting the DOM into an inconsistent state?