Skip to content

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

Open
lydell wants to merge 138 commits into
elm:masterfrom
lydell:safe
Open

lydell wants to merge 138 commits into
elm:masterfrom
lydell:safe

Conversation

@lydell

@lydell lydell commented Jun 1, 2025 •

Copy link
Copy Markdown

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, as well as at NoRedInk since 2025-11-03. There are at least two more companies using it in production as reported by people on Discord.

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 three details should be equivalent, except less buggy.

The goal was to be 100 % backwards compatible. For some people, it is. For others, there are four 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 properties are diffed against the real DOM. This can uncover mistakes in Elm code, where the you forgot to set selected properly on options in a <select> element, for example.

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

The fourth detail can be summarized as class vs className. property "className" and attribute "class" have never been merged – and it hasn’t really been a problem, since nobody mixes both in practice. But now you might mix Html.Attributes.class (which uses the class attribute) with Html.Styled.Attributes.class (which uses the className property, from the elm-css package). But there isn’t really a reason to do so.

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).

Comment thread src/Elm/Kernel/VirtualDom.js Outdated
// 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 };

@lydell lydell Aug 16, 2025 •

Copy link
Copy Markdown
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:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The diff is now marked as outdated, because I optimized this: Instead of void-ing an unnecessary object, we simply mention all the __foo in the comment: 655f019 (this PR)

Stopping propagation implies synchronous rendering. `isSync` is passed
along with the message in `sendToApp`. However, when wrapping
`sendToApp` with the mapping function from `Html.map`, I forgot to pass
it along, causing `isSync` to be `undefined`, which results in the same
outcome as `false`.
ahankinson added a commit to elm-janitor/virtual-dom that referenced this pull request Nov 20, 2025
ahankinson added a commit to elm-janitor/browser that referenced this pull request Nov 21, 2025
ahankinson added a commit to elm-janitor/browser that referenced this pull request Nov 21, 2025
lydell added 13 commits October 14, 2026 16:26
The Elm compiler runs its replacements in comments, too, so no need for
creating an unnecessary object. (The compiler does not parse the kernel
files as JavaScript, it treats them as plain text.)
To reproduce:

1. Render keyed children `[a, b]`.
2. Remove `a` in the browser devtools.
3. Render `[b, a]` (a swap).
To reproduce:

    if modBy 2 model.count == 0 then
        Html.div [] [ Html.br [] [] ]

      else
        Html.section [] []

When rendering the `section`, its `tNode` should not contain the `br`
from the `div`.
It never uses its `x` argument for anything. And it makes sense: Before
calling the function we have checked `x === y` or that two lazy nodes
are equal.
It never uses its `eventNode` argument. And it makes sense: The function
only cares about text nodes (which can’t have event listeners).
When virtualizing HTML attributes that should become Elm virtual-dom
properties, we’re calling `_VirtualDom_property` which takes an Elm
`Decode.Value` as its value argument, but we had forgotten to actually
turn our plain JavaScript value into `Decode.Value` with `__Json_wrap`.

This resulted in a bug:

1. Have `<button disabled>` in the HTML.
2. But render just `Html.button []` in Elm (no `disabled`).

This resulted in the button staying disabled, while it should become
enabled (the HTML attribute should be removed).
The code currently covers updates and replacements, but not removals.

To reproduce:

1. Render `Html.div [] [ Html.text "Guten Tag" ]`.
2. Translate the page.
3. Render `Html.div [] []`.

Result: The translation of “Guten Tag” stays on the page.

With this commit: The translation of “Guten Tag” is removed.

Remember how browsers’ page translators differ:

- Firefox: Updates the existing text node. So it actually had no bug before.
- Safari: Replaces with a new text node.
- Chrome: Replaces with a `<font>` element with a new text node inside.
As mentioned in the commit message for commit bdb8913
there is no known case where the Firefox translator splits texts due to different word
order and detecting that a removed text node has changed results in a better outcome.

This commit removes the extra checks, since it get rids of a (recursive) helper function.
We used to have code like this, but it was removed on this branch:

```js
// remove if not in the new facts
if (!(xKey in y))
{
	diff = diff || {};
	diff[xKey] =
		!category
			? (typeof x[xKey] === 'string' ? '' : null)
			:
		(category === 'a__1_STYLE')
			? ''
			:
		(category === 'a__1_EVENT' || category === 'a__1_ATTR')
			? undefined
			:
		{ __namespace: x[xKey].__namespace, __value: undefined };

	continue;
}
```

There you see how we set events to `undefined`, as a way of showing that
they should be removed. This is now diffed against the actual event
callbacks in `_VirtualDom_applyEvents` instead:

```js
for (key in allCallbacks)
{
        if (!(key in events))
        {
                domNode.removeEventListener(key, allCallbacks[key]);
                delete allCallbacks[key];
        }
}
```

Since we never set any events to a falsy value, the code removed in this
commit was dead.
If a browser extension swaps the order of children and the user is also
using a page translator, the code handling translated text crashes.

Setup:

```elm
Html.div []
    [ Html.text (if something then "Guten Tag" else "Guten Abend")
    , Html.button [] [ Html.text "1" ]
    , Html.button [] [ Html.text "2" ]
    ]
```

Steps to reproduce (using Google Translate in Chrome, which replaces
text nodes with `<font>` elements):

1. Swap the two buttons in the browser console.
2. Translate the page.
3. Flip `something`.

Before this commit: A crash, due to the old code assuming Elm’s elements
were in the correct order.

With this commit: The new translation appears (no leftover old text),
and the buttons return to their correct order.

As a bonus, the new code is simpler than the old.
It’s really strange, but `setAttributeNS` allows a prefix, as in
`xlink:href`, but `removeAttributeNS` does not – you need to pass just
`href`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment