Dev48
ЯЗЫК
  • О нас
  • Услуги
  • Индустрии
  • Технологии
  • Статьи
  • Контакты
Забронировать звонок
    Главная/Статьи/Rendering ogromnyh pull rekvestov v prilozhenii github copilot
Dev48
info@dev48.org+375 29 320-40-96WhatsApp@isky_by

© 2026 · Все права защищены.

Рендеринг огромных пулл-реквестов в приложении GitHub Copilot

Фото: viarami (Pixabay) — https://pixabay.com/photos/corona-covid19-covid-app-iphone-5275916/

Источник: GitHub Actions•25 сентября 2026 г. в 02:41

Рендеринг огромных пулл-реквестов в приложении GitHub Copilot

Как мы переработали интерфейс отображения различий (diff) в приложении GitHub Copilot, чтобы открывать пулл-реквесты на миллион строк с сотнями встроенных комментариев к ревью. Статья «Рендеринг огромных пулл-реквестов в приложении GitHub Copilot» впервые опубликована в блоге GitHub.

Масштабные рефакторинги и миграции часто приходится внедрять как одно изменение.

Стековые пулл-реквесты — отличный способ разделить работу на более мелкие изменения, что упрощает проверку и помогает командам выпускать код с меньшим риском. Но некоторые изменения, подобные этому, невозможно аккуратно разделить. В результате у вас остается один пулл-реквест, который может стать очень большим, а обсуждение в ходе проверки заставляет его расти еще сильнее.

Процесс проверки должен оставаться быстрым и плавным, даже когда diff и обсуждение к нему огромны. В приложении GitHub Copilot мы переработали представление пулл-реквеста с учетом этого требования.

Чтобы понять, насколько это эффективно, мы открыли самый большой пулл-реквест, который смогли найти: open-source проект с 2200 файлами, более чем миллионом измененных строк и более чем 400 встроенными комментариями к проверке. Вот как мы добились высокой производительности даже для такого экстремального пулл-реквеста.

Суть проблемы

Отрисовка большого diff на высокой скорости — задача понятная: виртуализируйте строки, сохраняйте DOM-дерево небольшим и опирайтесь на тот факт, что каждая строка — это строка кода известной высоты.

Комментарии — это сложная часть. Высота комментария зависит от того, как переносится его Markdown, от раскрывающихся разделов, наличия в нем поля для ответа и того, загрузились ли изображения. Вы узнаете все это только во время отрисовки. Это требует иной архитектуры.

Три проблемы:

  • Измерение. Вы не можете узнать высоту комментария, пока не отрисуете его. Это нарушает принцип, позволяющий большим diff-ам оставаться отзывчивыми при прокрутке.
  • Конвейер данных. Быстрая поверхность diff бесполезна, если конвейер данных, питающий ее, тормозит или отбрасывает уже выполненную работу.
  • Как мы на самом деле находили ошибки. Эти проблемы проявляются под нагрузкой, на конкретном движке, в определенной позиции прокрутки. Поэтому мы определили, что означает «здоровое» состояние, оснастили поверхность инструментами для его отслеживания и запустили весь цикл «изменение → измерение → улучшение» в автоматическом режиме.

Первый шаг — понять геометрию, которая делает diff только с кодом быстрым. Как только появляются комментарии, этой геометрии становится недостаточно.

Что делает большие diff-ы быстрыми

Вы не можете разместить миллион узлов DOM на одной странице. Стандартный ответ — виртуализация: монтируйте только те строки, которые находятся на экране, плюс небольшой запас, и используйте эти же элементы DOM повторно по мере прокрутки пользователем. Список ведет себя так, будто существуют все миллион строк. Полоса прокрутки имеет правильный размер, прокрутка к строке работает. Но в каждый момент времени реально существуют только около 100 строк.

Чтобы эта иллюзия сохранялась, кто-то должен предоставлять геометрию. Высота полосы прокрутки — это сумма высот всех строк. Позиция строки N — это сумма высот строк над ней. Переход к строке, отрисовка полосы прокрутки, определение того, что находится на экране — все это арифметические операции над таблицей высот. Вы можете построить эту таблицу на основе оценок и корректировать ее по мере измерения строк, и универсальные виртуализаторы переменной высоты делают именно это.

