Skip to content

Batch: Support browser extensions, Google Translate, fix Html.map, and more #187

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 123 commits into
base: master
Choose a base branch
from

Conversation

lydell
Copy link

@lydell lydell commented Jun 1, 2025

Intro

This PR:

  • Fixes basically all bugs related to the (virtual) DOM.
  • Makes Elm work with browser extensions and Google Translate – and might be the first framework to do so. End users for the win!
  • Does not change performance in any way that I’ve been able to measure.
  • Is 99.99 % backwards compatible.
  • Is used in production at Insurello since 2025-05-26.

For those who would like to use this PR in their own app – see https://github.com/lydell/elm-safe-virtual-dom.

The above link also explains exactly what is changed, in a way that should be somewhat understandable even if you’re not super into the real and virtual DOM.

Below is a more technical, condensed version of the above link.

Summary of changes and fixed issues

The key changes

Making Elm work well with browser extensions, third-party scripts and page translators required three key changes.

1. Keeping our own tree of DOM nodes

A render in Elm currently works like this:

  1. Run view.
  2. Diff with the previous virtual DOM, producing patches.
  3. Attach a DOM node to each patch (_VirtualDom_addDomNodes).
  4. Apply the patches.

It’s step 3 that can crash if a browser extension has changed the DOM.

With this PR, there is no more step 3. Instead, we store the DOM nodes in our own tree. While diffing in step 2, we simply make the changes immediately to the correct DOM node, by reading it from our own tree. Since there’s no walking of the real DOM tree, it doesn’t matter what changes a browser extension makes.

See New algorithm for pairing virtual DOM nodes for all the details.

2. Cooperating with page translators

There’s some new code for cooperating with page translators (Google Translate, as well as the translators built into Firefox and Safari). If the diff tells us to update a text node, but we detect that the text node in question has been translated, we tell the parent element of that text node to delete all text node children and re-render them. The page translator then kicks in and re-translates that element (taking the entire text of the element into account for a more accurate translation).

(Evan, if you remember us talking about this at Elm Camp 2024 – don’t worry, there is no “bug” introduced on purpose regarding <font> elements – they can still be created by Elm just fine. Google Translate oddly uses <font> tags for translated text, but it turned out to easy to tell the difference between <font> tags created by Elm and by others.)

3. Virtualizing more conservatively

When using Browser.document and Browser.application, Elm takes charge of <body>. Third-party scripts and browser extensions put things in <body> too, and crucially they might do so before Elm initializes. Currently, Elm virtualizes all elements in the mount node (<body>) in this case, which most likely results in the extra things in <body> added by third-party scripts and browser extensions being removed.

This PR changes the virtualization behavior to only virtualize text nodes, and elements that have the data-elm attribute, leaving everything else alone. I add data-elm automatically to all elements created by Elm, so if you server-side render your page by running your Elm code on the server, you get that for free without having to do anything. You can read more about how this can be used in practice at elm-pages PR 519.

Note: This is the reason the PR is only 99.99 % backwards compatible. Read more in the “Breaking” changes section below.

The other changes

So, I’ve talked about the key changes. But the “Summary of changes and fixed issues” section mentions a few more things. Why are they in this PR?

  • Fixed Html.map: I needed to work on Html.map anyway to make it work with the new DOM node pairing algorithm.
  • Improved Html.Keyed: Same thing.
  • Virtualization: I needed to touch virtualization to support third-party scripts better, so I ended up going all the way with it.
  • CSS custom properties and namespaced attributes: I needed to work on the diffing code anyway, since it now applies changes directly to DOM nodes instead of creating patches, and it was way easier to do all the changes in one batch and test it thoroughly once in production, rather than juggling with a stack of PR:s.
  • lazy fix for inputs: It was incredibly easy to fix thanks to the other changes in this PR.

Detailed descriptions of changes

“Breaking” changes

