elm / virtual-dom

The foundation of HTML and SVG in Elm.
https://package.elm-lang.org/packages/elm/virtual-dom/latest
BSD 3-Clause "New" or "Revised" License
209 stars 80 forks source link

Html.map + timeupdate event after DOM removal creates invalid Msg #171

Open Yarith opened 4 years ago

Yarith commented 4 years ago

Problem

This issue provides only another SSCCE to the #103 issue. In my opinion the explanation in the other issue applies to this SSCCE too. Like #103 this SSCCE could be added to the list in #105 as well.

Version

OS: Windows 10 1909 (Build 18363.900) Elm: 0.19.1

Thrown exceptions

The thrown exceptions differ between Chrome and Firefox. Added so it is easier to find for other people. But the exception text may vary between the expected types.

Chrome 83.0.4103.116 (64-Bit)

Uncaught TypeError: Cannot read property 'a' of undefined

Firefox 78.0.2 (64-Bit)

TypeError: _v0 is undefined

SSCCEE

https://ellie-app.com/9pbMRZHxRkVa1

module Main exposing (main)

import Browser
import Html exposing (Html, br, button, div, source, text, video)
import Html.Attributes exposing (autoplay, id, loop, property, src, style, type_)
import Html.Events exposing (on, onClick)
import Json.Decode as JD
import Json.Encode as JE

type alias Model =
    { currentTime : Float
    , duration : Float
    , videoVisible : Bool
    , selection : Maybe ( GroupId, ItemId )
    }

initialModel : Model
initialModel =
    { currentTime = 0
    , duration = 0
    , videoVisible = False
    , selection = Nothing
    }

type MediaMsg
    = MediaMsgTimeupdate { currentTime : Float, duration : Float }

type VideoPageMsg
    = VideoPageMsgLeave
    | VideoPageMsgMediaMsg MediaMsg

type GroupId
    = GroupId String

groupIdEncoder : GroupId -> JE.Value
groupIdEncoder (GroupId value) =
    JE.string value

type ItemId
    = ItemId String

itemIdEncoder : ItemId -> JE.Value
itemIdEncoder (ItemId value) =
    JE.string value

type ListPageMsg
    = ListPageMsgSelect GroupId ItemId

type Msg
    = VideoPage VideoPageMsg
    | ListPage ListPageMsg

update : Msg -> Model -> Model
update msg model =
    case msg of
        VideoPage videoPageMsg ->
            case videoPageMsg of
                VideoPageMsgMediaMsg (MediaMsgTimeupdate { currentTime, duration }) ->
                    { model | currentTime = currentTime, duration = duration }

                VideoPageMsgLeave ->
                    { model | videoVisible = False }

        ListPage listPageMsg ->
            case listPageMsg of
                ListPageMsgSelect groupId itemId ->
                    let
                        _ =
                            Debug.log "Attempt to store selection with" listPageMsg

                        _ =
                            -- In my case i am storing the selection in the local storage,
                            -- so i can just use F5 to reload the page, without reselecting.
                            Debug.log "Selection json which can be sent to local storage" <|
                                JE.encode 0 <|
                                    JE.object
                                        [ ( "groupId", groupIdEncoder groupId )
                                        , ( "itemId", itemIdEncoder itemId )
                                        ]
                    in
                    { model | selection = Just ( groupId, itemId ), videoVisible = True }

timeupdateDecoder : JD.Decoder { currentTime : Float, duration : Float }
timeupdateDecoder =
    JD.map2 (\currentTime duration -> { currentTime = currentTime, duration = duration })
        (JD.at [ "target", "currentTime" ] JD.float)
        (JD.at [ "target", "duration" ] JD.float)

viewList : Html ListPageMsg
viewList =
    div []
        [ button [ onClick <| ListPageMsgSelect (GroupId "fpie73") (ItemId "72ba27hs") ]
            [ text "Open video" ]
        ]

