Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';

import { installDomTranslationGuard } from '@utils/domTranslationGuard';

/**
* Reproduces the Google Translate DOM-mutation crash: a text node that React
* still references gets re-parented (wrapped in a `<font>`) by the translator,
* then React tries to use it as an insertBefore reference / removeChild target.
*
* The guard patches `Node.prototype` once (idempotent), so the suite installs
* it a single time and asserts native behaviour via the captured originals.
*/
describe('domTranslationGuard', () => {
const nativeInsertBefore = Node.prototype.insertBefore;
const nativeRemoveChild = Node.prototype.removeChild;

beforeAll(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
installDomTranslationGuard();
});

afterAll(() => {
Node.prototype.insertBefore = nativeInsertBefore;
Node.prototype.removeChild = nativeRemoveChild;
vi.restoreAllMocks();
});

describe('insertBefore', () => {
it('natively throws when the reference node was re-parented', () => {
const parent = document.createElement('div');
const orphanReference = document.createTextNode('translated');
const newNode = document.createElement('span');

expect(() => nativeInsertBefore.call(parent, newNode, orphanReference)).toThrow();
});

it('inserts before the wrapper when the reference was wrapped in place', () => {
const parent = document.createElement('div');
// Translator wrapped the reference text node in a <font> that is still a
// direct child of the parent (the common Google Translate case).
const tail = document.createElement('span');
const fontWrapper = document.createElement('font');
const reference = document.createTextNode('translated');
fontWrapper.appendChild(reference);
parent.append(fontWrapper, tail);
const newNode = document.createElement('b');

expect(() => parent.insertBefore(newNode, reference)).not.toThrow();
// Order is preserved: new node lands before the wrapper, not appended.
expect(Array.from(parent.childNodes)).toEqual([newNode, fontWrapper, tail]);
});

it('appends when the re-parented reference has no ancestor under the parent', () => {
const parent = document.createElement('div');
const existing = document.createElement('span');
parent.appendChild(existing);
// Reference lives in an unrelated subtree entirely.
const detached = document.createElement('font');
const reference = document.createTextNode('translated');
detached.appendChild(reference);
const newNode = document.createElement('b');

expect(() => parent.insertBefore(newNode, reference)).not.toThrow();
expect(Array.from(parent.childNodes)).toEqual([existing, newNode]);
});

it('preserves normal ordering for a real child reference', () => {
const parent = document.createElement('div');
const existing = document.createElement('span');
parent.appendChild(existing);
const newNode = document.createElement('b');

parent.insertBefore(newNode, existing);

expect(Array.from(parent.childNodes)).toEqual([newNode, existing]);
});

it('appends to the end when reference is null (native contract)', () => {
const parent = document.createElement('div');
const first = document.createElement('span');
parent.appendChild(first);
const newNode = document.createElement('b');

parent.insertBefore(newNode, null);

expect(Array.from(parent.childNodes)).toEqual([first, newNode]);
});
});

describe('removeChild', () => {
it('natively throws when the child was already re-parented', () => {
const parent = document.createElement('div');
const fontWrapper = document.createElement('font');
const orphan = document.createElement('span');
fontWrapper.appendChild(orphan);

expect(() => nativeRemoveChild.call(parent, orphan)).toThrow();
});

it('skips silently when the child was already re-parented', () => {
const parent = document.createElement('div');
const fontWrapper = document.createElement('font');
const orphan = document.createElement('span');
fontWrapper.appendChild(orphan);

expect(() => parent.removeChild(orphan)).not.toThrow();
expect(orphan.parentNode).toBe(fontWrapper);
});

it('still removes a real child', () => {
const parent = document.createElement('div');
const child = document.createElement('span');
parent.appendChild(child);

parent.removeChild(child);

expect(parent.childNodes.length).toBe(0);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Hardens `Node.prototype.insertBefore` / `removeChild` against DOM mutations
* performed by in-browser page translators (most notably Google Translate).
*
* Translators rewrite text nodes in place — wrapping them in their own `<font>`
* elements — without React's knowledge. React keeps references to the original
* text nodes, so when it later reconciles it can call
* `parent.insertBefore(newNode, referenceNode)` (or `parent.removeChild(child)`)
* with a reference/child that the translator has since re-parented. The browser
* then throws:
*
* Failed to execute 'insertBefore' on 'Node': The node before which the new
* node is to be inserted is not a child of this node.
*
* which is uncaught during commit and takes down the whole route via the
* router's error boundary. Users vote successfully but then see "Something went
* wrong".
*
* The guard below intervenes only in that exact pathological case (the
* reference/child is not a child of `this`), falling back to appending /
* no-op so React's commit completes without throwing. In every other case it
* delegates to the native implementation unchanged.
*
* This is the widely-adopted mitigation for the React + Google Translate crash;
* see facebook/react#11538. Keeping translation working (rather than disabling
* it) matters for a governance app used worldwide.
*/

let installed = false;

export const installDomTranslationGuard = (): void => {
if (installed) return;
if (typeof Node !== 'function' || !Node.prototype) return;
installed = true;

const originalInsertBefore = Node.prototype.insertBefore;
Node.prototype.insertBefore = function <T extends Node>(
this: Node,
newNode: T,
referenceNode: Node | null,
): T {
// A translator re-parented the reference node (typically by wrapping it in
// a <font>). Insert before the ancestor that is still a direct child of
// `this` so ordering is preserved; only append if no such ancestor exists.
if (referenceNode && referenceNode.parentNode !== this) {
let ancestor: Node | null = referenceNode.parentNode;
while (ancestor && ancestor.parentNode !== this) {
ancestor = ancestor.parentNode;
}
if (import.meta.env.DEV) {
console.warn(
'[domTranslationGuard] insertBefore reference node was re-parented; ' +
(ancestor ? 'inserting before its wrapper.' : 'appending instead.'),
{ newNode, referenceNode, parent: this },
);
}
return ancestor
? (originalInsertBefore.call(this, newNode, ancestor) as T)
: (this.appendChild(newNode) as T);
}
return originalInsertBefore.call(this, newNode, referenceNode) as T;
};

const originalRemoveChild = Node.prototype.removeChild;
Node.prototype.removeChild = function <T extends Node>(this: Node, child: T): T {
// A translator already detached/re-parented the child: skip the removal.
if (child.parentNode !== this) {
if (import.meta.env.DEV) {
console.warn('[domTranslationGuard] removeChild target is not a child; skipping removal.', {
child,
parent: this,
});
}
return child;
}
return originalRemoveChild.call(this, child) as T;
};
};
5 changes: 5 additions & 0 deletions src/governance-app-frontend/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import ReactDOM from 'react-dom/client';

import { installDomTranslationGuard } from '@utils/domTranslationGuard';

import { App } from './app/App';

// Guard React's DOM commits against page-translator mutations before mounting.
installDomTranslationGuard();

const rootElement = document.getElementById('root') as HTMLElement;
ReactDOM.createRoot(rootElement).render(<App />);
Loading