How do I force Elm to reuse a DOM element?

Viewed 186

I want to make a web app using Elm that will contain some video elements, with a dynamic layout that can change depending on the width and height of the browser. When I tried that, it was clear that Elm was generating new elements for the videos, but that doesn't work because the video elements have state that needs to be preserved.

I wrote a demonstration of the problem with counters instead of videos for simplicity. I tried to fix the problem with Html.lazy and Keyed.node but it persisted.

The code here can also be cloned from https://github.com/ijt/elm-dom-elt-reuse.

src/Main.elm:

port module Main exposing (..)

import Browser
import Html exposing (..)
import Html.Attributes as Attribute exposing (id, style)
import Html.Events exposing (onClick)


main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }


type alias Model =
    { layout : Layout }


type Layout
    = Row
    | Column


init : () -> ( Model, Cmd Msg )
init _ =
    ( { layout = Row }, startCounters () )


type Msg
    = ToggleLayout


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ToggleLayout ->
            let
                l2 =
                    case model.layout of
                        Row ->
                            Column

                        Column ->
                            Row
            in
            ( { model | layout = l2 }, Cmd.none )


subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.none


view : Model -> Html Msg
view model =
    div []
        [ button [ onClick ToggleLayout ] [ text "Toggle Layout" ]
        , counters model
        ]


counters : Model -> Html Msg
counters model =
    case model.layout of
        Column ->
            div []
                [ div [] [ counter1 ]
                , div [] [ counter2 ]
                ]

        Row ->
            div [] [ counter1, spacer, counter2 ]


spacer : Html Msg
spacer =
    text " "


counter1 : Html Msg
counter1 =
    span [ id "counter1" ]
        [ text "0" ]


counter2 : Html Msg
counter2 =
    span [ id "counter2" ]
        [ text "0" ]


port startCounters : () -> Cmd msg

static/index.html:

<!DOCTYPE HTML>
<html>
<head>
  <meta charset="UTF-8">
  <title>Main</title>
  <style>body { padding: 0; margin: 0; }</style>
</head>

<body>

<pre id="elm"></pre>

<script src="Main.js"></script>
<script>
  window.app = Elm.Main.init( { node: document.getElementById("elm") } );

  window.app.ports.startCounters.subscribe(function() {
    let c1 = document.getElementById("counter1");
    let c2 = document.getElementById("counter2");
    function increment(e) {
      let n = parseInt(e.innerText);
      e.innerText = n + 1;
    }
    requestAnimationFrame(function() {
      setInterval(function() {
        increment(c1);
        increment(c2);
      }, 100)
    })
  });
</script>

</body>
</html>

Makefile:

static/Main.js: src/Main.elm
    elm make src/Main.elm --output=static/Main.js
1 Answers

Keeping the elements always at the same depth in the DOM tree does the trick.

Here is the new code:

counters : Model -> Html Msg
counters model =
    let
        d =
            case model.layout of
                Column ->
                    "column"

                Row ->
                    "row"
    in
    div
        [ style "flex-direction" d
        , style "display" "flex"
        ]
        [ counter1
        , counter2
        ]


counter1 : Html Msg
counter1 =
    span [ id "counter1", style "padding" "8px" ]
        [ text "0" ]


counter2 : Html Msg
counter2 =
    span [ id "counter2", style "padding" "8px" ]
        [ text "0" ]


port startCounters : () -> Cmd msg

Thanks to jessta on the Elm Slack channel for this idea.

Related