Skip to content

Федерация и оболочка

Ваш модуль поставляется как remote для Module Federation. Хост — Building OS: оболочка владеет страницей, тем React, который её рендерит, и роутером, в который монтируются ваши маршруты. Она загружает ваш remoteEntry.js во время выполнения и монтирует ваш ./Shell.

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

Начиная с 1.10.0 всё это генерируется

npx @tv/extension-sdk init-module пишет за вас и карту shared, и алиас, и файл-заглушку, и ci.yml с прогоном smoke-проверки. Если модуль сгенерирован на 1.10.0 или новее — всё уже на месте; всё равно проверьте, а дальше читайте, если обновляете старый модуль или собираете конфигурацию вручную.

Экспорт ровно один, и он называется ./Shell

ts
exposes: {
  './Shell': './src/Shell.tsx',
},

Оболочка ищет строго это имя. Любое другое ничего не загрузит и ничего внятного не сообщит. Закройте это проверкой в CI:

bash
npx @tv/extension-sdk check-exposes module-manifest.json --config=./vite.config.ts

Проверка сначала смотрит в frontend/, затем в корень репозитория.

Общая область shared

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

ts
shared: ['react', 'react-dom', 'react-router', 'react-router-dom'],

Именно это генерирует tv-sdk init-module. react-router и react-router-dom нужны для того, чтобы <Routes>, <Link> и useNavigate() внутри вашего Shell видели <BrowserRouter>, который смонтировал Building OS: модуль со своей копией роутера получает собственный контекст и падает. Указывайте обе библиотеки или ни одной — react-router-dom реэкспортирует react-router.

Что на самом деле делают опции

Building OS и модули используют @originjs/vite-plugin-federation, а большая часть написанного о Module Federation — в том числе в старых репозиториях модулей — описывает реализацию webpack, а не эту. Проверено по коду плагина (1.4.1):

ОпцияВ этом плагине
requiredVersionЕдинственная, которая что-то делает. Не задана или false — берётся копия хоста, какой бы версии она ни была. Задан диапазон — копия хоста берётся, только если удовлетворяет ему, иначе молча используется ваша собственная.
singletonИгнорируется. Закомментирована в типах плагина, в рантайме отсутствует.
eagerИгнорируется, аналогично.
strictVersionИгнорируется, аналогично.

Поэтому массив выше и длинная форма из старых модулей — { singleton: true, eager: true, requiredVersion: false, strictVersion: false } — работают одинаково. Предпочитайте массив: он не обещает гарантий, которых плагин не даёт.

Практическое следствие: совпадение версий никто не проверяет. Не задавать requiredVersion — правильно: оболочка обновляется по своему графику, и привязываться к нему не нужно. Но это же означает, что модуль, собранный под React 18, без единого предупреждения при сборке запустится на React 19 оболочки и упадёт при рендере. requiredVersion этого тоже не исправит: при несовпадении плагин молча загрузит вашу копию — а это снова два React. Защита — smoke-тест интеграции с оболочкой в CI (ниже), который монтирует собранный модуль против реальных версий оболочки.

Имя из shared, которого нет в package.json, ломает сборку

Could not resolve entry module "zustand". Плагин делает каждое имя из shared точкой входа сборки — это запасная копия на случай, если хост её не предоставит, — поэтому пакет должен быть установлен. К eager это отношения не имеет (с массивом происходит то же самое). Добавляйте запись тогда, когда добавляете зависимость, — не раньше.

Алиас use-sync-external-store — обязателен

Этот пункт не опционален и не очевиден.

react-i18next начиная с 16-й версии зависит от CJS-заглушки use-sync-external-store/shim, внутри которой выполняется require('react'). Плагин федерации переписывает декларации импорта в общую область, но не умеет переписывать require. Rollup разрешает этот require в настоящий пакет react — и ваш remote увозит с собой вторую полноценную копию React. Первый же хук, прошедший через неё внутри рендера хоста, падает:

Cannot read properties of null (reading 'useSyncExternalStore')

Диспетчер второй копии равен null, потому что рендером владеет React хоста. Так ведёт себя любая зависимость, которая делает CJS-require React, — react-i18next просто добралась до платформы первой.

Лечение — локальная ESM-заглушка плюс алиас. Заглушка целиком:

ts
// src/shims/use-sync-external-store-shim.ts
// Federation-safe replacement for the CJS `use-sync-external-store/shim`.
// NOTE: this must be an import-then-export, NOT `export { x } from 'react'`.
import { useSyncExternalStore } from 'react';

export { useSyncExternalStore };
ts
// vite.config.ts
resolve: {
  alias: {
    'use-sync-external-store/shim': path.resolve(
      __dirname,
      './src/shims/use-sync-external-store-shim.ts',
    ),
  },
},

Однострочный ре-экспорт не работает

export { useSyncExternalStore } from 'react' выглядит эквивалентно, но таковым не является. Плагин федерации переписывает только декларации импорта; декларация ре-экспорта уходит к обычному резолверу, попадает в упакованную CJS-копию React и возвращает ровно тот дефект, ради которого заглушка и написана. Сначала импорт, потом экспорт.

Сохраняйте алиас, даже если делите i18n (см. ниже). Он страхует запасной путь: если хост откажется делиться (несовпадение диапазона версий, более старая оболочка), ваш remote откатится на собственную копию — и без алиаса этот откат не деградирует, а падает.

