Drop-in replacement for `Browser.document` with element mount point and title – no ports needed

It’s known that Browser.application and Browser.document can encounter runtime errors in the wild, due to their assumption that Elm has complete control over the DOM. This assumption doesn’t play nicely with browser extensions.

This recent post by @Ethan_Martin, providing a Webpack-based fix for both Browser.application and Browser.document (thank you!), prompted me to share something I discovered recently: a way to create a drop-in replacement for Browser.document within Elm, without requiring the use of ports to set the document title.

The key to making this work has to do with the lenient way browsers look for the document title. In all the browsers I tested (Firefox, Safari, IE, Chromium-based browsers), they use the first <title> element they encounter anywhere in the DOM – even if it’s in the <body>!

This means we can use Browser.element with Html.node to provide the <title> element from Elm. The only restriction is that Elm’s <title> must be the first <title> element in the DOM for it to take effect – any earlier <title> element specified in the HTML, even an empty one, will override it.

Below is a demo app showing the simplest way I found to implement this in Elm. (Note that it won’t work via elm reactor, which specifies its own <title> element.)

module Demo exposing (main)

import Browser
import Html


documentElement :
  { init : flags -> ( model, Cmd msg )
  , subscriptions : model -> Sub msg
  , update : msg -> model -> ( model, Cmd msg )
  , view : model -> Browser.Document msg
  }
  -> Program flags model msg
documentElement { init, subscriptions, update, view } =
  Browser.element
    { init = init
    , subscriptions = subscriptions
    , update = update
    , view =
      \model ->
        let
          { title, body } =
            view model
        in
        Html.div [] <| Html.node "title" [] [ Html.text title ] :: body
    }


main : Program () () msg
main =
  documentElement
    { init = \_ -> ( (), Cmd.none )
    , subscriptions = \_ -> Sub.none
    , update = \_ _ -> ( (), Cmd.none )
    , view =
      \_ ->
        { title = "Hello World!"
        , body = [ Html.text "It works!" ]
        }
    }

Hope this is helpful!

10 Likes

After thinking about this some more, here’s a more elegant implementation. Rather than creating a documentElement wrapper function around Browser.element, all that’s really needed is this transformation function:

documentToHtml : Browser.Document msg -> Html.Html msg
documentToHtml { title, body } =
  Html.div [] <| Html.node "title" [] [ Html.text title ] :: body

This allows you to transition your programs from using Browser.document like this…

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

…to using Browser.element like this, without having to modify your existing view function:

main =
  Browser.element
    { init = init
    , subscriptions = subscriptions
    , update = update
    , view = \model -> view model |> documentToHtml
    }

Or alternatively:

main =
  Browser.element
    { init = init
    , subscriptions = subscriptions
    , update = update
    , view = view >> documentToHtml
    }
2 Likes

Regarding putting the title in the body, I would suggest to test this with software tools like SiteImprove (Web Accessibility tools to help you achieve accessibility compliance) which check for accessibility compliance.

I can see that browsers may be lenient about title placement in the DOM but I’m suspicious about the HTML correctness of the final result—not to mention the SEO implications.

2 Likes

That’s a very good point @passiomatic – my solution here is a hacky workaround. Even though browsers accept a <title> in within <body>, the HTML standard is clear that it should always be specified within <head> – so any tooling expecting standards-compliant behavior won’t like this approach.

In my testing, I did discover that you could start with a <title> in your static HTML, then remove it from the DOM via Javascript like this:

document.head.removeChild(document.querySelector('title'))

…which then allowed Elm’s body title to take effect from that point onward. I don’t know if this is an improvement, or just compounding the issues inherent to this approach. :grimacing:

In hindsight, a better solution might be to use a custom element that sets document.title. That would provide a more standards-compliant port-free way to to continue updating the document title from Elm when switching from Browser.document to Browser.element.

1 Like

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