Unmountable Elm programs

Hi.

Elm programs could be used at least in following ways (1) as a fullscreen program or (2) is embed program. It does make sense to start a new web app with a fullscreen program. However, when one wants to introduce Elm to existing code base then embed is the way to go. When DOM element which was used to embed Elm program is removed from the DOM one need to manually kill(unembed, unmount or whatever is called) the Elm program to prevent memory leaks. Memory leaks can be introduced by subscriptions or by HTML event listeners (the view part). At the moment Elm does not have a way to unmount(unembed or kill) the program. One of the workarounds now is to keep a property (e.g. isMounted : Bool) to track the state of the program. Then one can use isMounted in the following way.

subscriptions : model -> Sub msg
subscriptions model =
    if model.isMounted then
        -- Whatever subscriptions are
    else
        Sub.none -- Empty subscription

view : model -> Html msg
view model =
    if model.isMounted then
        -- Whatever view is
    else
        Html.text "" -- Empty view which lets VirtualDom clean all event listeners

This approach adds unnecessary complexity to business logic. But we could automate this work in the following way.

port module Unmountable.Html exposing (Model, Msg, programWithFlags)

import Html


port unmount : (() -> msg) -> Sub msg


type Model internalModel
    = Mounted internalModel
    | Unmounted


type Msg internalMsg
    = Unmount
    | InternalModelMsg internalMsg


programWithFlags :
    { init : flags -> ( internalModel, Cmd internalMsg )
    , update : internalMsg -> internalModel -> ( internalModel, Cmd internalMsg )
    , subscriptions : internalModel -> Sub internalMsg
    , view : internalModel -> Html.Html internalMsg
    }
    -> Program flags (Model internalModel) (Msg internalMsg)
programWithFlags config =
    Html.programWithFlags
        { init =
            \flags ->
                let
                    ( internalModel, internalCmd ) =
                        config.init flags
                in
                ( Mounted internalModel, Cmd.map InternalModelMsg internalCmd )
        , update =
            \msg model ->
                case model of
                    Mounted internalModel ->
                        case msg of
                            Unmount ->
                                ( Unmounted, Cmd.none )

                            InternalModelMsg internalMsg ->
                                let
                                    ( newInternalModel, internalCmd ) =
                                        config.update internalMsg internalModel
                                in
                                ( Mounted newInternalModel, Cmd.map InternalModelMsg internalCmd )

                    Unmounted ->
                        ( Unmounted, Cmd.none )
        , subscriptions =
            \model ->
                case model of
                    Mounted internalModel ->
                        let
                            unmountSub =
                                unmount (\_ -> Unmount)

                            internalModelSub =
                                config.subscriptions internalModel
                        in
                        Sub.batch [ unmountSub, Sub.map InternalModelMsg internalModelSub ]

                    Unmounted ->
                        Sub.none
        , view =
            \model ->
                case model of
                    Mounted internalModel ->
                        Html.map InternalModelMsg (config.view internalModel)

                    Unmounted ->
                        Html.text ""
        }

So one could use it as an almost drop-in solution. Instead of Html one could add import Unmountable.Html as Html and use Html.programWithFlags as before. The type signature for main function will change from Program flags model msg to Program flags (Html.Model model) (Html.Msg msg).

e.g.

module MyProgram exposing (main)

import Unmountable.Html as Html


-- MAIN

main : Program flags (Html.Model model) (Html.Model msg)
main =
    Html.programWithFlags
        { init = init
        , update = update
        , subscriptions = subscriptions
        , view = view
        }


-- All the required parts are here

type alias Flags = ...

type alias Model = ...

init : Flags -> ( Model, Cmd Msg )
init flags = ...

type Msg = ...

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model = ...

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

view : Model -> Html Msg
view model = ...

And on JS side one needs to call the unmount method of ports object in the following way.

MyProgram.ports.unmount.send(null);

And that’s it. When there will be the official way to unmount Elm programs one will just remove the dependency and change type signature for main function back to the original.


My question is: if it worth it to create a package with unmountable programs for most common Elm modules such as Platform, Html, Navigation, elm-css, etc…?

9 Likes

Why can’t you just set the app variable to null to unmount the program?

var app = Elm.Main.embed(elmDiv)

...

app = null

Setting the app variable to null will not tell Elm runtime to remove event listeners. And no one else but Elm runtime knows which event listeners were added. Subscriptions are global events. Thus they should be changed to Sub.none to remove all event listeners. And VirtualDom handles all the adding/removing of event listeners for its nodes. Thus setting the view part to Html.text "" will remove all event listeners.