Но если каждая строка — это строка кода с известным размером шрифта, вам это не нужно. Вы можете вычислить всю таблицу заранее, и она никогда не изменится, поэтому позже ничего не нужно корректировать.

Назовем это контрактом «все высоты известны до отрисовки». Наша поверхность diff построена вокруг него:

  • Императивный рендерер повторно используемых строк кода (без React-компонента на строку)
  • Геометрия на типизированных массивах для вычислений смещения
  • Документы diff, принадлежащие бэкенду, передаваемые потоком с приоритетом структуры
  • Императивный API прокрутки с точным переходом к строке N

Ничто из этого не масштабируется плохо, потому что никакая работа на кадр не растет вместе с общим количеством строк. Для чистого кода этот дизайн является правильным, и мы сохранили его полностью.

Теперь поместите ветку обсуждения в середину diff. Какой она высоты?

Вы не знаете и не можете узнать это, не отрисовав ее. Ее высота зависит от вещей, которые существуют только во время отрисовки, и они могут продолжать меняться после первой отрисовки:

  • Markdown, который переносится по-разному при разной ширине
  • Блоки <details>, которые пользователь может развернуть или свернуть на месте
  • Компоновщик ответов, который открывается внутри существующей ветки и растет по мере ввода текста
  • Diff-ы предложенных изменений, реакции, режим редактирования, баннеры разрешения
  • Изображения и асинхронные ресурсы, которые меняют высоту после завершения загрузки

Очевидный ответ — зарезервировать слот фиксированной высоты для каждого комментария, размер которого определяется оценщиком. Это разваливается на большом пулл-реквесте. Оценщик, который прав в среднем, все равно ошибается в крайних случаях. Он перерезервирует большинство комментариев, оставляя пробелы, и недорезервирует сложные, которые обрезаются или обзаводятся вложенной полосой прокрутки. Если вы измерите реальную высоту после отрисовки и запишете ее обратно в общую таблицу смещений, все, что ниже, сдвинется, пока пользователь уже прокручивает страницу. Это скачок прокрутки, и на большом пулл-реквесте он значителен.

Поэтому для комментариев нужен другой контракт. «Все высоты известны до отрисовки» недостижимо для этого контента. Что мы могли пообещать взамен: высоты ограничены, измеряются лениво, а корректировки малы и привязаны к тому, на что смотрит пользователь.

Две геометрии вместо одной

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

Геометрия кода сохраняет исходный мир. Она детерминирована, вычисляется префиксными суммами, точна и никогда не перестраивается при изменении размера комментария.

Геометрия динамических блоков охватывает все, высоту чего мы не можем предсказать, например, ветки обсуждений, черновики и компоновщики ответов. Каждый из них — это блок, идентифицируемый тем, чем он является, а не тем, где он сейчас находится. У него есть стабильный ключ, который сохраняется при загрузке контента, и он привязан к файлу, строке и стороне, а не к координате пикселя, поэтому изменение макета не может потерять его. Мы также храним «отпечаток» всего, что может изменить высоту блока: его контент, открыт ли <details>, активен ли компоновщик. И мы записываем ширину, при которой он был измерен в последний раз, округленную до диапазонов, чтобы обычное изменение размера окна не делало недействительным каждое измерение в документе.

Эффективная высота блока тогда проста: измеренная высота, если у нас есть валидная, кэшированная высота, если отпечаток и ширина все еще совпадают, и оценка в противном случае. Эти высоты живут в своем собственном индексе, отдельно от строк кода, поэтому изменение размера комментария никогда не заставляет перестраивать геометрию кода. И количество блоков ограничено комментариями, а не строками. Несколько тысяч блоков — это нормально, если первая отрисовка никогда не монтирует и не измеряет их все сразу.

Планировщик измерений и ошибка, которую мы совершили первой

Эта часть заняла больше всего времени, потому что наш первый дизайн был ошибочным поучительным образом.

