Бывает, что фича готова и её пора передавать в тестирование, но при этом не покидает ощущение, что что-то не так. Однажды внутреннее чутьё меня не обмануло и привело в исходники React.
Меня зовут Денис Кондратьев — я фронтенд-разработчик в Точка Банк. В статье поговорим, как работает приоритетный рендеринг в React, что такое проблема разрыва и разберём реальный кейс на примере корпоративного мессенджера.
Проблема с голосовыми
Всё началось с простой задачи — добавить голосовые сообщения в наш корпоративный мессенджер. Готовая фича должна была выглядеть так:
Пользователь записывает голосовое сообщение → Оно отображается в чате в виде аудиоплеера → Плеер показывает прогресс прослушивания
При этом у сообщения есть тред — отдельный экран с комментариями. По клику он загружает данные с бэкенда и, пока идёт запрос, крутится спиннер.
После написания кода я решил проверить основные сценарии. На первый взгляд всё работало как надо: голосовые сообщения записывались и воспроизводились, плеер показывал прогресс. Но после нажатия на тред интерфейс неожиданно завис в состоянии загрузки.

Судя по логам, бэкенд ответил со статусом 200, то есть данные пришли быстро, но спиннер всё равно продолжал крутиться. Тред открылся, только когда аудио проигралось до конца. Я решил разобраться, в чём причина.
Попытки дебага
Первое, что я сделал — пошёл проверять свой исходный код. Начал с функции, которая обновляет значение таймера воспроизведения голосового сообщения.
start(): void { this.rafId = requestAnimationFrame(() => { this.setProgress(this._webApiPlayer.currentTime); this.start(); }); }
Дальше посмотрел на компонент отображения: он получает текущее время через хук, переводит его в прогресс и передаёт в визуальный бегунок.
constcurrentTime = useAudioPlayerProgress(audioController); const progress = 100 / (duration / currentTime); return <AudioPlayer progress={progress} />;
Затем — компонент активного треда, который обёрнут в Suspense и показывает спиннер, пока загружаются данные.
return ( <Suspense fallback={fallback}> <ActiveThread /> </Suspense> );
На первый взгляд всё выглядело правильно: таймер обновлялся через requestAnimationFrame, данные для треда приходили, а Suspense показывал спиннер во время загрузки. То есть код выглядел слишком хорошо, чтобы быть причиной ошибки.
Я поискал ответы в интернете, спросил у нейросетей, но никаких зацепок не нашёл. Стало понятно, что нужно копать глубже. Пришлось идти в исходный код React, ставить инструкции дебаггера и смотреть, что происходит внутри.

Как работает React
Чтобы понять, в чём была причина, нужно разобраться, как React работает под капотом. Не буду пересказывать всю документацию — только то, что реально пригодится в нашем кейсе.
Если коротко, процесс рендеринга в React состоит из трёх этапов:
Фаза планирования. React получает обновления состояния, складывает их в очередь и решает, какие важнее.
Фаза рендера. Работает с виртуальным DOM, строит и обновляет дерево компонентов.
Фаза коммита. Взаимодействует с реальным DOM, т.е. интерфейсом пользователя.
Важно, что фаза рендера может быть прервана в любой момент ради более срочной задачи. То есть React постоянно принимает решения, какую задачу сделать сейчас, а какую можно отложить.
Чтобы понять, как устроен этот механизм, нужно заглянуть чуть глубже в виртуальный DOM. Он представлен в виде дерева специальных объектов — Fiber.

Каждый такой объект содержит несколько групп полей:
Основные свойства: tag, key, type, elementType, stateNode.
Взаимосвязи в дереве: return, child, sibling.
Работа с обновлениями: pendingProps, memoizedProps, memoizedState.
Приоритеты: lanes, childLanes.
Алгоритм обхода дерева тоже не совсем привычный. React не идёт рекурсивно в глубину, как мы привыкли. Сначала он спускается к первому потомку (child), затем проходит соседние узлы (sibling), а чтобы вернуться к родителю, использует поле return. Благодаря этому он может в любой момент остановиться и продолжить с того же места.
Чтобы React решал, какую задачу выполнять следующей, у каждого Fiber есть два поля:
lanes — приоритет задач в текущем узле.
childLanes — приоритет задач у потомков.
Когда React доходит до очередного Fiber, он делает простую проверку. Если в lanes есть работа — выполняет её. Если более приоритетная работа, например, реакция на действия пользователя, находится в childLanes — спускается к потомкам.
Именно этот механизм позволяет постоянно переключаться между задачами. И в нашем случае это сыграло ключевую роль.
Лейн (lane) |
Приоритет |
Функция или компонент |
|---|---|---|
Sync Lane |
Очень высокий |
Обработчики событий useSyncExternalStore/useState |
Default Lane |
Высокий |
Обновление без приоритета useState |
Transition Lane |
Средний |
Функция startTransition |
Retry Lane |
Низкий |
Механизмы Suspense |
Idle Lane |
Низкий |
requestIdleCallback |
Offscreen Lane |
Низкий |
Offscreen API |
Гонка приоритетов
Так выглядел мой тестовый проект, на котором я дебажил React. Это наше виртуальное DOM-дерево: для удобства я убрал HTML-элементы и оставил только самые важные компоненты. Внизу добавил условные обозначения приоритетов:
зеленый — низкий приоритет;
красный — высокий приоритет;
синий — работа выполняется в данный момент.

Когда мы нажимаем кнопку «воспроизвести», меняется состояние таймера и бегунок начинает двигаться. Это приводит к обновлению компонента таймера. Поскольку внутри используется Sync External Store, оно получает высокий приоритет.