https://ellie-app.com/R3CJJnWH7ja1
Here you can play with the program to see memory leaks. Changing view to unmountableView and subscriptions to unmountableSubscription will prevent those memory leaks.

3 Likes

But if you also remove the root DOM node of Elm’s virtual DOM, which was mentioned in the original post above, then those event listeners must be removed too, along with the nodes they’re attached to.

The Ellie you posted doesn’t actually seem to remove the root DOM node though. Did you try that elsewhere?
Let me know if you see any difference with this version: https://ellie-app.com/R83vcRVv56a1
I wasn’t exactly sure how to reproduce your results though. Maybe you could describe in a bit more detail the steps you’re taking to test for the memory leak?

When you eliminate the leaking DOM event handlers, can you still see another leak due to subscriptions? I can see that the subscription from the timer to Debug.log keeps running after removing the app, but I’d expect the Elm runtime to handle this without leaking.

Thank you for your questions.

Removing the DOM node does not remove any of the attached event listeners to the DOM node. That’s how JS works. Even more, the removed DOM node will not be garbage collected because it’s a reference for an attached event listener. That’s called a memory leak because it will hang in memory until you actually explicitly say you want to remove the event listener.

The memory leak with subscriptions is very easy to see. Because once you stop Elm program or remove DOM node from JS you can still notice logs in the console and in the logs tab. Those logs are from subscriptions. Elm runtime could not handle removing of those event listeners from subscriptions magically. You have to explicitly set subscriptions to Sub.none (I believe this will be handled by Elm once there will be official kill/unmount method).

The memory leak with attached events for the DOM node you can see in this simple program (link below). This program has two buttons (remove Elm program and test memory leak) inserted on HTML side and one button (increase counter) inserted on Elm side. Both test memory leak and increase counter buttons will increase the counter and log new value to console. Remove Elm program button will remove DOM node with Elm program from JS.

https://ellie-app.com/RyfjRscJmXa1

Steps on how to reproduce memory leak:
• Wait until Elm program is embeded successfully
• Open console
• Click remove program button
• Click test memory leak button few times
• Notice that counter is increased each time you click the button and log is thrown into the console even though there is no DOM node visible. That’s a memory leak.

To be honest, making sure that Elm is gracefully unmounted when someone does not want to include it in the page anymore seems like something that would very much be the concern of the Elm runtime itself.

The disadvantages of having this as a library would be:

  • When building this as a library, users need to wrap most of their existing TEA-code with extra bits, although being able to properly unmount is a property that nearly all Elm applications should have.
  • Releasing it as a library is difficult because it includes ports: Either the user has to do some extra manual work to set up the port in their own module and include it as a callback, or they cannot (as far as I know) release the module on the Elm package management.

To be honest, making sure that Elm is gracefully unmounted when someone does not want to include it in the page anymore seems like something that would very much be the concern of the Elm runtime itself.

I agree with you. But there is no such thing at the moment which could unmount a program. Thus one has to do it manually.

When building this as a library, users need to wrap most of their existing TEA-code with extra bits, although being able to properly unmount is a property that nearly all Elm applications should have.

The only extra things are an import of the package and slightly different type signature for main function. I’ve described this above. And that’s it. If you want to unmount Elm app you would have to write much more =)

Releasing it as a library is difficult because it includes ports: Either the user has to do some extra manual work to set up the port in their own module and include it as a callback, or they cannot (as far as I know) release the module on the Elm package management.

This will not be any different with Elm runtime having a way to unmount a program. Because Elm runtime never will understand that you are removing Elm program from outside world. So what Elm can provide is some method for the Elm program which can be called whenever one decides to remove Elm program from outside world. This is similar to call MyProgram.ports.unmount.send(null);. So there are actually no difficulties related to having ports for this package. More of it one actually don’t have to care about this package to have ports. Because this package is consistent and does not depend on the internal implementation of the app one could work on. So this package exposes a port which one can call from outside world. Similar to what Elm can provide some later.

To clarify: I definitely think that a library that allows us to re-use this common functionality is a better idea than every developer having to make and maintain it themselves.
I wanted to make the point that this functionality makes even more sense in Elm itself because implemented as a library library it would be a very leaky abstraction, and it is a concern that applies to a lot of applications.

However, since the Elm core team currently has other things on their mind (like working on v 0.19), it is unlikely that such a change, even if they would agree with me, would make it into the language runtime in the short term, so it is definitely a good idea to combine a couple of re-usable building blocks to allow developers to safely unmount their applications into a library right now. :slight_smile:

2 Likes

@Qqwy I agree with you. And the good point of having such package is that it can easily provide missing functionality until it’s there. And when functionality will be there then this package will die. Which is ok.

As far as I know 0.19 still does not have such functionality.

1 Like

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