Очевидный способ измерения динамического контента — один ResizeObserver на блок, который следит за элементом и записывает его измеренную высоту обратно в макет всякий раз, когда она меняется. Это то, что мы спроектировали, а затем отвергли во время оптимизации производительности. Это цикл обратной связи, которого должны избегать большие виртуализированные поверхности. Наблюдатель, который записывает высоту обратно в макет элемента, за которым он наблюдает, может перезапустить сам себя, и стоимость растет с каждым смонтированным блоком.

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

  • Вне основного потока. Он запускается, когда видимая область стабилизируется, никогда не срабатывает по разу на каждый кадр прокрутки и полностью ожидает завершения прокрутки. Перекомпоновка (reflow) в процессе прокрутки — это именно те «рывки» (jank), которых мы избегаем. Он снова запускается, как только прокрутка прекращается.
  • Ограничено областью просмотра (viewport). Кандидатами являются только блоки, находящиеся примерно в пределах 2400 пикселей от области просмотра, поэтому сложность работы составляет O(viewport). Далекие блоки продолжают использовать свои оценочные значения и корректируются по мере приближения.
  • Чтение на экране — приоритет. Смонтированный блок находится на экране, поэтому его отрендеренная высота — это истина в последней инстанции. Проход считывает каждый смонтированный блок-кандидат за один пакет, выполняя одну перекомпоновку без промежуточных записей, и фиксирует полученные данные. Смонтированный блок никогда не пропускается в пользу устаревшей оценки. Это правило устранило самую неприятную ошибку, с которой мы столкнулись: комментарии, которые отображались с полосой пустого пространства под ними, потому что смонтированный блок был исключен из измерений и оставлен с завышенной оценкой высоты.
  • Измерение вне экрана — это ограниченный запасной вариант. Для близлежащего блока, который еще не смонтирован, проход выполняет не более одного рендеринга вне экрана, чтобы скорректировать его резервирование до того, как он попадет в область видимости при прокрутке. Блоки, которые выше области просмотра, пропускают даже это. Их избыточное резервирование скрыто под «линией сгиба», поэтому рендеринг не стоит затраченных усилий.
  • Наблюдатель (Observer) ловит остальное. Некоторые изменения высоты не меняют «отпечаток» и не совпадают с прокруткой: ввод текста в редакторе ответов, завершение загрузки изображения, переключение <details>. Каждый смонтированный блок удерживает ResizeObserver, но по умолчанию он лишь помечает блок, чтобы проход в режиме ожидания (idle pass) перечитал его. Он никогда не записывает высоту самостоятельно, что замкнуло бы цикл обратной связи, который мы отвергли. Он отключается при размонтировании, а неактивная вкладка запроса на слияние (pull request) ничего не отслеживает.
  • За одним намеренным исключением. Ожидание было явно ошибочным для изменений размера, вызванных вами: развертывание <details>, открытие редактора ответов, загрузка изображения. Блок увеличивался немедленно, но код под ним сдвигался только при следующем проходе в режиме ожидания. В течение одного кадра комментарий был выше, в то время как все под ним оставалось на старой позиции, и вы могли видеть эти два шага. Поэтому, когда блок смонтирован и находится на экране, наблюдатель теперь измеряет его и применяет корректировку в том же кадре, до отрисовки. Блок увеличивается, код меняет положение, и все, что ниже, сдвигается вместе с ним. Два предохранителя не дают этому превратиться в цикл, которого мы избегали: не более одного синхронного коммита на кадр, чтобы серия изменений размеров схлопывалась в одно, и никогда во время активной прокрутки, где срабатывает откат к пакетному проходу.

Привязка прокрутки (Scroll anchoring): Корректировка без борьбы с пользователем

Когда измеренная высота отличается от оценочной, арифметика полосы прокрутки меняется, и наивный результат приводит к «прыжкам» области просмотра. Решение состоит в том, чтобы корректировать по идентификатору, а не по пикселям:

  • Перед применением обновлений высоты зафиксируйте, к чему привязан пользователь (строка или блок, по идентификатору), плюс смещение внутри него.
  • Примените дельты высоты.
  • Разрешите тот же якорь в его новую позицию в пикселях.
  • Прокрутите так, чтобы якорь оставался на месте в области просмотра.

