This article is published in English.
The React Bug That Only Appears When Readers Translate Your Page
Chrome page translation detaches React text nodes, causing removeChild crashes or silent freezes. Measurement across browsers shows when viewport repair helps and when writing into translator wrappers is safer.
Ticket one looked trivial. A React counter painted a stale figure while every nearby control still responded. Application state held the correct value; the painted string did not. Chrome’s built-in translator was enabled for that visitor.
A week later the same product started throwing NotFoundError: Failed to execute 'removeChild' on 'Node' and tearing down its own root. Same mechanism, louder failure.
What the translator does
Chrome’s translator never mutates your text node where it sits. It builds a replacement, wraps that replacement in a <font> element, inserts the wrapper at the old position, and disconnects yours from the live tree.
The original node remains allocated. React still holds the reference. The node is simply absent from the document.
That structural swap explains both failure modes. removeChild throws because the node React wants to drop no longer has a parent. Assigning nodeValue throws nothing, yet changes text that is no longer visible.
Crashes leave stack traces. Frozen UI leaves nothing useful. A counter that stops advancing looks like a state bug, which is where teams usually start digging.
The fix everybody pastes
shuhei described the DOM conflict on the React issue tracker in 2018. Dan Abramov marked the issue won’t-fix, yet the workaround from that discussion remains the default copy-paste answer. The patch short-circuits removeChild and insertBefore so they quietly return when the node is not a child of the intended parent.
Crashes disappear.
Live updates disappear with them.
The same React sample was compared under three configurations during one translation session. Leaving the tree unprotected produced two uncaught exceptions that dismantled the root, buttons included. Installing the pasted guard removed every error and every refresh: the counter froze, deleted strings stayed on screen, and a ternary that flipped state painted both branches at once.
Visible failure is traded for invisible staleness.
Measuring instead of guessing
Forum answers were set aside in favor of direct observation. Chrome, Edge, Firefox, Yandex, and Google’s standalone translation widget were compared with a recorder page that snapshotted every text node, paused until a human enabled translation, then ran sixteen probes plus five experiments. Timing work used Playwright against a real Chrome instance with a seeded profile.
The first hard result: the bug is not everywhere.
On Edge and Firefox the engine rewrites the text node without detaching it, so later mutations stick. A once-per-second counter climbed to 6 in both browsers. Readers on those engines never hit the crash path and never hit the freeze path. That fact undercuts anyone selling a one-size-fits-all fix, yet it is still true, which is why the README and demo page both state it.
The finding that changed the shape of the problem
Chrome’s translator works on whatever is currently in view, not on the entire page at once.
Against an idle translator, ten wake-up attempts were sent, along with a silent control: forced layout reads; fake resize, focus, visibilitychange, and mousemove events; scrolling the window away and back; plus authentic wheel and pointer activity emitted by the browser itself.
Only element.scrollIntoView() woke translation, after 168 milliseconds. The remaining nine attempts stayed quiet for a full ten seconds.
Two failed attempts were genuine browser events, which rules out “trusted input” as the sole trigger. Window-level scrolling also failed. The translated element has to become visible itself.
Where the first conclusion failed
A bold claim entered the draft: after a detached text node is restored, Chrome never translates it again. A probe appeared to confirm it. The probe ran, the node stayed English, and the sentence went into documentation.
That probe lived below the fold.
The contradiction surfaced only after the viewport rule was written down: both statements cannot hold together. Moving the probe into view and repeating the run showed Chrome repairing the restored node in 210 milliseconds. Off screen it never repaired, regardless of wait time.
The claim had been wrong for two days inside a document whose thesis is that measurement beats assumption.
Two further mistakes followed the same pattern. A head-to-head with an existing library was meaningless on the first pass because that library never loaded. Its bundle ends with a //# sourceMappingURL= comment, and the line appended to expose it globally landed inside that comment. Every run now asserts which DOM methods each arm actually patched before measurement begins.
Firefox was also overstated. Three counter runs all painted the correct number, yet two finished in French and one finished in English. Under continuous updates an in-place engine can lag and briefly show the original language.
All three corrections remained visible in the write-up, together with what replaced them. Hiding revisions would ask readers to trust unchecked sections.
What a repair costs the person reading
Once a node is gone from the tree, recovery has two shapes. Restore the original node and wait for the translator to notice and translate again. Or push the new value into the wrapper the translator already inserted.
Most existing libraries choose restoration. Functionally it succeeds, yet the reader absorbs a visible cost. Across five replicates of four updates, with visible text sampled every 50 milliseconds:
Restoring showed source-language strings for 100–150 ms on each update and 500–600 ms across a four-step sequence. Writing into the wrapper showed source-language text for 0 ms on every one of the twenty updates.
A single flash is easy to ignore. A live counter pays that flash on every tick.
A prior draft quoted 150–200 ms per update and 700 ms for the sequence. Those numbers came from one run and did not survive five replicates. The published report therefore retains the full five-run set: a lone timing sample is not a fact.
Where the better approach stops working
Injecting a fresh digit into an already-translated sentence works in Dutch. In Russian it can break grammar.
Intl.PluralRules('ru') classifies 4 as few and 7 as many, and noun endings follow that category. A Russian sentence that uses the few ending for four bulbs must not keep that ending after the count becomes seven. An early build produced exactly that silent corruption in a language the implementer cannot read.
The current logic refuses whenever the plural category, digit length, or sentence shape changes, or when the locale is unrecognized. When the heuristic declines, the UI shows the accurate count in the untranslated language. The downgrade is deliberate: a right number in English is safer than a wrong inflection in a language nobody on the team can proofread.
In Dutch and German, Intl.PluralRules returns other for every integer, so the plural-category trap never appears while testing against those locales alone.
What remains unmeasured
The Safari row is empty on purpose. WebKit in Playwright does not include a translator, and no Windows Safari build has shipped since 2012, so automated coverage is unavailable. Collecting data means a physical Mac and a person who can dismiss the translation prompt by hand. Guessing a result would be worse than leaving the cell blank.
When a recording never observes an active translation, the score is stored as null instead of false. That distinction blocks later readers from treating a quiet session as evidence that an engine is harmless.
The library
The accompanying package is translate-shield on npm. When Chrome replaces a text node with a <font> wrapper, the library records that relationship and redirects later React writes into the wrapper so the screen stays in the language the visitor selected. There are no runtime dependencies and the packed size is about 15 kB. Edge and Firefox receive an empty behavior path, since those engines never detach the node in the first place.
The package neither translates strings nor substitutes for an i18n library.
An interactive demo places a shielded document beside an unprotected twin while the visitor’s browser translates both. Separate documents are mandatory: the patch applies document-wide, so one page cannot host a fair control arm.
Every statistic cited here is backed by a JSON file generated by a re-runnable test in the repository, including measurements that were revised after earlier mistakes.
Further reading
Interactive comparison: https://google-translate-simulation.netlify.app/
Repository with raw probe recordings: https://github.com/alievdavlat/translate-shield
Published package page: https://www.npmjs.com/package/translate-shield