diff --git a/src/governance-app-frontend/src/common/utils/domTranslationGuard.spec.ts b/src/governance-app-frontend/src/common/utils/domTranslationGuard.spec.ts new file mode 100644 index 000000000..1a462f8ac --- /dev/null +++ b/src/governance-app-frontend/src/common/utils/domTranslationGuard.spec.ts @@ -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 ``) 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 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); + }); + }); +}); diff --git a/src/governance-app-frontend/src/common/utils/domTranslationGuard.ts b/src/governance-app-frontend/src/common/utils/domTranslationGuard.ts new file mode 100644 index 000000000..e575f6d16 --- /dev/null +++ b/src/governance-app-frontend/src/common/utils/domTranslationGuard.ts @@ -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 `` + * 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 ( + this: Node, + newNode: T, + referenceNode: Node | null, + ): T { + // A translator re-parented the reference node (typically by wrapping it in + // a ). 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 (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; + }; +}; diff --git a/src/governance-app-frontend/src/main.tsx b/src/governance-app-frontend/src/main.tsx index 9ebc7ea84..8819ea106 100644 --- a/src/governance-app-frontend/src/main.tsx +++ b/src/governance-app-frontend/src/main.tsx @@ -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();