После этого React помечает красным не только сам компонент, но и всю родительскую ветвь. Это нужно, чтобы он понимал, куда идти и где обновлять компонент.

Так как других задач в данный момент нет, React берёт её в работу и спускается по дереву сверху вниз, начиная с root-компонента.

На каждом узле он проверяет Lanes и ChildLanes: если в lanes работы нет (то есть стоит значение NoLane), узел пропускается, и React идёт дальше. Так он доходит до каунтера и обновляет значение.
Важно, что на этом этапе изменения ещё не видны пользователю — всё происходит только в виртуальном DOM.

После завершения работы React поднимается обратно вверх по дереву, используя поле return, доходит до корневого узла и завершает фазу рендера.

Затем наступает фаза коммита, и только в этот момент пользователь видит изменения: ползунок в интерфейсе начинает двигаться.

Этот цикл повторяется много раз, пока таймер не закончится.

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

Но в процессе выполнения возникает новая задача — обновление таймера. Она имеет более высокий приоритет, поэтому React делает следующее:
приостанавливает текущую (низкоприоритетную) работу;
возвращает её обратно в очередь;
переключается на выполнение высокоприоритетной задачи.

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

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

И только когда таймер дойдёт до 100% и новые высокоприоритетные обновления перестанут приходить, React наконец сможет спокойно завершить низкоприоритетную задачу.

Тогда наш тред появится на экране.

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

Если посмотреть на issues в репозитории React, можно найти похожие описания. В частности, там говорится, что при использовании Concurrent Mode, startTransition или Suspense React может приостанавливать текущую работу, чтобы переключиться на более приоритетные задачи. Именно это мы и наблюдали в нашем кейсе.
Для этого даже есть специальный термин — голодание (starvation). Это состояние, когда задачи с низким приоритетом постоянно откладываются или не выполняются вовсе из-за непрерывного потока более приоритетных задач.
Вот что об этом пишет коллега из команды React:
Обсудили с командой. Все переходы запутываются в текущей реализации. Запланирован проект по решению этой проблемы, но после релиза есть более срочные дела, поэтому мы рассчитываем вернуться к ней не раньше второй половины этого года. Очевидно, что проблема станет ещё более актуальной по мере того, как конкурентные функции начнут внедряться.
Комментарий был создан ещё в 2022 году, но до сих пор ничего не изменилось.
Как убрать разрыв
Когда стало понятно, в чём причина, мы начали перебирать варианты, как решить проблему. Идей было много, но почти у каждой находились серьёзные ограничения.
Отказаться от React там, где нужна скорость. Теоретически это решает проблему, практически — тянет за собой много последствий. Мы используем готовую дизайн-систему, все компоненты которой написаны на React. Отказ от него означал бы, что часть интерфейса придётся переписывать, а потом ещё и поддерживать.
Отказаться от Suspense. У него низкий приоритет, и именно он постоянно проигрывает в конкуренции с обновлениями таймера. Но вместе с Suspense мы теряем удобную модель работы с асинхронностью, в том числе error boundaries и всплытие ошибок по дереву.
Использовать CSS. Это позволило бы вынести обновление таймера из React и убрать конкуренцию приоритетов. Но тогда нужно синхронизировать состояния между CSS и JavaScript. К тому же такой подход плохо сочетается с нашей дизайн-системой.
Использовать React Spring. Этот вариант выглядел перспективно, но так как сроки были ограничены, мы решили от него отказаться.
Были и другие радикальные идеи, типа canvas, SVG или SolidJS. Но, думаю, тут комментарии излишни. В нашем случае это выглядело как чрезмерное усложнение ради одной конкретной проблемы. Нам нужно было решение, которое впишется в текущую систему и не сломает остальную часть приложения.
Что мы сделали в итоге
В документации React в подобных ситуациях рекомендуют использовать useSyncExternalStore, то есть переводить обновления в максимально высокий приоритет (Sync Lane).
Но в нашем случае это не решало проблему, а наоборот — усугубляло её. В чате уже используется Suspense с низким приоритетом. И добавление ещё большего количества высокоприоритетных задач означало бы, что низкоприоритетная работа продолжит откладываться.
Тогда я решил копнуть в сторону React Scheduler. О нём довольно мало информации в сети, но идея звучит обнадёживающе: если низкоприоритетная задача слишком долго не выполняется, планировщик должен повысить её приоритет.

На практике всё оказалось не так радужно. Я снова начал записывать голосовые, но так и не дождался загрузки треда. Scheduler в нашем сценарии ничем не помог.
В итоге из всех вариантов сработал только один. Мы не стали повышать приоритет медленной задачи, а понизили приоритет быстрой. Предвосхищая вопросы — да, так можно было ?
Для этого мы обернули обновление состояния в startTransition. Под капотом это тот же useState, но с другим приоритетом: обновления переходят в Transition Lane (средний приоритет), а значит таймер и Suspense соревнуются почти на равных. И наконец всё заработало.
return useRxBind( playerProgress$.pipe(throttleTime(throttle)), startTransition:true );
useRxBind — это кастомный хук, который подписывается на RxJS-стрим и синхронизирует его значение с состоянием React. Флаг startTransition: true говорит хуку оборачивать обновления состояния в startTransition, тем самым переводя их в Transition Lane.
В дополнение мы немного притормозили обновления таймера через throttle, что ещё сильнее снизило нагрузку.
В итоге:
голосовые сообщения продолжили нормально воспроизводиться;
тред стал открываться сразу, даже во время проигрывания.
Ирония в том, что на поиск причины ушло около двух недель, а само решение заняло две минуты ?