viewVideo : Html VideoPageMsg
viewVideo =
    div []
        [ button [ onClick <| VideoPageMsgLeave ] [ text "Leave video" ]
        , br [] []
        , br [] []
        , video
            [ autoplay True
            , property "muted" (JE.string "muted")
            , loop True
            , on "timeupdate" (timeupdateDecoder |> JD.map MediaMsgTimeupdate)
            ]
            [ source
                [ id "mp4"
                , src "http://www.w3schools.com/html/movie.mp4"
                , type_ "video/mp4"
                ]
                []
            ]
            |> Html.map VideoPageMsgMediaMsg
        ]

view : Model -> Html Msg
view model =
    div
        [ style "transform" "scale(2, 2)"
        , style "transform-origin" "left top"
        ]
        [ if model.videoVisible then
            viewVideo |> Html.map VideoPage

          else
            viewList |> Html.map ListPage
        , div []
            [ br [] []
            , text <| String.fromFloat model.currentTime ++ "/" ++ String.fromFloat model.duration
            ]
        ]

main : Program () Model Msg
main =
    Browser.sandbox
        { init = initialModel
        , view = view
        , update = update
        }

Workaround

In #103 are also workarounds described. Additional Html.map identity on the pages with same "map depth" works, but is not safe, because in future there could be another page with the same "map depth".

In my case i have wrapped the event in a custom element, which disconnects the event listener as soon as the element is removed from the DOM. The custom element controls the playstate too, so i do not need a port for pause/resume. Place this video-control custom element inside the video element. The custom element is defined as followed:

// This is based on the idea from baffalop on Slack
// https://elmlang.slack.com/archives/C0CJ3SBBM/p1593872335052000
// Place this custom element inside the video element.
customElements.define('video-control',
    class VideoControl extends HTMLElement {
        static get observedAttributes() { return ['playstate']; }

        constructor() {
            super();

            this.boundVideoTimeupdated = this.videoTimeupdated.bind(this);
        }

        attributeChangedCallback(name, oldValue, newValue) {
            const parentElement = this.parentElement;
            if (name === "playstate") {
                VideoControl.updatePlayState(parentElement, newValue);
            }
        }

        /**
        * @param {HTMLVideoElement} videoElement 
        * @param {boolean} value
        * @return {string}
        */
        static updatePlayState(videoElement, value) {
            if (!videoElement)
                return;

            if (value === "play") {
                videoElement.play();
            } else if (value == "pause") {
                videoElement.pause();
            }
        }

        /**
        * @param {HTMLVideoElement} videoElement 
        */
        videoTimeupdated() {
            const videoElement = this.videoElement;
            if (!videoElement || videoElement.tagName !== "VIDEO")
                return;

            this.dispatchEvent(new CustomEvent("timeupdate", {
                detail: { currentTime: videoElement.currentTime, duration: videoElement.duration }
            }));
        }

        /**
        * @param {HTMLVideoElement} videoElement 
        */
        connectVideoElement(videoElement) {
            this.disconnectVideoElement();

            if (!videoElement || videoElement.tagName !== "VIDEO")
                return;

            this.videoElement = videoElement;

            // Wir müssen timeupdate hier kapseln, da das timeupdate vom Video-Element
            // einen Laufzeitfehler erzeugt, sollte die Seite verlassen werden, bevor
            // das Video angehalten wurde.
            VideoControl.updatePlayState(videoElement, this.getAttribute("playstate"));

            videoElement.addEventListener('timeupdate', this.boundVideoTimeupdated, false);
        }

        /**
        * @param {HTMLVideoElement} videoElement 
        */
        disconnectVideoElement() {
            const videoElement = this.videoElement;
            if (!videoElement || videoElement.tagName !== "VIDEO")
                return;

            videoElement.removeEventListener('timeupdate', this.boundVideoTimeupdated, false);

            this.videoElement = null;
        }

        connectedCallback() {
            this.connectVideoElement(this.parentElement);
        }

        disconnectedCallback() {
            this.disconnectVideoElement();
        }
    });