Błąd w React, który pojawia się tylko wtedy, gdy czytelnicy tłumaczą twoją stronę
Tłumaczenie strony w Chrome oddziela węzły tekstu React, co powoduje błędy removeChild lub ciche zamarzanie aplikacji. Pomiary przeprowadzone w różnych przeglądarkach pokazują, kiedy naprawa obszaru widoku pomaga, a kiedy bezpieczniej jest pisać do otoczeń tłumacza.
Bilet numer jeden wydawał się błahy. Licznik w React pokazywał przestarzałą wartość, podczas gdy wszystkie pobliskie elementy sterujące nadal reagowały. Stan aplikacji zawierał prawidłową wartość; natomiast wyświetlana strona nie. Dla tego użytkownika aktywowany był wbudowany translator Chrome.
Miesiąc później ten sam produkt zaczął wywoływać błąd NotFoundError: Failed to execute 'removeChild' on 'Node' i niszczyć swój własny korzeń. Ten sam mechanizm, tylko głośniejszy błąd.
Czym zajmuje się translator
Translator Chrome nigdy nie modyfikuje istniejącego węzła tekstu. Tworzy jego zamiennik, otacza ten zamiennik elementem <font>, wstawia go na dawnej pozycji i odłącza oryginalny węzeł od żywego drzewa.
Oryginalny węzeł pozostaje przydzielony pamięci. React nadal przechowuje do niego referencję. Węzeł po prostu nie występuje już w dokumencie.
To zamiany strukturalne tłumaczą oba sposoby awarii. Funkcja removeChild wywołuje błąd, ponieważ węzeł, który React chce usunąć, nie ma już rodzica. Przypisanie wartości nodeValue nie powoduje żadnego błędu, ale zmienia tekst, który już nie jest widoczny.
Awarie pozostawiają ślady stosu wywołań. Zamarznięta interfejs użytkownika nie dostarcza żadnych użytecznych informacji. Licznik, który przestaje się zwiększać, wygląda jak błąd stanu, i to właśnie od tego zwykle zaczynają poszukiwania zespoły.
Rozwiązanie, które wszyscy kopiują
Shuhei opisał konflikt w DOM na platformie do śledzenia problemów w React w 2018 roku. Dan Abramov oznaczył ten problem jako nierozwiązywalny, mimo to rozwiązanie proponowane w tamtej dyskusji pozostaje standardową odpowiedzią polegającą na kopiowaniu i wklejaniu. Patch obejmuje funkcje removeChild i insertBefore, sprawiając, że spokojnie zwracają wartości w momencie, gdy węzeł nie jest dzieckiem zamierzonego rodzica.
Awarie znikają.
Rozwiązania w czasie rzeczywistym również znikają razem z nimi.
To samo przykładowe rozwiązanie React zostało porównane w trzech konfiguracjach podczas jednej sesji tłumaczenia. Pozostawienie drzewa bez ochrony spowodowało pojawienie się dwóch niezłapanych wyjątków, które zniszczyły całą strukturę, w tym przyciski. Zainstalowanie dodanego zabezpieczenia usunęło wszystkie błędy; licznik zamarł, usunięte ciągi znaków pozostały na ekranie, a operacja ternarna zmieniająca stan wyświetlała obie gałęzie jednocześnie.
Widoczna awaria jest zamieniana na niewidoczną stagnację.
Pomiar zamiast domysłów
Odpowiedzi z forów zostały odrzucone na rzecz bezpośredniej obserwacji. Chrome, Edge, Firefox, Yandex oraz samodzielny widget tłumaczeniowy Google zostały porównane z stroną rejestrującą, która robila zrzuty ekranu każdego węzła tekstowego, pauzowała, dopóki osoba nie uruchomiła tłumaczenia, a następnie przeprowadziła szesnaście testów oraz pięć eksperymentów. Pomiar czasu wykonywano za pomocą Playwright na rzeczywistym instancie Chrome z ustalonym profilem.
Pierwszy istotny wynik: błąd nie występuje wszędzie.
W Edge i Firefox silnik przepisuje węzeł tekstu bez jego oddzielenia, więc późniejsze modyfikacje pozostają. Licznik zliczający wydarzenia raz na sekundę osiągnął wartość 6 w obu przeglądarkach. Użytkownicy tych silników nigdy nie trafili na ścieżkę awarii ani na ścieżkę zamarznięcia aplikacji. Ten fakt podważa twierdzenia tych, którzy oferują uniwersalne rozwiązanie, ale pozostaje on prawdziwy, dlatego zarówno README, jak i strona demonstracyjna o tym wspominają.
Odkrycie, które zmieniło charakter problemu
Przetłumaczacz w Chrome działa na tym, co jest aktualnie widoczne, a nie na całej stronie jednocześnie.
Przeciwko nieaktywnemu przetłumaczaczowi wysłano dziesięć prób aktywacji, wraz z cichym testem: wymuszone odczyty układu; fałszywe zdarzenia zmiany rozmiaru, skupienia uwagi, widoczności i ruchu myszy; przewijanie okna w górę i w dół; a także autentyczne zdarzenia związane z kółkiem myszy i wskaźnikiem emitowane przez sam przeglądarkę.
Tylko element.scrollIntoView() wywołało tłumaczenie po 168 milisekundach. Pozostałe dziewięć prób pozostało ciche przez pełne dziesięć sekund.
Dwie nieudane próby były rzeczywistymi zdarzeniami przeglądarki, co wyklucza „zaufane dane wejściowe” jako jedyny czynnik wyzwalający. Przewijanie na poziomie okna również nie powiodło się. Element, który miał zostać przetłumaczony, musi sam stać się widoczny.
Gdzie pierwszy wniosek się mylił
W projekcie pojawiło się śmiałe twierdzenie: po przywróceniu oddzielonego węzła tekstu Chrome już nigdy go nie tłumaczy. Wydawało się, że badanie to potwierdzi. Badanie zostało przeprowadzone, węzeł pozostał po angielsku, a zdanie trafiło do dokumentacji.
To badanie znajdowało się poniżej granicy widoczności.
Sprzeczność ujawniła się dopiero po zapisaniu reguły dotyczącej obszaru widzenia: obie tezy nie mogą istnieć jednocześnie. Przeniesienie sondy do pola widzenia i powtórzenie testu pokazało, że Chrome naprawił przywrócony węzeł w ciągu 210 milisekund. Poza polem widzenia nigdy go nie naprawiał, bez względu na czas oczekiwania.
To twierdzenie było błędne przez dwa dni w dokumencie, którego tezą jest to, że pomiary są lepsze od założeń.
Kolejne dwa błędy miały ten sam schemat. Porównanie z istniejącą biblioteką nie miało sensu już przy pierwszej próbie, ponieważ ta biblioteka nigdy się nie załadowała. Jej plik kończył się komentarzem //# sourceMappingURL=, a linia dodana w celu jej globalnego dostępu znalazła się wewnątrz tego komentarza. Każdy test teraz pokazuje, które metody DOM faktycznie zostały naprawione przez każdą z części przed rozpoczęciem pomiarów.
Firefox również został przeceniony. W trzech próbach korekcyjnych wyniki pokazały właściwą liczbę, jednak dwa zakończyły się w języku francuskim, a jedno w języku angielskim. Pod wpływem ciągłych aktualizacji silnik mogący działać bez zmian może zwalniać i tymczasowo pokazywać oryginalny język.
Wszystkie trzy korekty pozostały widoczne w raporcie, razem z tym, co je zastąpiło. Ukrywanie zmian sprawiłoby, że czytelnicy musieliby ufać niesprawdzonej treści.
Ile kosztuje naprawa dla czytelnika
Gdy węzeł znika z drzewa, istnieją dwa sposoby przywrócenia sytuacji. Można przywrócić oryginalny węzeł i czekać, aż tłumacz to zauważy i przetłumaczy ponownie. Albo można wprowadzić nową wartość do elementu, który tłumacz już wstawił.
Większość istniejących bibliotek wybiera pierwszą opcję. Funkcjonalnie działa to skutecznie, ale czytelnik ponosi widoczną stratę. W pięciu próbach z czterema aktualizacjami, przy pobieraniu widocznego tekstu co 50 milisekund:
Proces przywracania pokazywał ciągi w języku źródłowym przez 100–150 ms przy każdej aktualizacji oraz 500–600 ms w całej sekwencji składającej się z czterech kroków. Zapisywanie do obudowy dawało tekst w języku źródłowym przez 0 ms przy każdej z dwudziestu aktualizacji.
Jednorazowy błysk jest łatwy do zignorowania. Licznik w czasie rzeczywistym pokazuje ten błysk przy każdym odliczeniu.
Wcześniejszy projekt podawał 150–200 ms na aktualizację oraz 700 ms dla całej sekwencji. Te wartości pochodziły z jednego pomiaru i nie przetrwały pięciu powtórzeń. Dlatego opublikowany raport zachowuje pełny zestaw z pięciu pomiarów – pojedynczy przykład czasowy nie stanowi faktu.
Gdzie lepszy podejście przestaje działać
Dodawanie nowej cyfry do już przetłumaczonego zdania działa w języku holenderskim. W języku rosyjskim może to zepsuć gramatykę.
Intl.PluralRules('ru') klasyfikuje liczbę 4 jako mało, a 7 jako wiele, a końcówki rzeczowników odpowiadają tej kategorii. Rosyjskie zdanie używające końcówki mało dla czterech żarówek nie może zachować tej końcówki, gdy liczba wzrośnie do siedmiu. W wczesnej wersji programu wystąpiła dokładnie taka niezauważalna błądność w języku, którego autor nie potrafił odczytać.
Obecna logika odrzuca taką możliwość za każdym razem, gdy zmienia się kategoria liczby mnogiej, długość cyfry lub struktura zdania, albo gdy lokalizacja nie jest rozpoznana. Gdy heurystyka zawodzi, interfejs pokazuje dokładną liczbę w języku nietłumaczonym. Ta decyzja o obniżeniu jakości jest celowa – poprawna liczba w języku angielskim jest bezpieczniejsza niż błędna forma gramatyczna w języku, którego nikt z zespołu nie potrafi sprawdzić.
W języku niderlandzkim i niemieckim Intl.PluralRules zwraca wartość other dla każdej liczby całkowitej, więc pułapka związana z kategorią liczb mnogich nigdy nie występuje podczas testowania wyłącznie tych lokalizacji.
Co pozostaje niewymierzone
Wiersz dotyczący Safari jest celowo pusty. WebKit w Playwright nie zawiera translatora, a żadna wersja Safari dla systemu Windows nie została wydana od 2012 roku, więc automatyczne pomiarowanie nie jest możliwe. Zbieranie danych wymaga fizycznego komputera Mac oraz osoby, która może ręcznie odrzucić prośbę o tłumaczenie. Odgadywanie wyniku byłoby gorsze niż pozostawienie pola pustego.
Gdy nagranie w ogóle nie odnotowuje aktywnego tłumaczenia, wynik jest przechowywany jako null zamiast false. Ta różnica uniemożliwia późniejszym czytelnikom traktowanie spokojnej sesji jako dowodu na to, że silnik jest nieszkodliwy.
Biblioteka
Pakiet towarzyszący to translate-shield w npm. Gdy Chrome zastępuje węzeł tekstu obwódką <font>, biblioteka rejestruje tę relację i kieruje późniejsze zapisy React do tej obwódki, dzięki czemu ekran pozostaje w języku wybranym przez odwiedzającego. Nie ma zależności w czasie wykonywania, a rozmiar spakowanego pliku wynosi około 15 kB. Edge i Firefox otrzymują pusty ścieżkę zachowania, ponieważ te silniki od początku nie oddzielają takiego węzła.
Pakiet ani nie tłumaczy ciągów znaków, ani nie zastępuje biblioteki i18n.
Interaktywna demonstracja umieszcza zabezpieczony dokument obok niezabezpieczonego odpowiednika, przy czym przeglądarka odwiedzającego tłumaczy oba. Konieczne są oddzielne dokumenty: patch ma zastosowanie do całego dokumentu, więc jedna strona nie może pełnić funkcji elementu kontrolnego.
Każda przytoczona tutaj statystyka ma swoje potwierdzenie w pliku JSON utworzonym przez test możliwy do ponownego uruchomienia w repozytorium, włączając pomiary skorygowane po wcześniejszych błędach.
Dalsza lektura
Interaktywne porównanie: https://google-translate-simulation.netlify.app/
Repozytorium z surowymi nagraniami sond: https://github.com/alievdavlat/translate-shield
Strona pakietu na NPM: https://www.npmjs.com/package/translate-shield