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 )