Der React-Bug, der nur auftaucht, wenn Leser Ihre Seite übersetzen
Die Übersetzung einer Chrome-Seite trennt die React-Textknoten, was zu Abstürzen durch removeChild oder stillen Freeze-Effekten führt. Messungen in verschiedenen Browsern zeigen an, wann die Reparatur des Viewports hilft und wann das Schreiben in Translator-Wrapper sicherer ist.
Ticket eins schien trivial zu sein. Ein React-Zähler zeigte einen veralteten Wert an, während alle nahegelegenen Steuerelemente weiterhin reagierten. Der Anwendungsstate enthielt den korrekten Wert; der angezeigte String jedoch nicht. Für diesen Besucher war Chromes eingebauter Übersetzer aktiviert.
Eine Woche später begann dasselbe Produkt, den Fehler NotFoundError: Failed to execute 'removeChild' on 'Node' auszulösen und seine eigene Wurzel zu zerstören. Derselbe Mechanismus, nur ein stärkerer Fehler.
Was der Übersetzer tut
Chromes Übersetzer verändert niemals den ursprünglichen Textknoten an seinem Standort. Stattdessen erstellt er einen Ersatz, umhüllt diesen Ersatz in ein <font>-Element, fügt dieses Umhüllungselement an die alte Stelle ein und trennt den ursprünglichen Knoten vom aktiven Baum ab.
Der ursprüngliche Knoten bleibt weiterhin zugewiesen. React behält weiterhin die Referenz darauf. Der Knoten ist einfach nicht mehr im Dokument enthalten.
Der strukturelle Austausch erklärt beide Fehlermuster. removeChild wirft einen Fehler aus, weil der Node, den React entfernen will, keinen Elternnode mehr hat. Die Zuweisung von nodeValue verursacht keinen Fehler, ändert aber den Text, der nicht mehr sichtbar ist.
Krachereignisse hinterlassen Stack-Traces. Eine eingefrorene Benutzeroberfläche liefert nichts Nützliches. Ein Zähler, der nicht mehr weiterzählt, wirkt wie ein Zustandsfehler – dort beginnen Teams in der Regel mit den Untersuchungen.
Die Lösung, die jeder kopiert
Shuhei beschrieb den DOM-Konflikt im React-Issue-Tracker im Jahr 2018. Dan Abramov markierte das Problem als nicht lösbar, doch die Workaround-Lösung aus dieser Diskussion bleibt weiterhin die übliche Kopier-und-Einfügen-Lösung. Der Patch umgeht removeChild und insertBefore, sodass sie schweigend zurückkehren, wenn der Node kein Kind des vorgesehenen Elternnodes ist.
Die Krachereignisse verschwinden.
Auch die Live-Updates verschwinden damit.
Das gleiche React-Beispiel wurde während einer Übersetzungssitzung unter drei Konfigurationen verglichen. Wenn der Baum ungeschützt blieb, traten zwei nicht gefangene Ausnahmen auf, die den Wurzelelement sowie die Schaltflächen zerstörten. Durch Installation des eingefügten Schutzes verschwanden alle Fehler; der Zähler fror ein, gelöschte Zeichen blieben auf dem Bildschirm sichtbar, und ein ternärer Ausdruck, der den Zustand änderte, zeichnete beide Zweige gleichzeitig auf.
Sichtbare Fehler werden durch unsichtbare Veraltungen ersetzt.
Messen anstelle von Raten
Forum-Antworten wurden zugunsten direkter Beobachtung beiseitegelegt. Chrome, Edge, Firefox, Yandex sowie Googles eigenständiges Übersetzungstool wurden mit einer Aufnahmeseite verglichen, die jeden Textknoten abfotografierte, pausierte, bis ein Mensch die Übersetzung aktivierte, und anschließend sechzehn Tests sowie fünf Experimente durchführte. Für die Zeitmessungen wurde Playwright gegen eine echte Chrome-Instanz mit einem vorgefertigten Profil eingesetzt.
Erstes konkretes Ergebnis: Der Fehler tritt nicht überall auf.
In Edge und Firefox überschreibt der Engine den Textknoten, ohne ihn zu trennen, wodurch spätere Änderungen bestehen bleiben. Ein Zähler, der ursprünglich einmal pro Sekunde anstieg, erreichte in beiden Browsern den Wert 6. Nutzer dieser Engines kamen weder zum Absturz noch zur Blockade des Programms. Dieser Umstand widerlegt alle, die eine universelle Lösung verkaufen, bleibt aber dennoch wahr – deshalb wird dies sowohl in der README-Datei als auch auf der Demo-Seite erwähnt.
Die Erkenntnis, die die Art des Problems veränderte
Chromes Übersetzer arbeitet mit dem Inhalt, der gerade sichtbar ist, und nicht gleichzeitig mit der gesamten Seite.
Gegenüber einem inaktiven Übersetzer wurden zehn Aktivierungsversuche durchgeführt, zusammen mit einer stillen Kontrollmaßnahme: erzwungene Lesungen des Layouts; fingierte Ereignisse wie Größenanpassung, Fokussierung, Änderung der Sichtbarkeit und Mausbewegungen; Scrollen des Fensters hin und her; sowie echte Ereignisse durch das Scrollrad und den Mauszeiger, die vom Browser selbst erzeugt wurden.
Nur element.scrollIntoView() löste die Übersetzung nach 168 Millisekunden aus. Die verbleibenden neun Versuche blieben ganze zehn Sekunden lang still.
Zwei fehlgeschlagene Versuche waren echte Browser-Ereignisse, was ausschließt, dass „vertrauenswürdige Eingaben“ der einzige Auslöser sind. Auch das Scrollen auf Fensterebene scheiterte. Das zu übersetzende Element muss selbst sichtbar werden.
Wo die erste Schlussfolgerung fehlgeschlagen ist
In dem Entwurf stand eine kühne Behauptung: Nachdem ein abgetrennter Textknoten wiederhergestellt wird, übersetzt Chrome ihn nie wieder. Ein Test schien dies zu bestätigen. Der Test wurde durchgeführt, der Knoten blieb auf Englisch, und der Satz landete in der Dokumentation.
Der Test befand sich jedoch unterhalb des Sichtbereichs.
Der Widerspruch kam erst zum Vorschein, nachdem die Viewport-Regel festgehalten worden war: Beide Aussagen können nicht gleichzeitig zutreffen. Durch Verschieben des Probes in Sichtweite und Wiederholen des Tests zeigte sich, dass Chrome den wiederhergestellten Knoten in 210 Millisekunden reparierte. Außerhalb des Sichtbereichs wurde er niemals repariert, unabhängig von der Wartezeit.
Die Behauptung war bereits seit zwei Tagen falsch – in einem Dokument, dessen These besagt, dass Messungen besser sind als Annahmen.
Zwei weitere Fehler folgten demselben Muster. Ein direkter Vergleich mit einer vorhandenen Bibliothek war beim ersten Versuch sinnlos, weil diese Bibliothek niemals geladen wurde. Ihr Bundle endet mit einem Kommentar //# sourceMappingURL=, und die Zeile, die hinzugefügt wurde, um sie global zugänglich zu machen, landete innerhalb dieses Kommentars. Jeder Test gibt nun an, welche DOM-Methoden jede Variante tatsächlich vor Beginn der Messung repariert hat.
Auch Firefox wurde überbewertet. Drei Nachberechnungen zeigten alle die richtige Zahl an, doch zwei endeten auf Französisch und eine auf Englisch. Unter ständigen Updates kann ein bereits vorhandener Übersetzungsmotor zurückbleiben und kurzzeitig die ursprüngliche Sprache anzeigen.
Sämtliche drei Korrekturen blieben in der Dokumentation sichtbar, zusammen mit dem, was sie ersetzt hat. Die Versteckung von Änderungen würde die Leser dazu zwingen, unüberprüften Abschnitten zu vertrauen.
Was eine Korrektur den Leser kostet
Sobald ein Knoten aus dem Baum entfernt wird, gibt es zwei Möglichkeiten zur Wiederherstellung. Entweder wird der ursprüngliche Knoten wiederhergestellt und man wartet darauf, dass der Übersetzer es bemerkt und erneut übersetzt – oder der neue Wert wird in den bereits von dem Übersetzer eingefügten Wrapper eingeschoben.
Die meisten bestehenden Bibliotheken wählen die Wiederherstellung. Funktional funktioniert dies zwar, doch der Leser trägt einen sichtbaren Aufwand. Bei fünf Wiederholungen von vier Updates, wobei alle 50 Millisekunden ein sichtbarer Text abgerufen wurde:
Beim Wiederherstellen wurden die Zeichenfolgen in der Quellsprache bei jeder Aktualisierung für 100–150 ms angezeigt, und in einer vierstufigen Sequenz insgesamt für 500–600 ms. Beim Schreiben in den Wrapper wurde der Text in der Quellsprache bei jeder der zwanzig Aktualisierungen sofort angezeigt, also nach 0 ms.
Ein einzelner Blitz ist leicht zu übersehen. Ein Live-Zähler zeigt diesen Blitz bei jedem Takt an.
In einem früheren Entwurf wurden 150–200 ms pro Aktualisierung sowie 700 ms für die gesamte Sequenz angegeben. Diese Werte stammten von nur einem Durchlauf und hielten bei fünf Wiederholungen nicht stand. Der veröffentlichte Bericht behält daher die Ergebnisse aller fünf Durchläufe bei – ein einzelnes Zeitmessungsbeispiel stellt kein Faktum dar.
Wo der bessere Ansatz nicht mehr funktioniert
Das Einfügen einer neuen Ziffer in einen bereits übersetzten Satz funktioniert im Niederländischen. Im Russischen kann dies die Grammatik stören.
Intl.PluralRules('ru') klassifiziert 4 als few und 7 als many, wobei die Substantivendungen dieser Kategorie folgen. Ein russischer Satz, der für vier Glühbirnen das few-Endung verwendet, darf dieses Endung nachdem die Anzahl auf sieben steigt, nicht beibehalten. Eine frühe Version verursachte genau diese stille Fehlkorrektur in einer Sprache, die der Implementierer nicht lesen kann.
Die aktuelle Logik lehnt ab, sobald sich die Pluralkategorie, die Länge der Ziffern oder die Satzstruktur ändert oder wenn die Lokalisierung nicht erkannt wird. Wenn die heuristische Methode versagt, zeigt die Benutzeroberfläche die genaue Anzahl in der unübersetzten Sprache an. Dieser Rückfall ist beabsichtigt: Eine richtige Zahl auf Englisch ist sicherer als eine falsche Flexion in einer Sprache, die niemand im Team korrekt lesen kann.
In Niederländisch und Deutsch gibt Intl.PluralRules für jede ganze Zahl den Wert other zurück, sodass die Falle der Pluralkategorie beim Testen ausschließlich mit diesen Lokalisierungen niemals auftritt.
Was noch nicht gemessen wurde
Die Zeile für Safari ist absichtlich leer. WebKit in Playwright enthält keinen Übersetzer, und seit 2012 gibt es keine Windows-Version von Safari mehr, weshalb eine automatische Abdeckung nicht möglich ist. Das Sammeln von Daten erfordert einen physischen Mac sowie eine Person, die manuell die Übersetzungsanfrage ablehnen kann. Das Erraten eines Ergebnisses wäre schlimmer, als die Zelle leer zu lassen.
Wenn eine Aufzeichnung niemals eine aktive Übersetzung feststellt, wird die Bewertung als null statt als false gespeichert. Dieser Unterschied verhindert, dass spätere Leser eine ruhige Sitzung als Beweis dafür ansehen, dass ein Übersetzungsmotor schädlich ist.
Die Bibliothek
Das dazugehörige Paket ist translate-shield auf npm. Wenn Chrome einen Textknoten durch eine <font>-Umhüllung ersetzt, protokolliert die Bibliothek diese Beziehung und leitet spätere React-Eingaben in die Umhüllung um, sodass der Bildschirm weiterhin in der vom Besucher gewählten Sprache angezeigt wird. Es gibt keine Laufzeitabhängigkeiten und die komprimierte Größe beträgt etwa 15 kB. Edge und Firefox erhalten einen leeren Verhaltenspfad, da diese Browser den Knoten von vornherein nicht trennen.
Das Paket übersetzt weder Zeichenketten noch ersetzt es eine i18n-Bibliothek.
Eine interaktive Demo platziert ein abgeschirmtes Dokument neben einem ungeschützten Pendant, während der Browser des Besuchers beides übersetzt. Getrennte Dokumente sind erforderlich: Der Patch wirkt sich auf das gesamte Dokument aus, sodass eine Seite keinen separaten Kontrollbereich beherbergen kann.
Jede hier zitierte Statistik wird durch eine JSON-Datei gestützt, die durch einen erneut ausführbaren Test im Repository generiert wurde – einschließlich Messwerten, die nach früheren Fehlern überarbeitet wurden.
Weiterführende Literatur
Interaktive Vergleichsplattform: https://google-translate-simulation.netlify.app/
Repository mit Rohaufnahmen der Tests: https://github.com/alievdavlat/translate-shield
Veröffentlichte Paketseite: https://www.npmjs.com/package/translate-shield