DEV Community

Cover image for Google Translate was crashing our React app — here's how we fixed "removeChild" for good
QRflows
QRflows

Posted on

Google Translate was crashing our React app — here's how we fixed "removeChild" for good

I build QRflows, a dynamic QR code platform. Last week I was going through session recordings and found our most engaged user of the month: someone in Hong Kong who spent 11 minutes in the dashboard, clicked 82 times, tried half a dozen QR types, added a logo, built a landing page.

At minute three, the page crashed and reloaded.

The error in the console:

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

The recording made the cause obvious once I noticed it: the whole dashboard was in Chinese. Our site only exists in English. The user had Chrome's built-in Google Translate turned on.

This is one of those bugs that never shows up in your own testing, because you never translate your own app. Here's what's going on, how to reproduce it without Google Translate, and the three-layer fix we shipped.


Why Google Translate breaks React

React keeps references to the DOM nodes it created, including plain text nodes. When state changes, it updates or removes those exact nodes.

Google Translate doesn't know about React. When it translates a page, it walks the DOM and replaces text nodes with its own <font> elements containing the translated text:

<!-- What React rendered -->
<button>Save</button>

<!-- What the DOM looks like after Google Translate -->
<button><font><font>保存</font></font></button>
Enter fullscreen mode Exit fullscreen mode

The original "Save" text node is gone from the DOM, but React still holds a reference to it. The next time React needs to remove or replace that text, it calls parent.removeChild(textNode) — and the browser throws, because that node is no longer a child of anything.

The same thing happens with insertBefore when React tries to insert something next to a node that Translate has swapped out.

It's a long-standing issue — facebook/react#11538 has been open since 2017 — and it isn't specific to React: anything that holds references to text nodes is vulnerable. If you want the deep dive, Martijn Hols' write-up is excellent.

Which code is vulnerable

The crash needs two things: a bare text node and something conditional next to it. Patterns like these:

// Text sitting next to a conditional element
<button>
  {isSaving && <Spinner />}
  Save
</button>

// Conditional text
{error && error.message}

// Ternary returning strings
<span>{isSaving ? "Saving…" : "Saved"}</span>

// Text mixed with an expression
<p>Uploaded {count} files</p>
Enter fullscreen mode Exit fullscreen mode

Every QR builder has dozens of these: status labels, counters, "Uploading… / Done", plan badges. Which is why the crash hit our most active user and nobody else.


Reproducing it without Google Translate

Waiting for a translated session to show up in your logs is not a test plan. You can simulate what Translate does to the DOM in the browser console:

function fakeTranslate(root = document.body) {
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
    acceptNode: (node) =>
      node.nodeValue.trim() &&
      node.parentElement &&
      !node.parentElement.closest('script, style, [translate="no"], font')
        ? NodeFilter.FILTER_ACCEPT
        : NodeFilter.FILTER_REJECT,
  });

  const nodes = [];
  while (walker.nextNode()) nodes.push(walker.currentNode);

  for (const textNode of nodes) {
    const outer = document.createElement('font');
    const inner = document.createElement('font');
    inner.textContent = '译' + textNode.nodeValue; // mark it as "translated"
    outer.appendChild(inner);
    textNode.parentNode.replaceChild(outer, textNode);
  }
  return nodes.length;
}

window.addEventListener('error', (e) => console.log('CRASH:', e.message));
fakeTranslate();
Enter fullscreen mode Exit fullscreen mode

Paste it, run fakeTranslate(), then use the app: switch tabs, toggle options, type into inputs, navigate between routes. Run fakeTranslate() again after each step, because real Translate keeps re-translating new content as it appears.

On our dashboard, switching QR types after this reproduced the exact crash from the recording within a few clicks.


The fix: three layers

There's no single clean fix. What worked for us is three layers, from "never crash" to "fewer weird states".

Layer 1: a DOM guard so the crash can't happen

This is the workaround from the React issue thread: patch removeChild and insertBefore so that, instead of throwing, they skip the operation when the node relationship is no longer what React expects.

// src/lib/domTranslateGuard.ts
export function installDomTranslateGuard(): void {
  if (typeof window === 'undefined' || typeof Node !== 'function') return;

  const proto = Node.prototype as any;
  if (proto.__translateGuardInstalled) return;
  proto.__translateGuardInstalled = true;

  const originalRemoveChild = proto.removeChild;
  proto.removeChild = function (child: Node) {
    if (child.parentNode !== this) {
      console.warn('[dom-guard] removeChild skipped', child, this);
      (window as any).clarity?.('event', 'dom_guard_removeChild');
      return child;
    }
    return originalRemoveChild.call(this, child);
  };

  const originalInsertBefore = proto.insertBefore;
  proto.insertBefore = function (newNode: Node, referenceNode: Node | null) {
    if (referenceNode && referenceNode.parentNode !== this) {
      console.warn('[dom-guard] insertBefore skipped', referenceNode, this);
      (window as any).clarity?.('event', 'dom_guard_insertBefore');
      return newNode;
    }
    return originalInsertBefore.call(this, newNode, referenceNode);
  };
}
Enter fullscreen mode Exit fullscreen mode

