Сначала это выглядело как обычные капризы дев‑стендов: страница начинала загружаться и никогда не заканчивала. Консоль хранила молчание, логи nginx тоже не показывали ничего подозрительного. Самое странное — проблема проявлялась только в браузерах на базе Chromium, а Firefox и Safari вели себя так, будто ничего не произошло. Стенды у нас славятся нестабильностью, а команда была по уши в упаковке релиза и других задачах — баг просто списали на среду, пожали плечами, переключились на Firefox и продолжили работу. Прод тем временем работал как ни в чём не бывало.

Релиз вышел вечером. Ночью вечный лоадер добрался до прода — и стало не до Firefox.

В прошлой статье я рассказывал, как Vue 3 живёт внутри jQuery‑формы и договаривается с легаси. Сегодняшняя история из того же проекта, но этажом ниже, и она о том, что бывает, когда копий Vue на странице внезапно становится несколько. Заголовок уже выдал виновника, так что детектив у нас с известным убийцей — одной строкой в yarn.lock, прожившей в репозитории три месяца и никого не трогавшей. Интересно здесь другое — как мы её нашли и почему искали так долго.

Как монорепа дошла до такой жизни

Проект — yarn‑монорепа с легаси‑хостом в виде комбинации Symfony, jQuery и Vue 2, полмиллиона строк и куча точек входа. Рядом стоит воркспейс с Vue 3, откуда легаси‑страницы получают новые блоки (или цельные страницы) через Module Federation.

Хост живёт на Vue 2, поэтому в корневом package.json стоит только пин 2.7.16. Вот его след в yarn.lock — заодно познакомимся с форматом записей, он нам ещё пригодится:

"vue@npm:2.7.16, vue@npm:^2.6.11, vue@npm:^2.7.15":
    version: 2.7.16

Vue 3 объявлен в воркспейсе и в паре его пакетов — логгере и менеджере жизненного цикла. Диапазоны туда добавлялись в разное время разными задачами: ^3.5.26^3.5.29^3.5.30 — каждый фиксировал актуальную на тот момент минимальную версию, обычная рутина.

Остальное — штатная механика yarn. Поднять Vue 3 в корневой node_modules нельзя, место занято пином 2.7.16 — поэтому копии уезжают ниже; у нас они легли в node_modules самих пакетов. А пересекающиеся диапазоны yarn не сводит к одной версии задним числом, потому что lockfile — память установки, а не оптимизатор. Если ^3.5.26 был установлен, когда свежей была 3.5.29, а ^3.5.30 появился позже, в lock так и останутся две записи:

"vue@npm:^3.5.26, vue@npm:^3.5.29":
  version: 3.5.29

"vue@npm:^3.5.30":
  version: 3.5.32

Заметьте, ^3.5.26 и ^3.5.29 yarn свёл в одну запись — уже установленная 3.5.29 удовлетворяет обоим. А вот ^3.5.30 она не удовлетворяет, и yarn завёл вторую запись со свежей на тот момент 3.5.32. Хотя 3.5.32 подошла бы всем трём диапазонам, переписывать старые записи lock не станет.

Это дословные строки из нашего yarn.lock. Вторая запись прожила в репозитории с марта по июль, и придавать ей значение было не с чего: ни сборка, ни линтер, ни тесты про такое не говорили ни слова, а страницы работали.

Прежде чем читать дальше, откройте yarn.lock своего проекта и посчитайте такие записи для своего фреймворка — grep -c '^"vue@npm' yarn.lock, для react аналогично. Формат из примеров — Yarn Berry; в Yarn Classic, package‑lock и pnpm‑lock записи выглядят иначе, но суть подсчёта та же: сколько разных version числится за пакетом. Если вы никогда этого не делали — скорее всего, их больше одной. Чем это грозит и когда именно, разберём к концу текста вместе с чеклистом на проверку.”

След первый — виноваты стенды

Мы на этом этапе. Улик нет, теорий много
Мы на этом этапе. Улик нет, теорий много

Логика, по которой мы отмахнулись, была проста — вечный лоадер без единой ошибки в консоли выглядел как очередной фокус стендов, а не как баг в коде. Первой под подозрение попала стендовая инфраструктура: в access‑логах nginx злополучных запросов не было, как будто они вовсе не доходили до сервера, так что мы перебирали прокси, сеть и окружение. На это ушли первые часы — впустую.

Отрезвило простое сопоставление — конфигурация стендов давно не менялась, тогда как сборка менялась с каждой влитой задачей. И симптом воспроизводился на каждом свежепересобранном стенде и ни на одном, где оставалась старая сборка. Что бы ни происходило, оно приехало в артефакте сборки.

След второй — висящий запрос спрайта

В DevTools на висящей странице была одна заметная странность: запрос SVG‑спрайта иконок, /build/icons.<hash>.svg, вечно стоял в Stalled с provisional headers. Он выглядел готовым объяснением всей картины, по крайней мере, так нам тогда казалось.

Разбор увёл глубоко в устройство Chromium. Иконки на страницу вставлял в том числе легаси‑код, ссылками вида <use href="/build/icons.<hash>.svg#name">. Для такой внешней ссылки Blink заводит отдельный низкоприоритетный запрос документа и не переиспользует уже летящий fetch того же файла. А его планировщик ресурсов придерживает низкоприоритетные запросы, пока страница грузится. На нашей странице документ не догружался и планировщик придерживал запрос навсегда. Мы это починили, переведя ссылки на локальные якоря инлайнового спрайта — лишний fetch пропал совсем.

Полдня работы спустя
Полдня работы спустя

Спрайт больше не висел, но лоадер остался. Мы потратили полдня на то, чтобы вылечить жертву, приняв её за причину.

Но след оставил после себя правильный вопрос. В формулировке 'придерживает, пока страница грузится' спрятано главное: а почему страница, собственно, никогда не догружается? DOMContentLoaded не наступал вовсе, хотя все ресурсы, кроме злополучного спрайта, давно приехали.

Зеркало

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

Поэтому я снял слепок страницы — HTML и все загружаемые бандлы — и поднял его локально как обычную статику. Получилось зеркало: та же страница, тот же артефакт сборки, но уже без сервера. Chromium на этом зеркале зависал в ста случаях из ста, тогда как Firefox и Safari проходили без проблем. Повезло, что баг оказался детерминированным — плавающую проблему таким способом поймать было бы гораздо сложнее. Дальнейшее расследование заняло меньше времени, чем любая из ложных версий до этого.

Через Chrome DevTools Protocol я поставил паузу отладчика посреди зависшей загрузки, рассчитывая увидеть ожидание сети или пустой стек простоя. Вместо этого отладчик показал исполняющийся JavaScript — и в какой бы момент я ни жал паузу, в стеке стояли одни и те же кадры: __webpack_require__.f.consumes, резолв shared‑зависимости, загрузка чанка, снова consumes.

Страница ничего не ждала. Она продолжала работать — и делала это бесконечно.

Vue, которых два

Кадры повторялись, но в них уже было имя. Модуль, который рантайм резолвил снова и снова, звался consume/default/vue/vue. В рантайм‑чанке — файле вида runtime.<hash>.js — таких модулей оказалось два: vue?9c57 и vue?0eb5, хвосты — это хеши. Рядом лежали их фолбэк‑фабрики, и пути в них говорили прямо: ./packages/logger/node_modules/vue/... и ./packages/lifecycle-manager/node_modules/vue/... — две физические копии одного пакета, по одной на воркспейс‑пакет. Осталось спросить у yarn, как они там оказались: yarn why vue показал обе — и те самые две записи из начала статьи наконец объяснили, зачем они здесь лежали.

Теперь то же самое словами Module Federation. Общие зависимости вынесены в share scope, а каждый потребитель Vue получает consume‑модуль — обёртку 'возьми vue из общего скоупа, а если подходящего нет — вот фолбэк‑фабрика с собственной копией'. Consume‑модуль создаётся не один на пакет, а по одному на каждую физическую копию, дотянувшуюся до сборки, даже если требуемый диапазон у всех одинаковый. У нас копий было две — по числу записей в lock, и обе обёртки жили в одном рантайме.

Оба consume‑модуля были привязаны к чанку core. При загрузке чанка рантайм обязан сначала отрезолвить все его shared‑зависимости; туда же, в core, сборщик положил и фолбэк‑фабрики обеих копий. Геометрия замкнулась: загрузка core требует резолва обоих consume‑модулей, а фолбэк каждого из них требует загрузки core. И вот цикл:

загрузка чанка core
 └─ f.consumes: чанку нужны его shared-зависимости
     ├─ consume vue?9c57 (копия логгера) - резолв
     │   └─ подходящего vue в скоупе нет -> фолбэк, а он лежит в core -> загрузка чанка core
     │       └─ f.consumes: снова весь список
     │           ├─ consume vue?9c57 - резолв
     │           ├─ consume vue?0eb5 - резолв
     │           └─ ... и так до бесконечности
     └─ consume vue?0eb5 (копия менеджера жизненного цикла) - резолв -> его фолбэк тоже в core -> ...

Почему цикл не гаснет? Отметку 'этот модуль уже в работе' рантайм ставит только после вызова обработчика, а обработчик успевает синхронно запустить новую итерацию, которая отметки ещё не видит. В свежих версиях webpack на этом месте уже стоит ранняя защёлка; нашей сборке она ещё не досталась. Стек при этом не растёт. Share scope уже инициализирован, поэтому все промисы в цепочке давно разрешены — и цикл крутится через их микротаски. Каждая итерация короткая, что стек успевает схлопнуться, а событийный цикл остаётся занят навсегда. Поэтому нет ни переполнения, ни исключения — в консоли по‑прежнему пусто, а DOMContentLoaded не наступает.

Каким путём пойдёт инициализация, зависит от порядка, в котором успели стартовать хост и контейнер с Vue 3. Chromium на нашей странице стабильно попадал в горячий путь без единого реального ожидания, Firefox и Safari — в путь, где ожидания давали защёлке время сработать. Почему тайминги движков расходятся именно так, я дальше не копал — хватило зеркала, где всё повторялось раз за разом.

А почему цикл не собирается из одного consume‑модуля? Потому что для петли мало самого модуля — нужно ещё, чтобы его фолбэк лежал в том же чанке, чьи shared‑зависимости сейчас резолвятся. Основной копии Vue — той, что объявлена в самом воркспейсе — это не грозило: её фолбэк сборщик вынес в отдельный чанк, и цепочка 'резолв → фолбэк' на нём и заканчивалась. А обе nested‑копии попали ровно в замкнутую конфигурацию. После дедупа они исчезли вместе со своими consume‑модулями и та же страница ожила.

Почему ружьё молчало три месяца

Заряженное ружьё — это ещё не выстрел. Чтобы страница повисла, должны совпасть три условия, и весну они провели порознь.

Первое: копия обязана попасть в рантайм‑граф — в цепочки реальных импортов, из которых сборка собирает бандл. Строка в lock — лишь обещание; consume‑модуль появляется, когда сборка реально дотягивается до физической копии. Одна из наших двух до последнего момента была невидима для шеринга, потому что пакет собирался с забандленным внутрь Vue, и его копия лежала запечённой в dist. В начале июля сборку пакета поправили честным external: ['vue'], копия вышла из тени и получила собственный consume‑модуль. Шаг был правильный — и он же дослал в ствол второй патрон.

Второе: геометрия. Для петли consume‑модуль и его фолбэк должны съехаться в один чанк — пока фолбэк лежит в стороне, цепочка 'резолв → фолбэк' заканчивается, не замкнувшись. А раскладку чанков сборщик пересчитывает каждый релиз — граф растёт, оптимизатор перегруппировывает модули. Копия логгера жила в графе с марта, но до поры её фолбэк не попадал в core. Какой из релизов свёл их в одном чанке, восстановить уже не по чему — раскладка нигде не версионируется, а старые артефакты не хранились.

Третье: тайминги. Даже собранная петля стреляет только на горячем пути инициализации — Firefox и Safari проходили мимо неё и после того, как первые два условия совпали.

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

Фикс за вечер

Лечение вышло куда короче диагностики. yarn dedupe vue сводит пересекающиеся диапазоны к одной версии; заодно мы выровняли и сами диапазоны — во всех трёх местах теперь стоит одинаковый ^3.5.32. Правка lockfile вышла в минус 197 и плюс 116 строк. Грепу из начала статьи теперь есть что пересчитать — вместо трёх записей vue осталось две, нетронутый пин двойки и одна‑единственная тройка:

"vue@npm:^3.5.32":
  version: 3.5.39

Пин Vue 2 при этом не задет — дедуп работает в пределах пересекающихся диапазонов и через мажорную границу не лезет.

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

Страховкой стал явный конфиг Module Federation — чтобы класс проблемы не вернулся со следующим разъехавшимся диапазоном. До инцидента shared‑секция была простым спредом зависимостей, ...dependencies — её однажды завели по докам и не трогали, а пока диапазоны не разъезжались, поводов и не было. Теперь у фреймворка и его экосистемы прописано:

shared: {
    ...dependencies,
    vue: {
        singleton: true,
        requiredVersion: dependencies.vue,
    },
    // то же для pinia, vuex и vue-i18n
},

singleton: true не удаляет вторую копию, а меняет режим отказа — рантайм берёт одну копию, а о несовпадении версий предупреждает в консоли. Для фреймворка с глобальной реактивностью единственность — не вкусовщина, ведь две копии Vue — это два независимых мира реактивности, которые и без всякого зацикливания умеют ломать приложение (на тех граблях мы тоже постояли, но это отдельная история, не сегодняшняя).

Чеклист

Что я теперь делаю сам и предлагаю сделать вам. Первые три пункта — диагностика, по нарастанию тревожности; дальше — страховки, чтобы к диагностике не возвращаться.

Посчитайте записи. grep '^"vue@npm' yarn.lock — тот же grep из начала статьи, только без -c, потому что теперь важно не число, а сами записи. Несколько записей одного мажора — повод для следующего пункта, но ещё не приговор.”

Смотрите рантайм‑граф. yarn why vue --peers показывает, кто какую копию тянет; без --peers yarn промолчит про пакеты, у которых vue объявлен peer‑зависимостью, то есть как раз про правильно оформленные библиотеки. Запись, живущая в сборочном тулинге, безвредна, а вот копия в рантайм‑графе совсем нет.

Проверьте сам артефакт. После сборки: grep -oE 'consume/default/vue/vue\?[0-9a-f]+' runtime.*.js | sort -u. Рантайм‑чанк — файл вида runtime.<hash>.js в вашем билде; consume/<скоуп>/<пакет>/<запрос> — так в артефакте называются consume‑модули, для react соответственно consume/default/react/react. Каждый уникальный хвост после ? — отдельная копия. Считать же сырые вхождения без хвоста бессмысленно — единственная здоровая копия упоминается по разу на каждый чанк‑потребитель и легко даёт десятки совпадений.

Синглтоны для фреймворка. В shared у Module Federation — singleton: true для фреймворка и его экосистемы. От второй копии он не защитит, зато сломается всё громко и сразу, а не молча и через три месяца.

Переложите бдительность на робота. Ручной grep хорош во время расследования, но на дистанции работает только автоматика. Мы добавили yarn constraints — декларативные правила, которые держат версии зависимостей едиными по всем воркспейсам, от сборочного тулинга до самого Vue. Ядро правила — четыре строки внутри цикла по зависимостям всех воркспейсов в yarn.config.cjs (PACKAGE_WORKSPACES и VUE_VERSION — свои константы в виде списков воркспейсов и единого диапазона):

if (dependency.ident === 'vue' && PACKAGE_WORKSPACES.includes(workspace)) {
    dependency.update(VUE_VERSION);
    continue;
}

Проверка стоит гейтом в CI, так что диапазон vue, отличный от соседей, теперь не доживает до артефакта — валит пайплайн на первом же прогоне. Если constraints кажутся излишеством, минимальный вариант того же гейта — yarn dedupe --check той же строкой пайплайна.

Что я вынес

Строка в lockfile — тоже код, просто его никто не ревьюит. Наша строка три месяца спокойно пережила не один десяток релизов — и выстрелила, когда рантайм‑граф дорос до неё.

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

В прошлой статье Vue 3 жил в чужом доме и учился договариваться с jQuery. В этой он едва не погиб от встречи с самим собой — причём обе его копии были совершенно исправны.