DEV Community

Cover image for Your React app unmounts when a reader turns on Chrome translate
davlat aliev
davlat aliev

Posted on Originally published at github.com

Your React app unmounts when a reader turns on Chrome translate

Look for this in your error tracker:

Uncaught NotFoundError: Failed to execute 'removeChild' on 'Node':
The node to be removed is not a child of this node.
Enter fullscreen mode Exit fullscreen mode

There is no local repro. The affected sessions have nothing in common, until you notice they had
page translation on.

I spent about a month measuring what translators do to a live DOM. Chrome, Edge, Firefox, Yandex,
and Google's translate_a/element.js widget. Every figure here comes from a raw recording, taken
2026-09-02 on Windows against Chrome 151.

What actually happens to your node

Chrome does not edit your text node. It builds a new one, wraps it, puts the wrapper where yours
was, and detaches yours.

Before and after: the text node is replaced by a font wrapper and the original is detached

Here is the markup that replaced There are 4 lights! on a page translated to Dutch:

<font dir="auto" style="vertical-align: inherit;"><font dir="auto" style="vertical-align: inherit;">Er zijn 4 lampen!</font></font>
Enter fullscreen mode Exit fullscreen mode

Yandex does the same thing with <ya-tr-span> and keeps your source string in a data-value
attribute.

React's fiber still points at the detached original. That gives you two failures. removeChild
and insertBefore throw, because the node has no parent any more. And node.nodeValue = '...'
throws nothing at all, while reaching nobody.

The crash is the loud half. The freeze is the one that never reaches your error tracker.

The JSX that triggers it

Two shapes. A conditional text node with a sibling:

<p>
  {visible && 'There are 4 lights!'}
  <span> (status)</span>
</p>
Enter fullscreen mode Exit fullscreen mode

The <span> matters. Without a sibling, React clears the parent with textContent and nothing
throws. With one, the string becomes a real text node and React calls removeChild on it.

And any text with a value in the middle:

<p>There are {count} lights!</p>
Enter fullscreen mode Exit fullscreen mode

The literal text on both sides forces three separate text nodes. That is what freezes.

shuhei described the first shape in facebook/react#11538
in 2018. Dan Abramov closed it won't-fix.

The guard everyone pastes

From that thread:

const original = Node.prototype.removeChild
Node.prototype.removeChild = function (child) {
  if (child.parentNode !== this) return child
  return original.apply(this, arguments)
}
Enter fullscreen mode Exit fullscreen mode

One React app, three setups, same translation:

Three setups compared: crash, frozen, correct

The guard stops the throw. It leaves the app frozen, keeps deleted text on screen, and lets a
flipped ternary render both branches. It trades a crash you can see for a bug you cannot.

Not every browser

Detaching In place
Engines Chrome, Yandex, the Google widget Edge, Firefox
What lands in the DOM a wrapper element, original detached the text node is rewritten, no wrapper
Marker inline vertical-align: inherit, or eight data-* attributes _msttexthash on Edge, nothing on Firefox
Your write reaches the screen no yes

Edge and Firefox leave the node connected, so your writes land. A counter driven once a second
reached 6 in both. They are narrower, not immune: when they merge adjacent text runs they detach
too, 2 nodes against Chrome's 3 on the same probe.

One detection trap, since the usual advice is to spot Google's wrapper by its vertical alignment:

getComputedStyle(wrapper).verticalAlign // "baseline", the cascade resolved it
wrapper.style.verticalAlign             // "inherit", the fingerprint you want
Enter fullscreen mode Exit fullscreen mode

Chrome only translates what is on screen

I fired ten signals at an idle translator, plus a no-signal control, each against the same
untranslated node.

Ten signals and a control. Only scrollIntoView worked, at 168ms

One worked. Nine did nothing for 10 seconds, including a real mouse wheel and a real mouse move
generated by the browser. So trusted input is not the trigger, and neither is scrolling the
window. The element has to enter the viewport.

Chrome's translator is not a MutationObserver over your page. It re-scans what becomes visible.

The conclusion I got wrong

I had written down that Chrome never repairs a restored text node. I had a probe proving it.

The probe sat below the fold.

With the element in view, Chrome repaired the restored node in 210ms. Off screen it never did.
A probe that only ever ran below the fold would have shipped the wrong claim, and for two days it
did.

What a repair costs the reader

Once the node is detached you have two options. Restore the original and let the translator catch
up, or write the new value into the wrapper the translator already built.

Five replicates of four updates, sampled every 50ms:

Across the 20 updates Restore and retranslate Write into the wrapper
Shortest update 100ms of source language 0ms
Median update 150ms 0ms
A sequence of four 500 to 600ms 0ms

A price that changes once is a flash nobody reports. A counter pays it every tick.

Where writing into the wrapper stops working

Splicing a new digit into an already-translated sentence works in Dutch. In Russian it breaks the
grammar. Intl.PluralRules('ru') puts 4 in few and 7 in many, and the noun follows the
category, so Здесь 4 лампочки! must not become Здесь 7 лампочки!. An early build of mine
shipped exactly that.

The merge now refuses when the plural category, the digit count or the sentence shape changes, or
when the locale is unknown. On refusal the reader gets the right number in the source language.
That is a real loss, and Dutch and German report other at every count, so it stays invisible in
the languages this was developed against.

Two things worth checking in your own app

Attributes get translated too: alt, title, placeholder, aria-label and a submit value.
data-* attributes and meta[name=description] are left alone.

Every engine also rewrites <html lang>, and Chrome appends class="translated-ltr". If you use
Next.js, that lands before hydration and gives you a mismatch warning that no runtime library can
prevent. suppressHydrationWarning on the root element is the fix, and it only covers that one
element's own attributes.

What came out of it

translate-shield forwards React's writes into the wrapper instead of restoring the node. Zero
dependencies, about 15 kB packed. On Edge and Firefox it does nothing, which is correct there.

npm install translate-shield
Enter fullscreen mode Exit fullscreen mode
import { initTranslateShield } from 'translate-shield'

initTranslateShield()
Enter fullscreen mode Exit fullscreen mode

It does not translate anything and it does not replace an i18n library. If you only want the lint
rule, eslint-plugin-react-google-translate
catches the risky JSX shapes before they ship, and is worth running either way.

There is a live demo that runs the protected
and unprotected versions side by side in your own browser, in two separate documents, because the
patch covers a whole document and one page could not host an honest control.

Every figure above comes from a JSON file in
the repo, produced by a Playwright spec you can
re-run. Including the two I had to correct.

Top comments (1)

Collapse
 
lunarose profile image
Luna Rose

Learned something new today. The DOM translation behavior is surprisingly tricky.