From 1d5b47fa9b994108dbcace1f7659e2136ffcadfa Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:31:43 +0100 Subject: [PATCH] fix edge translation bug (#6158) --- .../src/core/utils/patchDomForTranslators.ts | 99 +++++++++++++++++++ frontend/src/index.tsx | 5 + 2 files changed, 104 insertions(+) create mode 100644 frontend/src/core/utils/patchDomForTranslators.ts diff --git a/frontend/src/core/utils/patchDomForTranslators.ts b/frontend/src/core/utils/patchDomForTranslators.ts new file mode 100644 index 0000000000..7efa285935 --- /dev/null +++ b/frontend/src/core/utils/patchDomForTranslators.ts @@ -0,0 +1,99 @@ +// Browser page translators (Edge, Chrome, extensions) wrap text nodes in +// injected elements, reparenting nodes React is holding. React's commit +// phase then throws NotFoundError on removeChild/insertBefore and the +// ErrorBoundary unmounts the app. https://github.com/facebook/react/issues/11538 +// +// We watch for translator fingerprints (Google Translate's translated-* class +// on , or any injected ) and install guards on Node.prototype only +// once one appears, so native DOM semantics are preserved when no translator +// is active. + +declare global { + interface Node { + __stirlingTranslatorPatched?: boolean; + } +} + +let patchApplied = false; + +function isGoogleTranslateActive(): boolean { + const cls = document.documentElement.classList; + return cls.contains("translated-ltr") || cls.contains("translated-rtl"); +} + +function applyDomPatch(trigger: string): void { + if (patchApplied) return; + if (typeof Node === "undefined" || !Node.prototype) return; + if (Node.prototype.__stirlingTranslatorPatched) return; + patchApplied = true; + Node.prototype.__stirlingTranslatorPatched = true; + + console.warn( + `[dom-patch] Browser page translator detected (${trigger}). ` + + "Installing removeChild/insertBefore guards to prevent React crashes. " + + "The UI may show minor glitches while the translator is active.", + ); + + const originalRemoveChild = Node.prototype.removeChild; + Node.prototype.removeChild = function patchedRemoveChild( + this: Node, + child: T, + ): T { + if (child.parentNode !== this) return child; + return originalRemoveChild.call(this, child) as T; + } as typeof Node.prototype.removeChild; + + const originalInsertBefore = Node.prototype.insertBefore; + Node.prototype.insertBefore = function patchedInsertBefore( + this: Node, + newNode: T, + referenceNode: Node | null, + ): T { + if (referenceNode && referenceNode.parentNode !== this) return newNode; + return originalInsertBefore.call(this, newNode, referenceNode) as T; + } as typeof Node.prototype.insertBefore; +} + +export function armTranslatorDetector(): void { + if (typeof window === "undefined" || typeof MutationObserver === "undefined") + return; + if (typeof document === "undefined" || !document.documentElement) return; + + // Edge case: class already set (e.g., bfcache restore). + if (isGoogleTranslateActive()) { + applyDomPatch("html class was already translated-* on arm"); + return; + } + + const observer = new MutationObserver((mutations) => { + for (const m of mutations) { + if ( + m.type === "attributes" && + m.target === document.documentElement && + isGoogleTranslateActive() + ) { + applyDomPatch(" translated-* class appeared"); + observer.disconnect(); + return; + } + if (m.type === "childList") { + for (const n of m.addedNodes) { + if (n.nodeName === "FONT") { + applyDomPatch(" element injected into DOM"); + observer.disconnect(); + return; + } + } + } + } + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + childList: true, + subtree: true, + }); +} + +armTranslatorDetector(); diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index a5c4783488..5a7b6272fb 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,3 +1,8 @@ +// Must be imported before React so the DOM-prototype patch is installed +// before React's commit phase runs. Prevents browser page translators +// (Edge / Google Translate / extensions) from crashing the app via +// parent-mismatch DOMExceptions. See the module for details. +import "@app/utils/patchDomForTranslators"; import "@mantine/core/styles.css"; import "@mantine/dates/styles.css"; import "../vite-env.d.ts"; // eslint-disable-line no-restricted-imports -- Outside app paths