ShadowRoots and `Browser.Dom`

I’m experimenting with putting Elm apps inside of shadow roots in custom elements, and almost everything is smooth — with the exception of Browser.Dom functions. When running an Elm app inside of a shadow root, you’d expect Browser.Dom.getElement to only have access to nodes inside the shadow root, and be unable to access anything outside.

However, the opposite seems to be true – try this Ellie and search for the outsideShadow and insideShadow elements. You’ll see that outsideShadow is findable, but insideShadow is not.

It appears that getElement and its kin use document.getElementById (see here), instead of doing something like:

app.node.getRootNode().getElementByID(...)

Has anyone run into this limitation before? Have you found any good workarounds?

1 Like

I wasn’t aware this was an issue, but I bet you code modify the compiled output to instead use the root node.

I was having the same issues ~1 year ago, and ended up re-implementing the Browser.Dom functions I needed using ports.

Edit as a follow up to your message:

Yes, we are still using it, but we have a custom-built request/response setup instead of using elm-porter. (This library was released shortly after we had this problem. But I’m sure it’s great!). It has been running smoothly in production for this whole year now, and I don’t remember any immediate problems we had that were related to this setup. It could definitely be improved - for example by using Promise-returning functions instead of a callback setup using ports directly, but we actually only ever needed 4 different effects.

My main complaint currently is that the Elm runtime does not have an easy way to shut it down - so currently we just live with leaking memory and producing runtime errors somewhere deep in the virtual dom if the custom element gets removed.

Our Javascript side looks a little bit like this:

const shadow = this.attachShadow({ mode: 'open' })

const styles = document.createElement('style')
styles.textContent = css.toString()

shadow.appendChild(styles)

const wrapper = document.createElement('div')
shadow.appendChild(wrapper)

const app = ElmApp.init({
    node: wrapper,
    flags: {
        // ..
    }
})

function withElement (id, data, callback) {
    requestAnimationFrame(() => {
        const el = shadow.getElementById(data)
        if (el) {
            app.ports.incoming.send({
                id,
                data: callback(el)
            })
        }
    })
}

app.ports.outgoing.subscribe(({ id, tag, data }) => {
    if (tag === 'getViewportOf') {
        withElement(id, data, el => ({
            scene: {
                width: el.scrollWidth,
                height: el.scrollHeight
            },
            viewport: {
                x: el.scrollLeft,
                y: el.scrollTop,
                width: el.clientWidth,
                height: el.clientHeight
            }
        }))
    } else if (tag === 'focus') {
        // ...
    }
})

in Elm, we then use a custom version of Browser.element to do all the plumbing:

port module Runtime exposing
    ( Effect
    , RuntimeProgram
    , attempt
    , batch
    , cmd
    , element
    , emitEvent
    , fail
    , js
    , map
    , none
    , perform
    )

import Browser
import Dict exposing (Dict)
import Html exposing (Html, div, h1, pre, text)
import Html.Attributes exposing (class)
import Json.Decode as Dec exposing (Decoder, Value)
import List.Extra as List
import Task exposing (Task)



-- MAKING PROGRAMS


type RuntimeModel model msg
    = RuntimeModel model (InternalModel msg)


type RuntimeMsg msg
    = A msg
    | R InternalMsg


type alias RuntimeProgram flags model msg =
    Program flags (RuntimeModel model msg) (RuntimeMsg msg)


element :
    { init : flags -> ( model, Effect msg )
    , update : msg -> model -> ( model, Effect msg )
    , view : model -> Html msg
    , subscriptions : model -> Sub msg
    }
    -> RuntimeProgram flags model msg
element { init, update, view, subscriptions } =
    Browser.element
        { init = runtimeInit init
        , update = runtimeUpdate update
        , view = runtimeView view
        , subscriptions = runtimeSubscriptions subscriptions
        }



-- EFFECT TYPE


type Effect msg
    = Error String
    | SendToJs String Value (Decoder msg)
    | EmitEvent String Value
    | SendToElm (Cmd msg)
    | Batch (List (Effect msg))


map : (msg1 -> msg2) -> Effect msg1 -> Effect msg2
map f effect =
    case effect of
        Error msg ->
            Error msg

        EmitEvent tag data ->
            EmitEvent tag data

        SendToJs tag value responseDecoder ->
            SendToJs tag value (Dec.map f responseDecoder)

        SendToElm theCmd ->
            SendToElm (Cmd.map f theCmd)

        Batch effects ->
            Batch (List.map (map f) effects)



-- EFFECTS


none : Effect msg
none =
    Batch []


batch : List (Effect msg) -> Effect msg
batch =
    Batch


fail : String -> Effect msg
fail =
    Error


cmd : Cmd msg -> Effect msg
cmd =
    SendToElm


perform : (a -> msg) -> Task Never a -> Effect msg
perform toMsg task =
    SendToElm <| Task.perform toMsg task


attempt : (Result err value -> msg) -> Task err value -> Effect msg
attempt toMsg task =
    SendToElm <| Task.attempt toMsg task


js : String -> Value -> Decoder msg -> Effect msg
js =
    SendToJs


emitEvent : String -> Value -> Effect msg
emitEvent =
    EmitEvent



-- INTERNAL RUNTIME


port events : { name : String, data : Value } -> Cmd msg


port outgoing : { id : Int, tag : String, data : Value } -> Cmd msg


port incoming : (Value -> msg) -> Sub msg


type InternalModel msg
    = Running (RunningModel msg)
    | Errored String


