Главная / Статьи / Баг в React, который появляется только тогда, когда читатели переводят вашу страницу

Баг в React, который появляется только тогда, когда читатели переводят вашу страницу

Перевод страницы в Chrome отделяет текстовые узлы React, что приводит к сбоям при вызове метода removeChild или к молчаливой заморозке интерфейса. Измерения в разных браузерах показывают, когда исправление проблемы области отображения помогает, а когда безопаснее работать с обертками переводчика.

1432 слов

Первая проблема казалась тривиальной. Контроллер React отображал устаревшее значение, в то время как все соседние элементы управления по-прежнему реагировали. Состояние приложения содержало правильное значение; отображаемая строка — нет. Для этого посетителя был включен встроенный переводчик Chrome.

Через неделю тот же продукт начал выдавать ошибку NotFoundError: Failed to execute 'removeChild' on 'Node' и разрушать собственный корневой узел. Тот же механизм, но более серьезная ошибка.

Что делает переводчик

Переводчик Chrome никогда не изменяет ваш узел текста на месте. Он создает замену, оборачивает эту замену в элемент <font>, вставляет получившийся блок на старое место и отсоединяет оригинальный узел от активной структуры документа.

Оригинальный узел остается выделенным в памяти. React по-прежнему хранит на него ссылку. Узел просто отсутствует в документе.

Это изменение структуры объясняет оба способа возникновения сбоев. Метод removeChild вызывает исключение, потому что у узла, который React хочет удалить, больше нет родителя. Присвоение значения nodeValue не вызывает исключений, но изменяет текст, который больше не виден.

При сбоях остаются следы стека вызовов. При замороженном интерфейсе не остается ничего полезного. Счётчик, который перестаёт увеличиваться, кажется ошибкой состояния, и именно с этого обычно начинают поиски причины.

Решение, которое все копируют

В 2018 году шухэй описал конфликт в DOM в трекере проблем React. Дэн Абрамов отметил, что эту проблему невозможно исправить, однако решение, найденное в ходе обсуждения, по-прежнему остаётся стандартным ответом в виде копирования-вставки. Патч переключает работу методов 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 кБ. Edge и Firefox получают путь поведения с пустыми настройками, поскольку эти движки изначально никогда не отделяют узел.

Этот пакет ни переводит строки, ни заменяет собой библиотеку i18n.

Интерактивная демонстрация размещает защищённый документ рядом с незащищённым аналогом, при этом браузер посетителя переводит оба документа. Использование отдельных документов является обязательным: патч применяется ко всему документу, поэтому одна страница не может служить основой для контроля.

Каждая приведённая здесь статистика подкреплена JSON-файлом, сгенерированным в репозитории с помощью теста, который можно запустить заново; включены также показатели, скорректированные после ранее допущенных ошибок.

Дополнительная литература

Интерактивное сравнение: https://google-translate-simulation.netlify.app/

Репозиторий с необработанными записями сенсоров: https://github.com/alievdavlat/translate-shield

Страница пакета в репозитории NPM: https://www.npmjs.com/package/translate-shield