Плюс несколько правил, которые предотвращают ощущение ошибки:

  • Блок над областью просмотра меняет высоту → корректировка на дельту (сохраняет ваше место).
  • Контент подгружается под областью просмотра → не корректировать (вы его не видите).
  • Если вы переключили <details> или открыли ответ в видимом блоке → подавить корректировку над блоком для этого блока, чтобы взаимодействие ощущалось прямым, и позволить контенту ниже течь естественным образом.
  • Никогда не боритесь с активным указателем или инерцией колеса; пакетная корректировка после кадра.

У последнего правила есть острый край, и мы на него напоролись. «Не корректировать, пока пользователь прокручивает» было реализовано как защита по последней зафиксированной прокрутке, и программные прокрутки также обновляли эту временную метку. Переключение боковой панели дерева файлов меняет ширину панели diff. При включенном переносе строк каждая перенесенная строка над вами перекомпоновывается в другое количество визуальных строк, все координатное пространство сдвигается, и поверхность сама излучает небольшую прокрутку при стабилизации. Защита считывала это как «пользователь только что прокрутил» и пропускала ту самую корректировку, которая должна была сохранить ваше место, поэтому файл, который вы читали, уплывал с экрана. Решение заключалось в том, чтобы отличать пользовательские прокрутки от тех, которые поверхность вызвала сама. Любая проверка «взаимодействует ли пользователь?» должна быть такой, которую ваши собственные побочные эффекты не могут удовлетворить.

Таким образом, корректировки остаются небольшими, они повторно используют измерения, которые у нас уже есть, и они следуют за тем, на что вы смотрите.

Часть 2: Конвейер за поверхностью

Поверхность diff может быть только такой быстрой, как данные, питающие ее, и три привычки с той стороны работы сформировали то, что мог делать UI. Первая — структура потока перед контентом. Diff запрашивается инкрементально, поэтому дерево файлов и метаданные отрисовываются, пока документ еще загружается, а полный набор веток обсуждения разрешается заранее, а не просачивается по частям. Вторая — откладывать работу по каждому элементу до тех пор, пока она не понадобится. Подсветка синтаксиса выполняется вне основного потока, поэтому строки появляются как обычный текст немедленно и окрашиваются, когда приходят результаты. Подсветка улучшает поверхность, а не блокирует прокрутку. Большие тела markdown и контекст предложенных изменений работают так же: ничего не строится, пока не приблизится к области просмотра.

Третья привычка касается того, какие затраты стоит сохранять. Освобождение документа diff при переходе — правильное поведение по умолчанию. Эти документы большие, и удержание каждого, который вы посетили, — это то, как долгая сессия заканчивает потреблением всей памяти. Но метаданные pull request сохраняются, поэтому оболочка вокруг diff, заголовок и дерево файлов перерисовываются мгновенно, когда вы возвращаетесь, а затем сидят там несколько секунд в ожидании diff, который у них был мгновения назад. Мгновенно отрисованная оболочка вокруг пустого diff выглядит сломанной, даже если вы ждете меньше времени в целом. Поэтому политика осталась, и мы добавили кэш: хранить последние несколько diff, вытеснять все, что за их пределами, и позволить фоновому обновлению замечать, когда один из них устарел.

Часть 3: Цикл измерения, или как мы на самом деле нашли ошибки

Почти каждая ошибка в этом проекте была невидимой, пока не становилась явной, а воспроизведение одной из них вручную — это мучение. Типичный отчет гласит: «полоса пустого пространства появляется под некоторыми комментариями, но только иногда, только в больших pull request, и она исчезает, если прокрутить мимо и обратно». Вы не можете отладить это, глядя на экран, поэтому мы создали инструменты для механической отладки.

Инструментируйте реальными сигналами приложения, а не одноразовыми логами

