Баг у React, який з’являється лише тоді, коли читачі перекладають вашу сторінку
Переклад сторінки Chrome відокремлює текстові вузли React, що спричиняє збої через метод removeChild або беззвучну заморозку програми. Дослідження в різних браузерах показують, коли корекція вікна перегляду допомагає, а коли безпечніше записувати дані у обгортки перекладача.
Перший випадок здавався тривіальним. Контролер у React відображав застаріле значення, тоді як усі сусідні елементи керування все ще реагували. Стан додатку містив правильне значення; проте відображена стрінг не відповідала йому. Для цього відвідувача був увімкнений вбудований перекладач Chrome.
Через тиждень той самий продукт почав викидати помилку NotFoundError: Failed to execute 'removeChild' on 'Node' та руйнувати власний кореневий елемент. Той самий механізм, але більш серйозна помилка.
Що робить перекладач
Перекладач Chrome ніколи не змінює ваш вузол тексту на місці. Він створює замінник, обгортає цей замінник елементом <font>, вставляє цей обгортку на старе місце та від’єднує ваш оригінальний вузол від активної структури.
Оригінальний вузол залишається виділеним у пам’яті. React все ще зберігає до нього посилання. Просто вузол відсутній у документі.
Ця заміна структури пояснює обидва способи збою. Функція removeChild викидає помилку, тому що вузол, який React хоче видалити, більше не має батька. Присвоєння значення nodeValue не викликає помилок, проте змінює текст, який більше не є видимим.
Падіння програми залишають сліди стеку. Заморожений інтерфейс не залишає нічого корисного. Лічильник, який перестає збільшуватися, виглядає як баг стану, і саме з цього команди зазвичай починають розслідування.
Рішення, яке всі копіюють
У 2018 році shuhei описав конфлікт у DOM у трекері проблем React. Dan Abramov позначив цю проблему як нерозв’язну, проте рішення, запропоноване під час обговорень, залишається стандартною відповіддю у вигляді копіювання-вставки. Цей патч перериває роботу функцій removeChild та insertBefore, щоб вони тихо поверталися, коли вузол не є дитиною потрібного батька.
Падіння програми зникають.
Живі оновлення також зникають разом із ними.
Ту саму зразкову програму React порівнювали у трьох конфігураціях під час однієї сесії перекладу. Якщо не захищати структуру дерева, це призводило до двох неконтрольованих винятків, які руйнували всю структуру, включаючи кнопки. Встановлення доданого захисного механізму усунуло всі помилки; лічильник застиг, видалені рядки залишилися на екрані, а оператор типу „тернарний“ одночасно відображав обидві гілки стану.
Видима невдача замінюється невидимою застарілістю.
Вимірювання замість припущень
Відповіді з форуму були відкладені на потім заради прямого спостереження. Chrome, Edge, Firefox, Yandex та окремий віджет перекладу від Google порівнювали з сторінкою-реєстратором, яка фіксувала кожен текстовий вузол, зупинялась до тих пір, поки людина не увімкнула переклад, а потім виконувала шістнадцять тестів та п’ять експериментів. Для вимірювання часу використовувався Playwright проти справжньої інстанції Chrome з заданим профілем.
Перший важливий результат: ця помилка присутня не скрізь.
У Edge та Firefox движок переписує вузол тексту, не від’єднуючи його, тому подальші зміни залишаються. Лічильник, який мав збільшуватися один раз на секунду, у обох браузерах досяг значення 6. Користувачі цих браузерів ніколи не потрапляли ані на шлях збою, ані на шлях зависання. Цей факт спростовує тих, хто пропонує універсальне рішення, проте він залишається правдивим, тому й у README, і на сторінці демонстрації про це зазначено.
Висновок, який змінив характер проблеми
Механізм перекладу в Chrome працює з тим, що наразі видно, а не з усією сторінкою одночасно.
Проти неактивного механізму перекладу було надіслано десять спроб активації, а також проведено беззвучні тести: примусове читання макету; штучні події зміни розміру, фокусу, видимості та руху миші; прокручування вікна вперед та назад; крім того, була імітована справжня діяльність колеса миші та вказівника, яку генерує сам браузер.
Лише element.scrollIntoView() спричинив переклад через 168 мілісекунд. Решта дев’ять спроб залишалися без змін протягом цілих десяти секунд.
Дві невдалих спроби були справжніми подіями браузера, що виключає можливість того, що „надійний вхідний даний“ є єдиним тригером. Скроллинг на рівні вікна також зазнав невдачі. Елемент, який має бути перекладений, повинен стати видимим сам по собі.
Де провалилась перша висновок
У проєкті з’явилась смілива твердження: після відновлення відокремленого текстового вузла Chrome більше ніколи його не перекладає. Здавалося, що дослідження підтвердить це. Дослідження було проведено, вузол залишився англійським, а речення потрапило до документації.
Це дослідження залишилося поза увагою.
Суперечність проявилась лише після того, як було сформульовано правило вікна перегляду: обидві твердження не можуть існувати одночасно. Переміщення зонда у поле зору та повторна робота показали, що Chrome відновлює пошкоджений елемент протягом 210 мілісекунд. Поза полем зору він ніколи не відновлював його, незалежно від часу очікування.
Це твердження було хибним протягом двох днів у документі, теза якого полягає у тому, що вимірювання краще за припущення.
Ще дві помилки виникли за тим самим сценарієм. Порівняння з існуючою бібліотекою під час першого запуску не мало сенсу, оскільки ця бібліотека ніколи не завантажувалась. Її пакет закінчується коментарем //# sourceMappingURL=, а рядок, доданий для її глобального доступу, опинився всередині цього коментаря. Тепер кожен запуск визначає, які методи DOM насправді були виправлені кожною з версій перед початком вимірювання.
Firefox також було переоцінено. Усі три спроби перевірки показали правильну кількість, проте дві завершилися французькою мовою, а одна — англійською. Через постійні оновлення двигун може відставати та тимчасово відображати первісну мову.
Усі три корекції залишалися видимими у звіті разом із тим, що їх замінило. Приховування змін змусило б читачів довіряти неперевіреним розділам.
Скільки коштує виправлення для читача
Як тільки вузол зникає з дерева, його відновлення може відбуватися двома способами: або відновити первісний вузол та чекати, поки перекладач це помітить та знову перекладе, або ввести нове значення у обгортку, яку перекладач вже додав.
Більшість існуючих бібліотек обирають варіант відновлення. Функціонально це ефективно, проте читач несе видимі втрати. У п’яти повтореннях чотирьох оновлень, при цьому текст перевірявся кожні 50 мілісекунд:
При відновленні даних спостерігалося відображення рядків мовою оригіналу протягом 100–150 мс під час кожної оновлення та 500–600 мс протягом чотирикрокової послідовності. При запису даних у обгортку текст мовою оригіналу відображався протягом 0 мс під час кожного з двадцяти оновлень.
Одиночний спалах легко проігнорувати. Живий лічильник відображає цей спалах після кожного кроку.
У попередньому проєкті зазначалося 150–200 мс на кожне оновлення та 700 мс для всієї послідовності. Ці цифри були отримані під час одного запуску та не витримали перевірки у п’яти повторних експериментах. Тому опублікований звіт містить дані з усіх п’яти запусків: одинокий зразок часу не є достовірним даним.
Де кращий підхід перестає ефективним
Додавання нової цифри до вже перекладеного речення працює ефективно у нідерландській мові. У російській це може порушити граматику.
Intl.PluralRules('ru') класифікує 4 як небагато, а 7 — як багато, і закінчення іменників відповідають цій категорії. Російське речення, у якому для чотирьох ламп використовується закінчення небагато, не повинно зберігати це закінчення після того, як кількість стане сімома. У ранній версії програми саме так виникала прихована пошкодженість у мові, яку розробник не міг прочитати.
Текуща логіка відмовляється виконувати завдання щоразу, коли змінюється категорія множини, довжина цифри чи структура речення, або коли локалізація не впізнається. Коли цей метод не допомагає, інтерфейс відображає точну кількість мовою без перекладу. Таке зниження якості є навмисним: правильна кількість англійською є безпечнішою, ніж неправильне закінчення в мові, яку ніхто у команді не може перевірити.
У нідерландській та німецькій мовах Intl.PluralRules повертає other для кожного цілого числа, тому пастка з категорією множини ніколи не виникає під час тестування лише з цими локалізаціями.
Що залишається незаміреним
Строка Safari навмисно порожня. WebKit у Playwright не містить перекладача, а версії Safari для Windows не випускалися з 2012 року, тому автоматизоване тестування неможливе. Збір даних вимагає фізичного Mac та людини, яка може вручну проігнорувати запит на переклад. Вгадування результату буде гіршим, ніж залишити клітинку порожньою.
Коли запис ніколи не фіксує активного перекладу, значення зберігається як null замість false. Це розрізнення заважає майбутнім користувачам вважати бездіяльну сесію доказом того, що двигун нешкідливий.
Бібліотека
Супровідний пакет — це translate-shield у npm. Коли Chrome замінює вузол тексту на обгортку <font>, бібліотека фіксує цю залежність та перенаправляє подальші записи React у цю обгортку, щоб екран залишався мовою, обраною відвідувачем. Не існує залежностей під час виконання, а розмір пакету становить близько 15 kB. Edge та Firefox отримують порожній шлях поведінки, оскільки ці браузери з самого початку не відокремлюють цей вузол.
Цей пакет не перекладає рядки та не є заміною бібліотеки i18n.
Інтерактивна демонстрація розміщує захищений документ поруч із незахищеним аналогом, причому браузер відвідувача перекладає обидва. Використання окремих документів є обов’язковим: патч застосовується до всього документа, тому одна сторінка не може слугувати окремим елементом керування.
Кожна наведена тут статистика підтверджується файлом JSON, створеним за допомогою тесту, який можна запустити знову у репозиторії, включаючи показники, які були скориговані після попередніх помилок.
Додаткова література
Інтерактивне порівняння: https://google-translate-simulation.netlify.app/
Репозиторій із необробленими записами зондування: https://github.com/alievdavlat/translate-shield
Сторінка опублікованого пакету: https://www.npmjs.com/package/translate-shield