Отображение огромных запросов на слияние (pull requests) в приложении GitHub Copilot
- Команда GitHub Copilot переработала представление пулл-реквестов для быстрого рендеринга огромных diff и сопутствующих обсуждений.
- Систему протестировали на пулл-реквесте из 2200 файлов, более миллиона измененных строк и свыше 400 встроенных комментариев.
- Высоту документа разделили на детерминированный код и динамические блоки, ограничив измерение комментариев радиусом 2400 пикселей от экрана.
Почему это важно: Масштабные изменения невозможно разделить на мелкие части, поэтому интерфейс ревью должен сохранять отзывчивость при любых объемах кода и обсуждений.
Масштабные рефакторинги и миграции часто приходится выпускать в рамках одного изменения.
Стекнутые пулл-реквесты — отличный способ разделить работу на более мелкие изменения, что упрощает ревью и помогает командам выпускать код с меньшими рисками. Но некоторые изменения, подобные этому, невозможно аккуратно разделить. В результате у вас остается единственный пулл-реквист, который может сильно разрастись, а дискуссии в ходе ревью делают его еще больше.
Интерфейс ревью должен оставаться быстрым и плавным, даже когда diff и сопутствующие обсуждения достигают огромных размеров. В приложении GitHub Copilot мы полностью переработали представление пулл-реквеста с учетом этого требования.
Чтобы проверить границы возможностей, мы открыли самый большой пулл-реквест, который смогли найти: проект с открытым исходным кодом, содержащий 2 200 файлов, более миллиона измененных строк и свыше 400 встроенных комментариев к ревью. Рассказываем, как мы добились высокой производительности даже в этом экстремальном случае.
Масштаб проблемы
Рендеринг большого diff на высокой скорости — задача понятная: виртуализируйте строки, поддерживайте минимальный объем смонтированного DOM и полагайтесь на тот факт, что каждая строка представляет собой строку кода известной высоты.
Самое сложное — это комментарии. Высота комментария зависит от того, как переносится текст в формате Markdown, от раскрывающихся секций, наличия поля для ответа и от того, загрузились ли в нем изображения. Все это выясняется только в момент рендеринга. Это вынуждает использовать другую архитектуру.
Три проблемы:
- Измерение. Вы не можете узнать высоту комментария, пока не отрендерите его. Это разрушает подход, который позволяет большим diff оставаться отзывчивыми при прокрутке.
- Конвейер данных. Быстрый интерфейс diff бесполезен, если питающий его конвейер данных зависает или отбрасывает уже проделанную работу.
- Как мы на самом деле находили баги. Эти проблемы проявляются под нагрузкой, на определенном движке и в конкретной позиции прокрутки. Поэтому мы определили, что значит «здоровое» состояние, замерили показатели интерфейса и запустили весь цикл «изменение → измерение → улучшение» в автоматическом режиме.
Первый шаг — понять геометрию, которая делает diff, состоящий только из кода, быстрым. Как только в дело вступают комментарии, этой геометрии становится недостаточно.
Что делает большие diff быстрыми
Нельзя поместить миллион узлов DOM на страницу. Стандартное решение — виртуализация: монтируйте только те строки, которые находятся на экране, плюс небольшой запас, и переиспользуйте те же элементы DOM по мере прокрутки пользователем. Список ведет себя так, будто все миллион строк существуют одновременно. Полоса прокрутки имеет правильный размер, переход к строке работает. Но реально на экране одновременно находится всего около 100 строк.
Чтобы эта иллюзия сохранялась, кто-то должен предоставлять геометрию. Высота полосы прокрутки — это сумма высот всех строк. Позиция строки N — это сумма высот строк, расположенных выше. Переход к строке, отрисовка полосы прокрутки, определение того, что находится на экране — все это арифметика над таблицей высот. Эту таблицу можно построить на основе оценок и корректировать по мере измерения строк, и универсальные виртуализаторы переменной высоты делают именно это.
Но если каждая строка представляет собой строку кода с известным размером шрифта, в этом нет необходимости. Вы можете вычислить всю таблицу заранее, и она никогда не изменится, поэтому корректировать ее в дальнейшем не нужно.
Назовем это контрактом «все высоты известны до отрисовки». Наш интерфейс diff построен вокруг него:
- Императивный повторно используемый рендерер строк кода (без компонентов React на каждую строку)
- Геометрия на основе типизированных массивов для вычислений смещений
- Управляемые бэкендом документы diff, передаваемые с приоритетом структуры
- Императивный API прокрутки с точным переходом к строке N
Ничто из этого не масштабируется плохо, поскольку объем работы на каждый кадр не растет вместе с общим количеством строк. Для чистого кода этот подход является правильным, и мы сохранили его полностью.
А теперь поместите ветку обсуждения (ревью) в середину diff. Какова ее высота?
Вы не знаете этого и не можете узнать без рендеринга. Высота зависит от вещей, которые существуют только в момент рендеринга, и они могут продолжать изменяться после первой отрисовки:
- Разметка Markdown, которая переносится по-разному при разной ширине экрана
- Блоки , которые пользователь может разворачивать или сворачивать на месте
- Форма ответа, которая открывается внутри существующей ветки и увеличивается по мере ввода текста
- Diff с предложенными изменениями, реакции, режим редактирования, баннеры разрешения
- Изображения и асинхронные ресурсы, которые меняют высоту после завершения загрузки
Очевидный ответ — зарезервировать слот фиксированной высоты для каждого комментария, размер которого определяется оценщиком. Это рушится на большом пулл-реквесте. Оценщик, который верен в среднем, все равно ошибается в крайних случаях. Он выделяет слишком много места для большинства комментариев, оставляя пробелы пустого пространства, и выделяет слишком мало для ресурсоемких, что приводит к обрезке текста или появлению вложенной полосы прокрутки. Если измерить реальную высоту после отрисовки и записать ее обратно в общую таблицу смещений, все элементы ниже сместятся в то время, когда пользователь уже прокручивает страницу. Это называется скачком прокрутки, и на большом пулл-реквесте он оказывается значительным.
Таким образом, комментарии требуют другого контракта. Контракт «все высоты известны до отрисовки» недостижим для такого контента. Что мы могли пообещать взамен: высоты ограничены, измеряются лениво, а исправления незначительны и привязаны к тому, на что смотрит пользователь.
Две геометрии вместо одной
Идея, которая сделала эту задачу решаемой, заключалась в том, чтобы перестать заставлять одну геометрию обслуживать оба типа контента. Мы разделили высоту документа на два независимых домена:
total height = deterministic code height (exact, known up front)
+ Σ dynamic block effective heights (estimated, then measured)
+ scroll padding
Геометрия кода сохраняет первоначальный подход. Она детерминирована, вычисляется через префиксные суммы, точна и никогда не перестраивается при изменении размера комментария.
Геометрия динамических блоков охватывает всё, высоту чего мы не можем предсказать: ветки ревью, черновики и формы ответа. Каждый такой элемент представляет собой блок, идентифицируемый по своей сути, а не по текущему положению. У него есть стабильный ключ, который сохраняется при загрузке контента, и он привязан к файлу, строке и стороне, а не к координате в пикселях, поэтому перекомпоновка не приведет к его потере. Мы также сохраняем слепок (fingerprint) всего, что может изменить высоту блока: его содержимое, открыт ли блок, активна ли форма ввода. Кроме того, мы записываем ширину, при которой блок измерялся в последний раз (с округлением до диапазонов), чтобы обычное изменение размера окна не делало недействительным каждое измерение в документе.
Эффективная высота блока тогда проста: измеренная высота, если у нас есть актуальная, кэшированная высота, если слепок и ширина по-прежнему совпадают, и оценка в противном случае. Эти высоты хранятся в собственном индексе, отдельно от строк кода, поэтому изменяющий размер комментарий никогда не заставляет перестраивать геометрию кода. При этом количество блоков ограничивается количеством комментариев, а не строк. Несколько тысяч блоков — это нормально, если первая отрисовка не пытается смонтировать и измерить их все одновременно.
Планировщик измерений и наша первая ошибка
Эта часть потребовала больше всего времени для правильной реализации, потому что наш первый вариант архитектуры содержал поучительную ошибку.
Очевидный способ измерения динамического контента — использовать один ResizeObserver на каждый блок, который отслеживает элемент и записывает его измеренную высоту обратно в макет при каждом изменении. Именно это мы спроектировали, а затем отвергли в ходе оптимизации производительности. Это цикл обратной связи, которого должны избегать большие виртуазированные поверхности. Наблюдатель, который записывает высоту обратно в разметку отслеживаемого им элемента, может вызывать срабатывание самого себя, и затраты растут с каждым смонтированным блоком.
Вместо этого было внедрено единое проходное измерение, управляемое состоянием простоя (idle) и прокрутки, подчиняющееся той же дисциплине, что и детерминированная сторона:
- Вне критического пути. Оно запускается, когда видимый диапазон стабилизируется, никогда не выполняется по разу на каждый кадр прокрутки и полностью ждет во время активной прокрутки. Изменение макета в середине прокрутки — это как раз те рывки, которых мы избегаем. Измерения запускаются снова, как только прокрутка останавливается.
- Ограничено видимой областью (viewport). Кандидатами являются только блоки, находящиеся в пределах примерно 2400 пикселей от области просмотра, поэтому объем работы составляет O (viewport). Удаленные блоки продолжают использовать свои оценки и корректируются по мере приближения.
- Результаты на экране побеждают. Смонтированный блок находится на экране, поэтому его отрендеренная высота является абсолютной истиной. Проход считывает каждый смонтированный кандидат за один пакет — единый пересчет макета без промежуточных записей — и фиксирует результаты. Смонтированный блок никогда не пропускается в пользу устаревшей оценки. Это правило исправило самый неприятный баг, с которым мы столкнулись: комментарии отображались с полосой пустого пространства внизу, потому что смонтированный блок был исключен из измерений и остался висеть со слишком большой оценкой.
- Измерение вне экрана — это ограниченный запасной вариант. Для близлежащего блока, который еще не смонтирован, процесс выполняет не более одного рендеринга вне экрана, чтобы скорректировать его зарезервированный размер до того, как он прокрутится в поле зрения. Блоки, превышающие размер области просмотра, пропускают даже это. Их избыточный резерв скрывается за нижней границей экрана, поэтому платить за этот рендеринг не стоит.
- Остальное обрабатывает наблюдатель (observer). Некоторые изменения высоты не меняют слепок и не совпадают с прокруткой: ввод текста в форме ответа, завершение загрузки изображения, переключение блока. Каждый смонтированный блок сохраняет
ResizeObserver, но по умолчанию все, что он делает, — это помечает блок флагом, чтобы проход простоя пересчитал его. Он никогда не записывает высоту самостоятельно, что и создало бы тот самый нежелательный цикл обратной связи. Наблюдатель отключается при размонтировании, а неактивная вкладка пулл-реквеста ничего не отслеживает. - С одним осознанным исключением. Ожидание выглядело некорректно для изменений размера, вызванных вами: разворачивание, открытие формы ответа, загрузка изображения. Блок увеличивался немедленно, но код под ним смещался только во время следующего простоя. В течение одного кадра комментарий был выше, в то время как всё под ним оставалось на старой позиции, и были заметны два шага анимации. Поэтому, когда блок смонтирован и находится на экране, наблюдатель теперь измеряет его и применяет исправление в том же кадре, до отрисовки. Блок увеличивается, код перепозиционируется, и всё, что ниже, сдвигается вместе. Два предохранителя защищают этот механизм от превращения в нежелательный цикл: не более одного синхронного коммита за кадр (поэтому серия изменений размера схлопывается в одно) и запрет на выполнение во время активной прокрутки, при которой система возвращается к пакетному проходу.
Привязка прокрутки: корректировка без борьбы с пользователем
Когда измеренная высота отличается от оценки, меняется арифметика полосы прокрутки, и наивным результатом становится прыжок области просмотра. Решение состоит в томщ, чтобы производить корректировку по идентификатору, а не по пикселям:
- Перед применением обновлений высоты зафиксируйте элемент, к которому привязан пользователь (строку или блок по идентификатору), а также смещение внутри него.
- Примените дельты высоты.
- Определите новое положение этого же якоря в пикселях.
- Прокрутите страницу так, чтобы якорь остался на своем месте в области просмотра.
Плюс несколько правил, которые делают поведение естественным:
- Блок выше области просмотра меняет высоту → скорректируйте положение на величину дельты (сохраняет ваше место).
- Контент подгружается ниже области просмотра → не корректируйте положение (вы его не видите).
- Если вы переключили или открыли ответ в видимом блоке → отключите корректировку по блокам выше для этого конкретного блока, чтобы взаимодействие ощущалось прямым, и позвольте контенту ниже смещаться естественным образом.
- Никогда не боритесь с активным моментом инерции указателя или колеса; выполняйте пакетную корректировку после завершения кадра.
У последнего правила есть подводный камень, и мы на него наткнулись. Правило «не корректировать, пока пользователь прокручивает страницу» было реализовано как проверка последней зафиксированной прокрутки, но программные прокрутки также обновляли эту временную метку. Переключение боковой панели файлового дерева изменяет ширину панели diff. При включенном переносе строк каждая переносимая строка выше вас пересчитывается в другое количество визуальных строк, всё координатное пространство сдвигается, и интерфейс выполняет небольшую собственную прокрутку по мере стабилизации. Проверка восприняла это как «пользователь только что прокрутил страницу» и пропустила ту самую корректировку, которая должна была сохранить вашу позицию, поэтому файл, который вы читали, уплыл за пределы экрана. Решение состояло в том, чтобы отличать прокрутку пользователя от прокрутки, вызванной самим интерфейсом. Любая проверка «взаимодействует ли пользователь с системой?» не должна удовлетворяться вашими собственными побочными эффектами.
Таким образом, корректировки остаются небольшими, они повторно используют уже имеющиеся у нас измерения и следуют за тем, на что вы смотрите.
Часть 2: Конвейер данных за интерфейсом
Интерфейс diff может быть лишь настолько быстрым, насколько быстрые данные питают его, и три привычки из этой сферы разработки определили возможности пользовательского интерфейса. Первая — передавать структуру раньше контента. Diff запрашивается инкрементально, поэтому файловое дерево и метаданные отображаются, пока документ всё еще загружается, а полный набор веток ревью разрешается заранее, а не подгружается по каплям. Вторая — откладывать работу с отдельными элементами до тех пор, пока она кому-то не понадобится. Подсветка синтаксиса выполняется вне основного потока, поэтому строки сразу появляются как простой текст и раскрашиваются, когда приходят результаты. Подсветка улучшает интерфейс, а не блокирует прокрутку. Большие тела Markdown и контекст предложенных изменений работают точно так же: ничего не создается до тех пор, пока не приблизится к области просмотра.
Третья привычка касается того, какие затраты стоит сохранять. Выгрузка документа diff из памяти при переходе на другую страницу — правильное поведение по умолчанию. Эти документы велики, и удержание в памяти каждого посещенного документа приводит к тому, что за время должной сессии приложение исчерпывает память. Но метаданные пулл-реквеста сохраняются, поэтому оболочка вокруг diff — шапка и файловое дерево — перерисовывается мгновенно при возврате назад, а затем замирает на несколько секунд в ожидании diff, который у нее был буквально секунду назад. Мгновенно отрисованная оболочка вокруг пустого diff выглядит сломанной, даже если в целом вы ждете меньше времени. Поэтому правило было сохранено, и мы добавили кэш: хранить в памяти последние несколько diff, вытеснять всё, что выходит за эти рамки, и позволять фоновому обновлению определять, когда какой-то элемент устарел.
Часть 3: Цикл измерений, или как мы на самом деле находили баги
Почти каждый баг в этом проекте был невидимым до тех пор, пока не проявлялся, а воспроизвести его вручную — настоящее мучение. Типичный отчет звучит так: «под некоторыми комментариями появляется полоса пустого пространства, но только иногда, только на больших пулл-реквестах, и она исчезает, если проскроллить дальше и обратно». Отлаживать такое разглядыванием экрана невозможно, поэтому мы создали инструменты для механической отладки.
Используйте реальные сигналы приложения вместо одноразовых логов
Наивный рабочий процесс заключается в расстановке вызовов console.log, ручном прохождении сценария, копировании вывода, отправке его кому-то (или чему-то), кто может его проанализировать, удалении логов и повторении процесса. Это медленно, требует участия человека в цикле, и хуже всего то, что в итоге вы измеряете собственные кустарные средства инструментирования, а не реальное поведение приложения.
Поэтому интерфейс содержит постоянные, структурированные зонды для проверки собственных инвариантов. Это простые вопросы, на которые компонент отвечает сам о себе при каждом рендеринге:
- Действительно ли интерфейс ограничен областью видимости? Сколько строк и блоков комментариев смонтировано прямо сейчас?
- Выполняется ли объединение измерений в один коммит за кадр, и сколько времени занимает этот кадр?
- Насколько велики корректировки прокрутки, которые мы выполняем?
- Не был ли добавлен какой-либо блок комментариев после начала прокрутки? (После загрузки топологии бэкенда это число должно быть равно нулю.)
- Действительно ли наблюдатели для каждого блока удаляются при размонтировании, или мы допускаем утечку по одному наблюдателю на блок?
Это объективные сигналы прохождения/непрохождения тестов, которые зафиксированы в виде бюджетов в сквозном (end-to-end) тесте против синтетического тестового сценария огромного пулл-реквеста с множеством комментариев. Теперь CI может сказать нам, находится ли интерфейс в здоровом состоянии.
Перевод цикла на автопилот
Центральным элементом стал автономный цикл изменение → измерение → улучшение. Он состоял из двух частей:
Полоса безголовых зондов (headless probes) запускала декларативный сценарий (открыть пулл-реквест, прокрутить до определенной позиции, перевернуть блок с деталями, изменить размер окна) против макетного сервера, считывая собственные производственные сигналы приложения: количество рендерингов React, шкалу производительности и сэмплер requestAnimationFrame для отслеживания рывков. Система самостоятельно выполняла весь цикл инструментирования, запуска, сбора, анализа и ранжирования, выводя узкие места в порядке приоритета. Поскольку сценарий представляет собой всего лишь JSON, передаваемый зонду во время выполнения, агент мог профилировать любой сценарий, описав его на обычном английском языке без редактирования строк исходного кода.
Автопилот управлял реальным десктопным приложением в сценарии с огромным пулл-реквестом в автоматическом режиме по кругу: сначала на «холодную», когда комментарии еще представлены скелетами, затем на «теплую», когда комментарии загружены, переключая блоки , открывая и отменяя формы ответов, сворачивая и разворачивая файлы, переключая дерево боковой панели, углубляясь в список файлов и изменяя размер окна. Каждое измерение дублировалось в локальный лог приложения, поэтому агент мог считывать поведение во время выполнения без участия человека за клавиатурой. Каждый сэмпл неси сигнал состояния здоровья, который служил объективной проверкой. Образец в «теплом» состоянии считался здоровым только в том случае, если не было незаполненных промежутков между комментариями, не оставалось пустых блоков комментариев, а реальный контент веток действительно был смонтирован во всем диапазоне прокрутки, включая глубокое погружение по файлам.
Использованный нами цикл выглядел следующим образом:
- Воспроизведение в автономном режиме на реальном движке. Запустите автопилот, дайте ему поработать в цикле, прочитайте локальный лог.
- Обнаружение по сигналу здоровья, а не на глаз. Доверяйте полям сэмплов.
- Исследование подозрительного участка. Когда сигнал ухудшается, добавьте туда один узкий структурированный зонд, пересоберите проект и перечитайте логи. (Редактирование интерфейса перезагружает живое окно «на лету» и повторно активирует автопилот, поэтому свежий снимок данных доступен примерно через один цикл.)
- Удаление вспомогательного кода. Как только вы поняли инвариант, зафиксируйте его в тесте и технической документации, оставив только сигналы уровня детекторов.
К чему мы пришли
Ревью пулл-реквеста такого размера раньше означало одно из двух: долгое ожидание или отказ от этой затеи и чтение кода где-то в другом месте. Ревью — это не документ с фиксированными размерами. Это диалог, который меняет форму по мере чтения, и подстилающий его интерфейс должен быть создан для этого с самого начала, а не исправляться «задним числом».
Результатом стал просмотр пулл-реквеста, где diff на миллион строк с сотнями связанных комментариев открывается, прокручивается и ведет себя как пулл-реквест нормального размера. Комментарии отображаются полностью, а не обрезаются в прокручиваемом окне. Разворачивание свернутой секции сдвигает код ниже нее и ничего больше. Возврат к только что покинутому пулл-реквесту возвращает вас ровно на то же место.
Если вы занимаетесь код-ревью профессионально, стоит прочувствовать разницу на пулл-реквесте, который, как вы уже знаете, является сложным. Откройте самый худший из тех, что у вас есть.