Наивный рабочий процесс — это расставить вызовы console.log, прогнать поток вручную, скопировать вывод, вставить его кому-то (или чему-то), кто может его проанализировать, удалить логи и повторить. Это медленно, требует участия человека, и, что хуже всего, вы в итоге измеряете свою собственную самодельную инструментацию, а не реальное поведение приложения.

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

  • Действительно ли поверхность ограничена областью просмотра? Сколько строк и блоков комментариев смонтировано прямо сейчас?
  • Сводится ли измерение к одному коммиту на кадр и сколько времени занимает этот кадр?
  • Насколько велики вносимые нами корректировки прокрутки?
  • Был ли вставлен какой-либо блок комментариев после начала прокрутки? (После того как топология бэкенда будет готова, это значение должно быть равно нулю.)
  • Действительно ли наблюдатели для каждого блока удаляются при размонтировании, или у нас происходит утечка по одному на блок?

Это объективные сигналы прохождения/непрохождения, которые задаются в качестве пороговых значений в сквозном тесте с использованием синтетического фикстурного объекта с огромным количеством комментариев в pull request. Теперь CI может сообщить нам, является ли интерфейс работоспособным.

Перевод цикла на автопилот

Центральным элементом стал автономный цикл «изменение → измерение → улучшение». Две линии:

Линия с headless-зондом выполняла декларативный поток (открытие pull request, прокрутка до определенной части, переключение блока details, изменение размера окна) на mock-сервере, считывая собственные производственные инструменты приложения: количество рендеров React, временную шкалу производительности и сэмплер requestAnimationFrame для отслеживания «заиканий» (jank). Он самостоятельно выполнял весь цикл: инструментарий, управление, сбор, анализ, ранжирование — и выводил «узкие места» в порядке их значимости. Поскольку поток представляет собой просто JSON, передаваемый зонду во время выполнения, агент может профилировать любой поток, описав его на простом английском языке, не редактируя ни строчки исходного кода.

Автопилот управлял реальным десктопным приложением через поток огромного pull request без присмотра, по циклу: сначала «холодный» запуск, когда комментарии еще представлены скелетами, затем «горячий», с загруженными комментариями, переключением блоков <details>, открытием и отменой редакторов ответов, сворачиванием и разворачиванием файлов, переключением дерева боковой панели, глубоким просмотром списка файлов, изменением размера окна. Каждое измерение дублировалось в лог приложения на диске, чтобы агент мог считывать поведение во время выполнения без участия человека. Каждый образец содержал сигнал работоспособности, который служил объективной проверкой. «Горячий» образец считался работоспособным только в том случае, если во всем диапазоне прокрутки, включая глубокий просмотр файлов, не было незаполненных промежутков между комментариями, пустых блоков комментариев, а реальное содержимое веток было успешно смонтировано.

Цикл, который мы использовали:

  • Воспроизведение без присмотра на реальном движке. Запуск автопилота, работа в цикле, чтение лога на диске.
  • Обнаружение с помощью сигнала работоспособности, а не визуально. Доверяйте полям образцов.
  • Зондирование подозрительного участка. Когда сигнал ухудшается, добавьте туда один узкий структурированный зонд, перезапустите, считайте снова. (Редактирование интерфейса приводит к «горячей» перезагрузке активного окна и перезапуску автопилота, поэтому получение свежих данных занимает около одного цикла.)
  • Удаление вспомогательных конструкций. Как только вы поймете инвариант, зафиксируйте его в тесте и проектной документации, оставив только сигналы уровня детектора.

К чему это нас привело

Раньше просмотр такого большого pull request означал одно из двух: ждать или сдаться и читать его где-то еще. Обзор — это не документ с фиксированными размерами. Это разговор, который меняет форму, пока вы его читаете, и лежащая в его основе поверхность должна быть построена для этого с самого начала, а не дорабатываться «заплатками» впоследствии.

Результатом стало представление pull request, где diff на миллион строк с сотнями вложенных комментариев открывается, прокручивается и ведет себя как pull request обычного размера. Комментарии отображаются полностью, а не обрезаются в прокручиваемом окне. Разворачивание свернутого раздела перемещает код под ним и ничего больше. Возвращение к pull request, который вы только что покинули, возвращает вас на то же место.

Если вы профессионально занимаетесь проверкой кода, стоит прочувствовать разницу на pull request, который, как вы уже знаете, вызывает трудности. Откройте самый сложный из тех, что у вас есть.

← Все статьи

О чём эта статья