Adopting elm-safe-virtual-dom
In this post we'll share the steps we took for adopting Simon Lydell's elm-safe-virtual-dom, which is a partial rewrite of Elm's Virtual DOM that makes it resilient to external DOM mutations, and friendlier to server-side rendering.
The post's target audience is Elm companies curious about adopting elm-safe-virtual-dom, and Elm engineers interested in the technical details of the migration.
Why we wanted an elm-safe-virtual-dom
Reliability and noise
Although Elm is famous for having no runtime exceptions, in practice, when browser extensions mutate the DOM behind Elm's back, Elm's Virtual DOM can get confused and throw errors. We were getting thousands of those a day.
Translations
Students arriving at our website don't always speak fluent English. While we provide our own language supports, crafted with principled pedagogy in mind, we still hear users would like support for simple browser-based translations.
Up until now, using browser-based translations has been a no-go, because they mutate the DOM in ways that break Elm's Virtual DOM.
The work
Patching Elm packages
The elm executable in our development environment was already wrapped by a script that applied patches to Elm's virtual dom package, using work done by Elm community member jinjor and further work at Lamdera.
One important difference between those patches and Simon Lydell's elm-safe-virtual-dom is that Simon's work requires patching not only elm/virtual-dom, but also elm/browser and elm/html, so we had to adapt our patching technique. Simon provides a patching script of his own, showcasing reliable ways to detect whether patches had been applied or not, and we are going to use it.
We leveraged our use of Nix to structure patching this way:
Fetch each forked package from Github using a derivation
Build a derivation with a directory structure mirroring Elm's package cache
Wrap the elm executable with a script that:
Detects when patching is necessary
Ensures the patching logic can't be run concurrently
Applies patches using Simon's script, adapted to our needs
Runs the real elm executable
Our changes to the patching script included:
Require an ELM_HOME environment variable to be set, so we don't accidentally patch the global Elm package cache
Require an argument for the directory where to find the forks
Handle read-only files from Nix's store
We also moved ELM_HOME for our projects to a custom directory. ELM_HOME is where Elm's global package cache lives, and it's usually found in ~/.elm. By moving it to a custom directory, we avoid interfering with other Elm projects on the same machine that might not need patching.
A sample repository with the patching automation can be seen here.
Issues we encountered
elm-css needs a fork too
The first thing we did after patching was run elm-test, and we got failures in tests, which we pinned down to Html.Styled from elm-css.
In a program with this view:
import Html.Styled as Html exposing (Html) import Html.Styled.Attributes as Attributes view : Model -> Html Msg view model = Html.div [] [ Html.label [ Attributes.for "pet-select" ] [ Html.text "Choose a pet" ] , Html.select [ Attributes.id "pet-select", on "change" targetValue ] [ Html.option [ Attributes.value "dog" ] [ Html.text "Dog" ] , Html.option [ Attributes.value "hamster" ] [ Html.text "Hamster" ] ] ]
And a test with elm-program-test:
exampleProgramTest : Test exampleProgramTest = test "selectOption borked" <| \() -> ProgramTest.createElement { init = init , update = update , view = view >> Html.toUnstyled } |> start () |> ProgramTest.selectOption "pet-select" "Choose a pet" "dog" "Dog" |> ProgramTest.done
We observed this failure:
â selectOption borked ⌠Query.fromHtml <div data-elm=""> <span class="elm-css-style-wrapper">...</span> <label for="pet-select" data-elm=""> Choose a pet </label> <select id="pet-select">...</select> </div> ⌠ProgramTest.selectOption "pet-select" "Choose a pet" "dog" "Dog" â check label exists: â has tag "label" â has attribute "for" "pet-select"
The isolated example can be seen in this gist.
The selectOption function from ProgramTest tries to find a a label element with a for attribute but fails. We can see the attribute on the HTML printed out by the test, so why is it not being found?
To understand it, you need to know how elm-css, elm/html and elm-program-test interact:
elm-css mirrors the elm/html API
Html.Styled mirrors Html
Html.Styled.Attributes mirrors Html.Attributes
Html.Styled.Attributes are translated into Html.Attributes when Html.Styled.toUnstyled is called
Simon's patch to elm/html changed certain Html.Attributes
for was changed to create an HTML attribute instead of an HTML property
The attribute "for" is called "htmlFor" when set as a property
elm-program-test uses elm/html internally to build its selectors
It was expecting an attribute called "for"
Our view uses elm-css
It was creating a property called "htmlFor" instead
We made a PR to elm-css mirroring Simon's changes to elm/html, and that fixed all tests for us.
stopPropagation on an ancestor blocks descendant's onCheck
This is an old Elm bug, reported here. Its minimal example is a checkbox inside a div that has stopPropagation on its click event:
Html.div [ Html.Events.stopPropagationOn "click" (Json.Decode.succeed ( Clicked, True )) ] [ Html.input [ Html.Attributes.type_ "checkbox" , Html.Attributes.checked checked , Html.Events.onCheck Checked ] [] ]
Clicks on the checkbox will not trigger an event for Elm.
We hit this bug on our modals, where stopPropagation is used in a backdrop container by default, even when where it isn't necessary.
The curious thing about this bug is, even though the old Elm bug is easily reproducible in isolation in the old Virtual DOM, it did not happen on our modals until we adopted elm-safe-virtual-dom. We tried to isolate what exactly made our modals immune to the issue. We gave up after a timeboxed investigation.
We solved the issue by removing stopPropagation where it wasn't necessary, and were lucky no places used both stopPropagation and checkboxes/radios inside modals.
It's possible another solution would have been adopting the dialog HTML element, but we haven't migrated our modals to use it yet, and haven't validated it would remove our need for stopPropagation.
UPDATE 2025-11-13: Simon Lydell did find an explanation for this issue and did a pretty detailed write-up in the original bug here.
Mini issue: --debug behaves differently than plain builds
While debugging the issue above, we noticed it would not reproduce when the Elm app was built with --debug, only in plain or optimized builds.
UPDATE 2025-11-13: Simon Lydell has fixed this, and --debug builds now behave the same as normal builds with elm-safe-virtual-dom. The the fix is already in the safe branch of lydell/virtual-dom.
UPDATE 2025-11-13: An earlier version of this post made a claim here that Html.lazy also did not work on --debug builds, in both Virtual DOM versions. That was not true. Jeroen Engels brought up that my misunderstanding was likely stemming from differences between plain and --optimize builds, which makes sense. Jeroen explained that in --optimize builds, additional wrapping for custom type values can be optimized away, and make otherwise failing lazy calls start working. Kudos to Simon for being skeptical about that claim, and to Jeroen for explaining my misunderstanding.
Some selects stopped working
Different from the elm-css issue we saw earlier, which only affected tests, we noticed some select elements stopped working in the real world. Picking an option in the select element would immediately be reverted.
We tracked it down to cases where we forgot to update the Attribute.selected value for options based on the model.
This worked before because the official Virtual DOM only compares its own virtualized DOM nodes' previous and current state when deciding when to change the real DOM. Since we were never changing selected, there was never anything to diff against.
elm-safe-virtual-dom compares properties directly against the real DOM, so it would see selected had changed in the real DOM, and would act to revert it.
This was a bug in our code, and the official Virtual DOM was only being forgiving about it.
contenteditable nodes stopped working
Nodes with contenteditable are plain looking text elements that users can type in, delete text from, etc. We use them in exercises where students are supposed to identify mistakes in text and fix them.
With safe-elm-virtual-dom, these nodes stopped accepting key presses for adding or removing text, even though you could still navigate with your cursor through the text.
Our implementation
We had implemented it like this:
Set contenteditable to true on a span
Set Attribute.property "innerText" to a fixed initial value
Read back target.innerText in the Decoder for events like keyup, blur, etc
Simplified code below:
view initialValue onInput = Html.Styled.span [ Attributes.contenteditable True , initialValue |> Encode.string |> Attributes.property "innerText" , eventListeners onInput ] [] eventListeners : (String -> msg) -> List (Html.Styled.Attribute msg) eventListeners onInput = List.map (\event -> Html.Styled.Events.on event (Decode.map onInput (Decode.at [ "target", "innerText" ] Decode.string) ) ) [ "blur", "keyup", "paste", "copy", "cut", "mouseup" ]
The issue
This implementation only worked because the official Virtual DOM only compares its own virtualized DOM nodes previous and current state when deciding when to change the real DOM. Since we never changed innerText through Elm, it never detected a diff, and never tried to change the real DOM.
In elm-safe-virtual-dom, the real DOM node's innerText is compared against the virtualized node on every render, so the fixed value would undo user edits.
Changing it to not be a fixed value made edits possible, but the cursor would jump to the start of the text on every keystroke.
Making things a bit worse
In some Elm apps, we have a port for tracking user activity that responds to a whole host of events, including input, which is the only event triggered by dictation software.
The interaction between this port and the contenteditable node got even more problematic:
On input
The real DOM would have been updated with user input
The port would send a "user active" message to Elm
Elm would not know the user changed the text, so it would re-render the contenteditable node with the old text
On keyup
Elm would fetch target.innerText for the event
innerText would reflect the reverted text
We tried making our contenteditable element listen to input events too, but the port still fired first, and relying on event ordering seemed fragile.
Our fix
The fix for elm-safe-virtual-dom required using a Custom Element, and a bit of creativity:
Give the custom element a private content property to hold the text coming from Elm
Provide getters and setters for the content property
In the Custom Element's connectedCallback, set innerText to the value of the content property
Keep reading target.innerText in Elm event decoders
This effectively replicates the implementation we had before: Elm only affects innerText at initialization, but it reads the up-to-date value from the real DOM on every edit.
The custom element's code looks like this:
customElements.define("content-editable", class extends HTMLElement { private elmContent: string | null = null; connectedCallback() { this.innerText = this.elmContent || ""; } get content(): string { return this.elmContent || ""; } set content(value: string) { this.elmContent = value; } }, );
We then wrap this in a ContentEditable Elm module to allow us to document and reproduce the pattern.
What if Elm needs to change or reset the text after initialization?
When we need to force the content-editable custom element to receive a new value from Elm, we can use Html.Keyed:
Html.Keyed.node "content-editable-container" [] [ ( key, ContentEditable.view content onInput) ]
When an event needs to change innerText, we can bump the value of key in the Html.Keyed child node, which in turn will cause Elm to destroy and re-create the custom element, re-running our connectedCallback and setting innerText to the new value.
Assessing risks
Risk of reverting
There is a risk that elm-safe-virtual-dom's patches are never adopted into upstream Elm packages, or that Evan takes an entirely different approach to address Virtual DOM breakage caused by external DOM mutations.
We had to make changes to our codebase to adopt elm-safe-virtual-dom. What would be the effort if we had to move away from elm-safe-virtual-dom in the future?
While assessing every change we made here, we realized all of them are backwards-compatible. Elm's official Virtual DOM is strictly more lenient than elm-safe-virtual-dom, so we expect going back to the official Virtual DOM would require no code changes aside from our patching infrastructure.
Furthermore, Simon is working with Lamdera to integrate his patches into their Elm compiler, which signals the community is moving towards embracing these changes, lowering the chances of a revert.
Risk of maintaining forks
Elm's core packages are very stable:
elm/browser and elm/html have not seen a release in 6 years
elm/virtual-dom has seen 2 patch releases in 6 years
We don't expect there to be effort keeping forks in sync, and we could count on Simon, and Lamdera in the future too, on helping keep things in sync.
The riskiest fork is rtfeldman/elm-css, which we had to patch ourselves, and which sees more frequent releases. However, our changes to elm-css were in its mirror of the elm/html API. Since we don't anticipate elm/html changing, we don't expect that mirror to change either.
Results
We shipped to production with elm-safe-virtual-dom on November 3rd 2025, and have not encountered any new issues.
We had 10 distinct Virtual DOM-related exceptions in production the week before shipping, triggering thousands of times per day. After shipping, only 3 were left. The new Virtual DOM made the call stack clearer for those 3, and helped us pin them down to an old polyfill we had for Custom Elements. Fixing that, we now have zero Virtual DOM exceptions in production.
Conclusion
Ultimately, adopting elm-safe-virtual-dom has been a clear and significant win for reliability. Adoption required a careful patching strategy via Nix and deep dives into surprising interactions with elm-css, contenteditable, and even a long-standing Elm bug.
The most interesting take-away, perhaps, is all of the issues we encountered led to backwards-compatible fixes, so reverting back to the official Elm Virtual DOM, if necessary, would be trivial.
The results were very satisfying: we've gone from thousands of daily Virtual DOM-related exceptions to zero. We now have the confidence to explore browser-based translations and are no longer at the mercy of browser extensions, just as we'd hoped.
For any team facing similar challenges in Elm, we highly recommend exploring elm-safe-virtual-dom. While the migration requires some effort, the payoff in stability and correctness are well worth it.
Juliano Solanho @omnibs Engineer at NoRedInk
Thank you Brian Carroll and Brian Cardiff for draft reviews and feedback, and Celso Bonutti for raising this opportunity internally! đ