I haven’t changed the Elm interface at all (no added functions, no changed functions, no removed functions, or types). All behavior except two details should be equivalent, except less buggy.

The goal was to be 100 % backwards compatible. For some people, it is. For others, there are two changes that are in “breaking change territory”. The first one can be summarized as: Elm no longer empties the mount element. It’s easily fixed by adding the data-elm attribute to select elements in the HTML.

That’s how users will perceive it. In reality, the actual change is which elements are virtualized (to support third-party scripts better, as mentioned in the “Virtualizing more conservatively” section).

The second detail can be summarized as setters should have getters on custom elements. Otherwise the setter will unnecessarily run on every render.

Read all about these “Breaking” changes in the safe-virtual-dom documentation.

Performance

  • The well-known js-framework-benchmark includes Elm. I ran that Elm benchmark on my computer, with and without my PR:s, and got the same numbers (no significant difference).
  • When testing with some large Elm applications at work, I couldn’t tell any performance difference with the PR:s. (Neither by eye nor by profiling.)
  • Both the official elm/virtual-dom and my PR have O(n) complexity.
  • The official elm/virtual-dom algorithm sometimes does more work, but other times this PR does more work. It seems to even out.

Related PR:s

Code style

I’ve done my best to:

  • Follow the existing code style.
  • Not introduce any unnecessary changes.
  • Use the same object key order when constructing objects (for performance).

Instead of at `Number.MIN_SAFE_INTEGER`. While `Number.MIN_SAFE_INTEGER`
doubles the amount of representable integers, there are reasons not to
use it:

- Debugging numbers near zero is easier, than numbers with a lot of
  digits.
- In elm/browser I added another ever-increasing counter for animation
  frames, and there it was not possible to use negative numbers. So we’d
  run out of animation frame counting before we run out of render
  counting anyway.
- It would still take like 25 000 years until we overflow.
- It wouldn’t surprise me if JS engines can optimize numbers that are
  near zero in some way.
lydell added 3 commits June 7, 2025 00:10
One might think that if `oldNode === newNode` no changes are needed,
but users can mutate properties, for example by typing into text inputs,
so we still need to apply properties.

This happens when using constants or `lazy`.
@r-k-b
Copy link

r-k-b commented Jul 3, 2025

We hit the Html.map bug (getting unexpected values like {$: 'UserBlurredRows'} instead of "some string" in unrelated msgs), this week in our ~200kloc Elm app.

@lydell's great work on elm-safe-virtual-dom helped a lot! 🎉 We're now using that to patch the files in elm_home in our build process, and that change will be in production within a few weeks.

lydell added 13 commits July 9, 2025 22:11
If an element has `[Html.Attributes.disabled True]` and then switches to
`[]`, the `disabled` property is supposed to be set to `false`. However,
this didn’t work because I had mixed up `props` and `prevProps`. I tried
to read `props["disabled"]` which is `undefined` (since `disabled` isn’t
set anymore), but should read `prevProps["disabled"]` (which is the
value `false`).
// grep --invert-match void src/Elm/Kernel/VirtualDom.js | grep --only --extended-regexp '_{2}[0-9a-z]\w+' | awk '!visited[$0]++' >b.txt
// # Keep only the double underscore tokens from the reference commit that still exist.
// grep --fixed-strings --line-regexp --file=b.txt a.txt
void { __2_TEXT: null, __text: null, __descendantsCount: null, __2_NODE: null, __tag: null, __facts: null, __kids: null, __namespace: null, __2_KEYED_NODE: null, __2_CUSTOM: null, __model: null, __render: null, __diff: null, __2_TAGGER: null, __tagger: null, __node: null, __2_THUNK: null, __refs: null, __thunk: null, __1_EVENT: null, __key: null, __value: null, __1_STYLE: null, __1_PROP: null, __1_ATTR: null, __1_ATTR_NS: null, __handler: null, __eventNode: null };
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This backwards compatibility (and some more later in the file) could be replaced by:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment