Event emitted during DOM node removal can destroy type safety

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?

4 Likes

(I put your example into an ellie for folks to try themselves)


I hate to see it, but there seem to be years-old github issues about this as well:

I don’t think it can be argued that “the library should’nt do that in the first place”, as this is also something the browser (at least Chrome) seems to do. I adapted your example to just use the core HTML package + onBlur to trigger a runtime exception in Chrome: https://ellie-app.com/9Qc88qHgN3ma1


A fourth option might be to not do the tagger (Html.map nodes) optimization at all:

Usually, Html.map gets used to lift the message type of a component/page to the apps Msg type. By storing the taggers separately, it is extra cheap to change the whole message type for that subtree, since it can just update the list of taggers in 1 place only, instead of re-adding every event listener individually. But when does the tagger actually change? I would think that usually the whole page under it changes as well, so a great deal of that DOM needs to be reconstructed anyways. The diffing algorithm seems to kind of acknowledge that as well, bailing on a different number of Html.map calls.

Can anyone think of a good example where changing the message tag using Html.map, but keeping the rest of the tree the same can happen?

The best I can come up with is in some dynamic list of “component”-like things, where you tag them with a key/index, but you do not use Html.Keyed.

1 Like

@jreusch Thanks for the issue links, though them I found https://github.com/elm/virtual-dom/pull/166, which is a reasonably recent pull request that (at least in my case) fixes the issue. Its tactic is to save the tagger on the callback, so that changes do not affect the callback. It was created in March, but since the last virtual-dom change was in 2018, it may be a while before the PR is merged.

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.