type alias RunningModel msg =
    { nextId : Int
    , requestsInFlight : Dict Int (Decoder msg)
    }


type InternalMsg
    = Recieved Value


runtimeInit : (flags -> ( model, Effect msg )) -> flags -> ( RuntimeModel model msg, Cmd (RuntimeMsg msg) )
runtimeInit init flags =
    let
        ( model, effect ) =
            init flags

        emptyModel =
            Running
                { nextId = 1
                , requestsInFlight = Dict.empty
                }

        ( internalModel, effectCmd ) =
            handleEffects emptyModel effect
    in
    ( RuntimeModel model internalModel, effectCmd )


runtimeUpdate :
    (msg -> model -> ( model, Effect msg ))
    -> RuntimeMsg msg
    -> RuntimeModel model msg
    -> ( RuntimeModel model msg, Cmd (RuntimeMsg msg) )
runtimeUpdate update runtimeMsg ((RuntimeModel appModel internalModel) as model) =
    case runtimeMsg of
        A appMsg ->
            let
                ( newAppModel, effect ) =
                    update appMsg appModel

                ( newInternalModel, effectCmd ) =
                    handleEffects internalModel effect
            in
            ( RuntimeModel newAppModel newInternalModel, effectCmd )

        R internalMsg ->
            case internalModel of
                Running runningModel ->
                    let
                        ( newInternalModel, effectCmd ) =
                            runningUpdate runningModel internalMsg
                    in
                    ( RuntimeModel appModel newInternalModel, effectCmd )

                Errored _ ->
                    ( model, Cmd.none )


runningUpdate : RunningModel msg -> InternalMsg -> ( InternalModel msg, Cmd (RuntimeMsg msg) )
runningUpdate model msg =
    case msg of
        Recieved value ->
            case Dec.decodeValue (Dec.field "id" Dec.int) value of
                Ok id ->
                    case Dict.get id model.requestsInFlight of
                        Just handler ->
                            case Dec.decodeValue (Dec.field "data" handler) value of
                                Ok appMsg ->
                                    ( Running
                                        { model
                                            | requestsInFlight =
                                                Dict.remove id model.requestsInFlight
                                        }
                                      -- We COULD just immediately call the App update here,
                                      -- but I think its better so see the message explicitely in the debugger and stuff
                                    , Task.perform A (Task.succeed appMsg)
                                    )

                                Err decodeError ->
                                    ( Errored <| Dec.errorToString decodeError
                                    , Cmd.none
                                    )

                        Nothing ->
                            ( Errored <| "Recieved a javascript message with id " ++ String.fromInt id ++ ", which I don't know about!"
                            , Cmd.none
                            )

                Err decodeError ->
                    ( Errored <| Dec.errorToString decodeError
                    , Cmd.none
                    )


runtimeView :
    (model -> Html msg)
    -> RuntimeModel model msg
    -> Html (RuntimeMsg msg)
runtimeView view (RuntimeModel appModel internalModel) =
    case internalModel of
        Running _ ->
            Html.map A (view appModel)

        Errored errorMessage ->
            div [ class "bg-red-200 p-4 text-red-800 antialiased" ]
                [ h1 [ class "font-bold text-4xl" ] [ text "Systemfehler" ]
                , pre [ class "mt-4 text-sm leading-none" ] [ text errorMessage ]
                ]


runtimeSubscriptions :
    (model -> Sub msg)
    -> RuntimeModel model msg
    -> Sub (RuntimeMsg msg)
runtimeSubscriptions subscriptions (RuntimeModel appModel _) =
    Sub.batch
        [ incoming (R << Recieved)
        , Sub.map A (subscriptions appModel)
        ]


handleEffects : InternalModel msg -> Effect msg -> ( InternalModel msg, Cmd (RuntimeMsg msg) )
handleEffects model effect =
    case model of
        Running runningModel ->
            case effect of
                Error errorMessage ->
                    ( Errored errorMessage, Cmd.none )

                EmitEvent name data ->
                    ( model
                    , events
                        { name = name
                        , data = data
                        }
                    )

                SendToJs tag data responseDecoder ->
                    ( Running
                        { runningModel
                            | nextId = runningModel.nextId + 1
                            , requestsInFlight = Dict.insert runningModel.nextId responseDecoder runningModel.requestsInFlight
                        }
                    , outgoing
                        { id = runningModel.nextId
                        , tag = tag
                        , data = data
                        }
                    )

                SendToElm theCmd ->
                    ( model, Cmd.map A theCmd )

                Batch effects ->
                    List.mapAccuml handleEffects model effects
                        |> Tuple.mapSecond Cmd.batch

        Errored _ ->
            ( model, Cmd.none )

This is super helpful – thank you @jreusch! I’ll give it a go.

@wolfadex – that’s also an interesting idea; monkey-patching the compiled Browser.Kernel to point it to the right node… One minor challenge is that the Browser kernel code doesn’t actually have a reference to the root node of the app in scope at all. It’d be handy if that was around! I can probably figure out a way to get that in scope, perhaps doing something like:

const elmRoot = document.createElement('div'); // This is where we'll initialize the elm.
const compiledElm = ...; // get the generated JS as a string
const monkeyPatched = compiledElm.replace('document.getElementById', 'appRoot.getRootNode().getElementById');
new Function('appRoot', compiledElm)(elmRoot);
1 Like

I just gave the monkey-patching approach I mentioned a try, and it does work!

Here’s a tiny repo showing the approach; it also shows how to initialize an Elm module without polluting the window object.

3 Likes

thanks for the awesome information.

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