i18n: делить обе библиотеки или ни одной

i18next и react-i18next попадают в shared вместе или никак. Никогда по одной.

Разрешены две модели. Выбирайте осознанно.

Модель 1 — изолированная сборка (по умолчанию)

Ни одна из библиотек не делится. Модуль владеет обеими копиями и собственным экземпляром i18next в src/i18n.ts, а за языком оболочки следует через resolveLocale() из @tv/extension-sdk/i18n, а не через общий экземпляр. Именно это генерирует init-module и именно так устроен tv-module-example (репозиторий закрытый, копия предоставляется по запросу — developers@tango.vision).

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

Модель 2 — общий экземпляр, обе библиотеки

i18next и react-i18next обе в shared, а ваш слой i18n не создаёт свой экземпляр, а подключается к хостовому: регистрирует каталоги в собственном пространстве имён через addResourceBundle и закрывает init() проверкой isInitialized.

ts
if (!i18n.isInitialized) {
  void i18n.use(initReactI18next).init({ resources: {}, lng: detectLanguage() /* … */ });
}
// Регистрация стоит ВНЕ условия: когда экземпляром владеет хост, init() здесь
// не выполняется — и init({ resources }) не зарегистрировал бы ничего.
i18n.addResourceBundle('en', NS, en, true, true);
i18n.addResourceBundle('ru', NS, ru, true, true);

Скопируйте frontend/src/i18n/index.ts из tv-module-bim — запросите его у нас, репозиторий закрытый — и не изобретайте условие заново: весь фокус в порядке строк, и ошибиться в нём незаметно очень легко.

Запрещённая форма и почему

Поделиться только react-i18next, оставив локальный i18next, — «очевидное» лечение падения выше. Оно хуже самого падения.

Каждый модуль инициализируется через экземпляр по умолчанию react-i18next: i18n.use(initReactI18next) выставляет глобальный дефолт внутри той копии, которую импортировал вызывающий код. Если поделиться только react-i18next, ваш initReactI18next отработает по хостовой копии и заменит экземпляр по умолчанию для оболочки и всех остальных модулей на странице.

Симптом получается наихудший из возможных: ваш модуль отрисовывается корректно, а оболочка вокруг него скатывается к сырым ключам i18n (structure.title, structure.selectBuilding, …). Если модулей загружено несколько, страницу забирает тот, кто инициализировался последним. Со стороны это выглядит как баг оболочки — при полностью «здоровом» модуле.

Во что это обошлось однажды

27 августа 2026 года мажорные обновления react-i18next (15 → 17) от Dependabot были влиты в одиннадцать репозиториев модулей одной пачкой. Все обычные сигналы были зелёными: установка, проверка типов, тесты, сборка образа, публикация образа. Все одиннадцать упали при монтировании внутри оболочки, часть оставалась сломанной двое суток. Все откатили в тот же день.

Ни один CI отдельно взятого модуля поймать это не мог: дефект существует только тогда, когда импорты remote разрешаются в общую область, предоставленную хостом. Ровно для этого и нужна проверка ниже.

Smoke-проверка интеграции с оболочкой

shell-smoke собирает ваш настоящий remoteEntry.js, строит общую область ровно так, как это делает рантайм федерации в оболочке, и монтирует ваш ./Shell под хостовым React в jsdom. После чего проверяет четыре вещи: экспорт загрузился; отрисовался без срабатывания error boundary; отрисовал хоть что-то; и глобальное состояние хоста пережило вашу инициализацию — последнее и есть проверка на подмену i18n.

Подключите в задание фронтенд-проверок, после сборки:

yaml
      - name: Build federation remote
        working-directory: frontend
        run: pnpm build

      - name: Shell-integration smoke (report-only)
        uses: tangovision/infrastructure/.github/actions/shell-smoke@master
        with:
          dist: frontend/dist
          mode: warn

Запускать строго после сборки: проверка монтирует dist/assets/remoteEntry.js, а не исходники. Сегодня по всему парку модулей mode стоит в warn (только отчёт); в error его переведут, когда проверка станет зелёной на всех основных ветках.

Чего зелёный прогон не доказывает

  • jsdom — не браузер. Canvas, вёрстка и другие отсутствующие API могут падать здесь и работать в Chrome — или проходить здесь и ломаться на подменённом API.
  • Сетевые импорты подменяются, а не выполняются. import('https://cdn…') заменяется пустым модулем и логируется. Проверка ничего не говорит о том, работает ли код с CDN, — лучше положите его в репозиторий.
  • Один экспорт за прогон. Только ./Shell, если не передать другие.
  • Реестр — третий источник remote. Оболочка загружает remote ещё и по URL из реестра платформы; про них проверка не знает ничего.

Обновление общей runtime-зависимости

Мажорное обновление чего угодно из общей области оболочки — react, react-dom, react-router, react-router-dom, @tanstack/react-query, zustand, i18next, react-i18next, react-oidc-context, oidc-client-ts — способно сломаться только внутри оболочки, то есть ровно там, куда ваш CI не видит.

Вливайте такое обновление только при наличии зелёной smoke-проверки и по одному репозиторию за раз. Никогда синхронной пачкой по всем модулям: это и есть форма инцидента 27 августа 2026 года, которая превращает один отлаживаемый сбой в одиннадцать одновременных.

→ Далее: Тестирование

Создано на платформе Tango Vision. Вопросы? developers@tango.vision