Two details that matter:

Install it before React touches the DOM. We call it as the very first thing in the client entry, before hydrateRoot:

// entry-client.tsx
import { installDomTranslateGuard } from './lib/domTranslateGuard';
installDomTranslateGuard();

// ...then hydrateRoot(...)
Enter fullscreen mode Exit fullscreen mode

Keep it out of SSR. It only lives in the client entry, and the typeof window check is a second line of defence. The server never touches Node.prototype.

The trade-off is honest: this hides the error instead of fixing the cause. A skipped insertBefore can occasionally leave a piece of UI missing until the next render. That's why we log it and send a custom event to our session analytics — so we can see how often the guard actually fires, and on which pages.

Layer 2: stop giving Translate bare text nodes

The guard prevents crashes. Wrapping text in elements prevents most of the situations where it would have to step in. If text lives inside its own <span>, Translate replaces the text inside the span, and React only ever adds or removes the span itself — which is still there.

// Before
<button>
  {isSaving && <Spinner />}
  Save
</button>

// After
<button>
  {isSaving && <Spinner />}
  <span>Save</span>
</button>
Enter fullscreen mode Exit fullscreen mode
// Before
{error && error.message}

// After
{error && <span>{error.message}</span>}
Enter fullscreen mode Exit fullscreen mode
// Before
<p>Uploaded {count} files</p>

// After
<p><span>Uploaded {count} files</span></p>
Enter fullscreen mode Exit fullscreen mode

No visual change, no new classes. We went through every component in the QR builder, the file upload flow, the editor and the download modals, and wrapped anything that changes at runtime: status labels, counters, button text next to spinners.

Layer 3: translate="no" — but only where it makes sense

The tempting shortcut is <html translate="no">. Don't. Your users turned on translation because they need it — breaking the crash by breaking the translation just moves the problem.

We only opt out content that shouldn't be translated anyway:

  • URL, email, phone and password inputs
  • short links and destination URLs
  • the QR preview and technical labels (sizes, PNG/SVG)
  • the brand name in the header and footer
<input type="url" translate="no" />
<span translate="no">{shortUrl}</span>
Enter fullscreen mode Exit fullscreen mode

Everything else — headings, buttons, help text — still gets translated.


How we verified it

After deploying, I ran the fake-translate script on the live site: the public PDF QR generator, then the logged-in dashboard. I switched QR types back and forth, toggled the logo option, typed into inputs, and navigated from the builder to the landing page editor and back — re-running fakeTranslate() after each step.

Result: zero uncaught errors, no reloads, and the UI kept updating live with the "translated" text in place. The brand name and URL fields stayed untouched, as intended.

We'll keep watching the dom_guard_* events in session analytics. If they show up a lot on a specific page, that page still has bare text nodes to wrap.


TL;DR

  • Google Translate replaces text nodes with <font> elements. React still holds the old nodes and crashes on removeChild / insertBefore.
  • You can reproduce it without Translate by wrapping text nodes in <font> from the console.
  • Layer 1: patch removeChild / insertBefore before hydration, client-only, and log when it fires.
  • Layer 2: wrap conditional and dynamic text in <span>.
  • Layer 3: translate="no" only on inputs, URLs and brand names — never on the whole app.

The part that stuck with me: this bug hit the single most engaged user we had that month. International users who translate your app are often exactly the people working hardest to use it.


I'm building QRflows in public — dynamic QR codes with Smart Rules, real-time analytics and a Claude MCP integration. If you've hit this bug with a different framework, I'd like to hear how you handled it.

Top comments (2)

Collapse
 
lycore profile image
Lycore Development •

The fakeTranslate snippet is the most useful part, since this bug is nearly impossible to catch otherwise. One small addition we've seen help: wrapping the conditional text in its own span, like <span>{error?.message}</span>, gives React an element to track instead of a bare text node, and Translate then edits the span's contents rather than replacing the node React holds. It also avoids the crash without needing translate="no" on parts of the UI that users may actually want translated. Have you tried that pattern against your fake translate test?

Collapse
 
qrflows profile image
QRflows •

Thanks - glad the snippet was useful. Yes, that’s exactly our Layer 2, and it’s the one that does most of the real work: once the text lives in its own , Translate rewrites the span’s contents and React’s references stay valid. Ran the fake-translate test against it, and the components we wrapped stopped crashing.

We still kept the guard for two reasons. First, coverage: it’s easy to miss one bare text node in a big component tree, and the next refactor can quietly add one back. Second, code we don’t own - third-party UI components render their own text nodes, and we can’t wrap those. The guard turns any of those misses into a logged event instead of a crashed page, and the event tells us which page still needs wrapping.

And agreed on translate="no" - we only use it on things that shouldn’t be translated anyway (URLs, inputs, the brand name), never as a crash fix.

Have you found a reliable way to catch bare text nodes before they ship? I’ve been thinking about a lint rule for text next to conditional expressions in JSX.